diff --git a/README.md b/README.md
index d1202f4..5755969 100644
--- a/README.md
+++ b/README.md
@@ -1,73 +1,447 @@
+
+
+
+
+
+
-Laboratorio interactivo construido con Python 3.13 + Tkinter. Reรบne scraping, monitorizaciรณn, chat TCP, alarmas, reproductor musical y utilidades varias en una รบnica aplicaciรณn de escritorio.
+๐ฃ Minesweeper Multiplayer + Dashboard
-## ๐ฌ Demo en video
+
+ Un proyecto completo de programaciรณn de servicios y procesos
+ Juego de buscaminas competitivo en red + Panel de control integral
+
-- YouTube: https://youtu.be/HgJwU_HagD8
-- Drive: https://drive.google.com/file/d/14wGkkyZ9ASbV__O2xp1zIZJxSA6gpJoF/view?usp=sharing
+
+ Caracterรญsticas โข
+ Arquitectura โข
+ Instalaciรณn โข
+ Uso โข
+ Mecรกnicas โข
+ Tecnologรญas
+
-## Foto
+---
-
+## ๐ธ Vista Previa
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ ๐ฎ MINESWEEPER MULTIPLAYER DASHBOARD โ
+โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโค
+โ โ โ โโโโโโโโโโโโโโโโโโโ โ
+โ ACCIONES โ รREA DE RESULTADOS โ โ ๐ฃ MINESWEEPER โ โ
+โ โโโโโโโโโโ โ โ โ MULTIPLAYER โ โ
+โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโค โ
+โ > Wallapop โ โ ๐ Monitor Sistema โ โ โ Ronda: 3 โ โ
+โ > Scraping โ โ ๐ CPU: 45% โ โ โ ๐ฃ Bombas: 9 โ โ
+โ > API Tiempo โ โ ๐พ RAM: 2.1GB โ โ โ โค๏ธ Vidas: 2 โ โ
+โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโค โ
+โ APPS โ โ โ โโโโโฌโโโโฌโโโโ โ โ
+โ โโโโโโโโโโ โ Tabs: [Resultados][Navegador] โ โ โ โข โ โข โ โ โ โ โ
+โ > VS Code โ [Correos][Bloc][Tareas] โ โ โโโโโผโโโโผโโโโค โ โ
+โ > Camellos โ [Alarmas][Enlaces] โ โ โ ๐ฅโ โข โ โข โ โ โ
+โ โ โ โ โโโโโดโโโโดโโโโ โ โ
+โ BATCH โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโค โ
+โ โโโโโโโโโโ โ โ ๐ Panel de Notas โ โ โ [Iniciar Juego] โ โ
+โ > Backups โ โ โ โ โ [Zona Despejada]โ โ
+โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโ โ
+โโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโ
+```
-## ๐ Caracterรญsticas clave
+---
-- **Layout triple panel**: accesos rรกpidos a la izquierda, notebook central con pestaรฑas temรกticas y panel derecho para chat y utilidades.
-- **Scraping Wallapop + genรฉrico**: asistentes emergentes, validaciones y guardado de resultados.
-- **Monitor de sistema**: grรกficas en vivo de CPU, RAM e hilos gracias a psutil y matplotlib embebido.
-- **Productividad integrada**: bloc de notas, gestor de alarmas, reproductor musical con pygame y lanzadores de procesos.
-- **Popup meteorolรณgico**: consulta OpenWeather para Jรกvea con cachรฉ y resumen formateado.
-- **Servidor TCP incluido**: `servidor.py` permite pruebas de chat broadcast desde la propia app.
+## โจ Caracterรญsticas
-## โ๏ธ Requisitos
+### ๐ฎ **Juego Minesweeper Multijugador**
+| Caracterรญstica | Descripciรณn |
+|----------------|-------------|
+| ๐ฅ **Competitivo** | 2+ jugadores compiten en tiempo real |
+| ๐ฃ **Colocaciรณn estratรฉgica** | Cada jugador coloca bombas para el rival |
+| ๐ **Por turnos** | Sistema de turnos para colocar y buscar |
+| ๐ **Dificultad progresiva** | 5 rondas con grids y bombas crecientes |
+| โค๏ธ **Sistema de vidas** | 3 vidas por partida, ยกno las pierdas! |
-- Python 3.8 o superior (desarrollado en 3.13)
-- Dependencias listadas en `requirements.txt`
+### ๐ **Dashboard Integral**
+- ๐ก **Monitor del sistema** en tiempo real (CPU, RAM, hilos)
+- ๐ค๏ธ **API del tiempo** para Jรกvea (OpenWeather)
+- ๐ **Anรกlisis de Wallapop** - Scraping de anuncios
+- โฐ **Sistema de alarmas** programables
+- ๐ **Bloc de notas** integrado
+- ๐ **Gestor de enlaces** rรกpidos
+- ๐ฒ **Minijuego de camellos** con animaciones
-```sh
+---
+
+## ๐๏ธ Arquitectura
+
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ ARQUITECTURA DEL SISTEMA โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โ SERVIDOR TCP โ
+ โ servidor.py โ
+ โ โโโโโโโโโโโโโโโโโโโโ โ
+ โ โ GameServer โ โ
+ โ โ - Estado juego โ โ
+ โ โ - Broadcast โ โ
+ โ โ - Turnos โ โ
+ โ โโโโโโโโโโโโโโโโโโโโ โ
+ โ Puerto 3333 โ
+ โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
+ โ
+ โโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโ
+ โ Protocolo JSON โ
+ โ sobre TCP/IP โ
+ โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโ
+ โโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโ
+ โ โ โ
+ โผ โผ โผ
+โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ
+โ CLIENTE 1 โ โ CLIENTE 2 โ โ CLIENTE N โ
+โ โ โ โ โ โ
+โ โโโโโโโโโโโโโโ โ โ โโโโโโโโโโโโโโ โ โ โโโโโโโโโโโโโโ โ
+โ โ app.py โ โ โ โ app.py โ โ โ โ cliente_ โ โ
+โ โ Dashboard โ โ โ โ Dashboard โ โ โ โ juego.py โ โ
+โ โ + Juego โ โ โ โ + Juego โ โ โ โ Standalone โ โ
+โ โโโโโโโโโโโโโโ โ โ โโโโโโโโโโโโโโ โ โ โโโโโโโโโโโโโโ โ
+โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ
+```
+
+### ๐ Estructura del Proyecto
+
+```
+Proyecto1AVApsp/
+โ
+โโโ ๐ servidor.py # Servidor TCP del juego (371 lรญneas)
+โ โโโ GameServer # Gestiona estado, turnos y broadcasts
+โ
+โโโ ๐ app.py # Dashboard principal (2566 lรญneas)
+โ โโโ DashboardApp # Aplicaciรณn Tkinter completa
+โ โโโ GameClient # Cliente TCP integrado
+โ
+โโโ ๐ cliente_juego.py # Cliente standalone (220 lรญneas)
+โ โโโ GameClient # Versiรณn ligera para jugar
+โ
+โโโ ๐ requirements.txt # Dependencias Python
+โโโ ๐ README.md # Este archivo
+```
+
+---
+
+## ๐ Instalaciรณn
+
+### Requisitos Previos
+- **Python 3.8+**
+- **pip** (gestor de paquetes)
+
+### Paso 1: Clonar el Repositorio
+```bash
+git clone https://github.com/MarcosFerrandiz/Proyecto1AVApsp.git
+cd Proyecto1AVApsp
+```
+
+### Paso 2: Crear Entorno Virtual (Recomendado)
+```bash
+python -m venv .venv
+
+# Linux/macOS
+source .venv/bin/activate
+
+# Windows
+.venv\Scripts\activate
+```
+
+### Paso 3: Instalar Dependencias
+```bash
pip install -r requirements.txt
```
-## โถ๏ธ Puesta en marcha
+
+๐ฆ Ver dependencias detalladas
-1. (Opcional) Inicia el servidor de chat:
- ```sh
- python3 servidor.py
- ```
-2. Lanza el panel principal:
- ```sh
- python3 app.py
- ```
-3. Usa el panel izquierdo para abrir scraping, notas, alarmas o el popup del clima; el panel derecho gestiona el chat y el reproductor.
+| Paquete | Versiรณn | Uso |
+|---------|---------|-----|
+| `psutil` | โฅ5.9.0 | Monitor de recursos del sistema |
+| `matplotlib` | โฅ3.5.0 | Grรกficos en tiempo real |
+| `pillow` | โฅ9.0.0 | Procesamiento de imรกgenes |
+| `pygame` | โฅ2.1.0 | Reproducciรณn de audio (opcional) |
+| `requests` | โฅ2.32.0 | Peticiones HTTP (API, Scraping) |
+| `beautifulsoup4` | โฅ4.12.0 | Parsing HTML (Scraping) |
-## ๐งฑ Estructura del proyecto
+
+
+---
+
+## ๐ฏ Uso
+
+### ๐ฅ๏ธ Iniciar el Servidor
+```bash
+python servidor.py
+```
+> El servidor escucharรก en `0.0.0.0:3333`
+
+### ๐ฎ Opciรณn A: Dashboard Completo
+```bash
+python app.py
+```
+Incluye el juego integrado + todas las funcionalidades del panel.
+
+### ๐ฎ Opciรณn B: Cliente Standalone
+```bash
+python cliente_juego.py
+```
+Cliente ligero solo para jugar al Minesweeper.
+
+### ๐ Conectar al Juego
+1. Introduce el **Host** del servidor (por defecto: `127.0.0.1`)
+2. Verifica el **Puerto** (por defecto: `3333`)
+3. Pulsa **"Conectar"**
+4. ยกEspera a otro jugador y pulsa **"Iniciar Juego"**!
+
+---
+
+## ๐ฃ Mecรกnicas del Juego
+
+### ๐ Flujo de una Partida
```
-app.py # GUI principal y lรณgica de negocio
-servidor.py # Servidor TCP broadcast para el chat
-requirements.txt # Lista de dependencias
-README.md # Documentaciรณn
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ FLUJO DEL JUEGO โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ โ
+โ โโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
+โ โ LOBBY โโโโโโถโ COLOCACIรN โโโโโโถโ BรSQUEDA โ โ
+โ โ โ โ DE BOMBAS โ โ (PLAYING) โ โ
+โ โโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโฌโโโโโโโ โ
+โ โ โ โ
+โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
+โ โ โ โ
+โ โ โผ โ
+โ โ โโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโ โ
+โ โ โ ยฟBOMBA? โโโโโถโ EXPLOSIรNโโโโโถโ ยฟVIDAS=0? โ โ
+โ โ โโโโโโฌโโโโโ โโโโโโโโโโโโ โโโโโโโฌโโโโโโโ โ
+โ โ โ NO โ โ
+โ โ โผ Sร โผ โ
+โ โ โโโโโโโโโโโ โโโโโโโโโโโโ โ
+โ โ โ SAFE โ โ GAME OVERโ โ
+โ โ โ(casilla โ โโโโโโโโโโโโ โ
+โ โ โ segura) โ โ
+โ โ โโโโโโฌโโโโโ โ
+โ โ โ โ
+โ โ โผ โ
+โ โ โฟโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
+โ โ โ SIGUIENTE TURNO โ โ
+โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
+โ โ โ
+โ โโโโโโโโโโโโโโโโโโโโโถ (Nueva Partida) โ
+โ โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
```
-## ๐ ๏ธ Flujos destacados
+### ๐ Progresiรณn de Dificultad
-- **Scraping**: workers en segundo plano con colas y retroalimentaciรณn visual en la pestaรฑa de resultados.
-- **Copias de seguridad**: selecciรณn interactiva de carpetas y reporte final con totales copiados/omitidos.
-- **Gestor de alarmas**: creaciรณn, cancelaciรณn y popups dedicados con recordatorio sonoro.
-- **Panel de recursos**: mezcla de grรกficas lineales, de รกrea y de barras para CPU/RAM/hilos.
-- **Popup โAPI Tiempoโ**: resumen meteorolรณgico en ventana modal con refresco bajo demanda.
+| Ronda | Tamaรฑo Grid | Bombas/Jugador | Total Bombas* |
+|:-----:|:-----------:|:--------------:|:-------------:|
+| 1๏ธโฃ | 3ร3 | 3 | 6 |
+| 2๏ธโฃ | 5ร5 | 5 | 10 |
+| 3๏ธโฃ | 8ร8 | 9 | 18 |
+| 4๏ธโฃ | 11ร11 | 12 | 24 |
+| 5๏ธโฃ+ | 14ร14 | 15 | 30 |
-## โ๏ธ Configuraciรณn opcional
+*\*Para 2 jugadores*
-- `OPENWEATHER_API_KEY` / `OPENWEATHER_FALLBACK_API_KEY`: claves para OpenWeather.
-- Variables `WALLAPOP_*`: encabezados y parรกmetros usados por el scraper.
-- Ajusta el host/puerto del chat en el panel derecho o modificando `SERVER_HOST_DEFAULT` y `SERVER_PORT_DEFAULT` en `app.py`.
+### ๐ฏ Fases del Juego
-## ๐ Prรณximos pasos sugeridos
+#### 1. ๐ฃ Fase de Colocaciรณn (`PLACING`)
+- Cada jugador coloca bombas **por turnos**
+- Las bombas se muestran brevemente (1 segundo) a todos
+- ยกMemoriza dรณnde pones TUS bombas y las del rival!
-1. Aรฑadir almacenamiento persistente (SQLite) para chats y notas.
-2. Incorporar pruebas unitarias para scraping y rutinas de backup.
-3. Extender el reproductor musical con colas y visualizaciones.
+#### 2. ๐ Fase de Bรบsqueda (`PLAYING`)
+- Excava casillas por turnos
+- **Casilla segura** โ Se marca en verde โ
+- **Bomba** โ ยกEXPLOSIรN! Pierdes 1 vida ๐
-ยฟQuieres ampliar alguna secciรณn (scraping extra, nuevos paneles, automatizaciรณn de tareas)? Adelante, la base estรก lista para seguir creciendo.
+### ๐ Condiciones de Victoria
+
+| Condiciรณn | Resultado |
+|-----------|-----------|
+| Rival pierde todas las vidas | **ยกGANASTE!** ๐ |
+| Todas las casillas seguras reveladas | **ยกZona despejada!** |
+| Superas la ronda 5 | **ยกVictoria total!** ๐ |
+
+---
+
+## ๐ก Protocolo de Comunicaciรณn
+
+### Mensajes JSON Cliente โ Servidor
+
+```javascript
+// Iniciar partida
+{ "type": "START_GAME" }
+
+// Colocar bomba (fase PLACING)
+{ "type": "PLACE_BOMB", "x": 2, "y": 1 }
+
+// Excavar casilla (fase PLAYING)
+{ "type": "CLICK_CELL", "x": 3, "y": 4 }
+
+// Verificar zona despejada
+{ "type": "CHECK_DUNGEON_CLEARED" }
+```
+
+### Mensajes JSON Servidor โ Clientes
+
+```javascript
+// Nueva ronda
+{
+ "type": "NEW_ROUND",
+ "round": 1,
+ "grid_size": 3,
+ "total_bombs_per_player": 3
+}
+
+// Notificaciรณn de turno
+{
+ "type": "TURN_NOTIFY",
+ "active_player": "('127.0.0.1', 54321)",
+ "msg": "Turno de ... para poner bombas."
+}
+
+// Flash de bomba (visible 1 segundo)
+{
+ "type": "BOMB_flash",
+ "x": 1, "y": 2,
+ "who": "('127.0.0.1', 54321)"
+}
+
+// Explosiรณn
+{
+ "type": "EXPLOSION",
+ "x": 1, "y": 2,
+ "who": "...",
+ "lives": 2
+}
+
+// Casilla segura
+{ "type": "SAFE", "x": 0, "y": 0 }
+
+// Game Over
+{
+ "type": "GAME_OVER",
+ "loser": "...",
+ "msg": "๐ ยก... ha perdido todas sus vidas!"
+}
+```
+
+---
+
+## ๐ ๏ธ Tecnologรญas
+
+
+
+
+
+ Python 3
+ Lenguaje base
+ |
+
+
+ Tkinter
+ Interfaz grรกfica
+ |
+
+
+ TCP Sockets
+ Comunicaciรณn en red
+ |
+
+
+
+
+ Matplotlib
+ Grรกficos
+ |
+
+
+ JSON
+ Protocolo mensajes
+ |
+
+
+ Requests
+ APIs HTTP
+ |
+
+
+
+### ๐งต Conceptos de PSP Aplicados
+
+| Concepto | Implementaciรณn |
+|----------|----------------|
+| **Procesos** | Lanzamiento de aplicaciones externas (VS Code, Firefox) |
+| **Threads** | Servidor multihilo, cliente con hilos de recepciรณn |
+| **Sockets TCP** | Comunicaciรณn cliente-servidor en red |
+| **Servicios** | API OpenWeather, scraping de Wallapop |
+| **Sincronizaciรณn** | Locks para acceso concurrente a estado compartido |
+
+---
+
+## ๐จ Caracterรญsticas del Dashboard
+
+### ๐ Monitor del Sistema
+- Grรกfico de CPU en lรญnea temporal
+- Grรกfico de memoria como รกrea
+- Contador de hilos del proceso
+
+### ๐ค๏ธ API del Tiempo (Jรกvea)
+- Temperatura actual y sensaciรณn tรฉrmica
+- Humedad y velocidad del viento
+- Descripciรณn del clima
+
+### ๐ Anรกlisis Wallapop
+- Extracciรณn de informaciรณn de anuncios
+- Headers personalizados para API
+- Resultados formateados
+
+### โฐ Sistema de Alarmas
+- Programaciรณn en minutos
+- Notificaciones visuales
+- Gestiรณn de alarmas activas
+
+---
+
+## ๐ค Contribuir
+
+1. **Fork** del proyecto
+2. Crea tu **Feature Branch** (`git checkout -b feature/NuevaFuncion`)
+3. **Commit** tus cambios (`git commit -m 'Add: Nueva funciรณn'`)
+4. **Push** a la rama (`git push origin feature/NuevaFuncion`)
+5. Abre un **Pull Request**
+
+---
+
+## ๐ Licencia
+
+Este proyecto es de carรกcter **educativo** y fue desarrollado como parte del mรณdulo de **Programaciรณn de Servicios y Procesos**.
+
+---
+
+
+ Desarrollado con โค๏ธ por Marcos Ferrandiz
+
+
+
+ Proyecto 1ยบ Evaluaciรณn - PSP (Programaciรณn de Servicios y Procesos)
+
+
+---
+
+
+
+
+
+
diff --git a/app.py b/app.py
index 712d084..82e0a66 100644
--- a/app.py
+++ b/app.py
@@ -127,11 +127,12 @@ WALLAPOP_HEADERS = {
-class ChatClient:
- """Cliente TCP bรกsico."""
+class GameClient:
+ """Cliente TCP para el juego Minesweeper."""
- def __init__(self, on_message):
+ def __init__(self, on_message: Callable[[dict], None], on_disconnect: Callable[[], None]):
self._on_message = on_message
+ self._on_disconnect = on_disconnect
self._sock: socket.socket | None = None
self._lock = threading.Lock()
self._connected = False
@@ -145,10 +146,13 @@ class ChatClient:
sock.connect((host, port))
self._sock = sock
self._connected = True
+ # Guardar nuestra direcciรณn local
+ self.my_address = str(sock.getsockname())
+ print(f"[CLIENT] Mi direcciรณn: {self.my_address}")
threading.Thread(target=self._recv_loop, daemon=True).start()
return True
except Exception as exc:
- self._on_message(f'[ERROR] Conexiรณn fallida: {exc}')
+ print(f"[ERROR] Conexiรณn fallida: {exc}")
return False
def _recv_loop(self):
@@ -156,11 +160,29 @@ class ChatClient:
while self._connected and self._sock:
data = self._sock.recv(4096)
if not data:
+ print("[CLIENT] No data received, connection closed")
break
- text = data.decode('utf-8', errors='replace').strip()
- self._on_message(text)
+ try:
+ text_chunk = data.decode('utf-8', errors='replace')
+ print(f"[CLIENT] Received: {text_chunk[:200]}") # Debug: primeros 200 chars
+ # Split by newline for robust framing
+ lines = text_chunk.split('\n')
+ # Note: this simple split might break if a message is split across recv calls.
+ # For a robust production app we need a buffer.
+ # Assuming short JSONs for now.
+ for line in lines:
+ line = line.strip()
+ if not line: continue
+ try:
+ msg = json.loads(line)
+ print(f"[CLIENT] Parsed message: {msg.get('type')}")
+ self._on_message(msg)
+ except json.JSONDecodeError as e:
+ print(f"[CLIENT] JSON decode error: {e}, line: {line[:100]}")
+ except Exception as e:
+ print(f"[CLIENT] Exception in recv: {e}")
except Exception as exc:
- self._on_message(f'[ERROR] {exc}')
+ print(f"[ERROR] Recv: {exc}")
finally:
with self._lock:
self._connected = False
@@ -170,14 +192,16 @@ class ChatClient:
except Exception:
pass
self._sock = None
- self._on_message('[INFO] Conexiรณn cerrada')
+ print("[CLIENT] Calling _on_disconnect")
+ self._on_disconnect()
- def send(self, text: str) -> bool:
+ def send(self, data: dict) -> bool:
with self._lock:
if not self._connected or not self._sock:
return False
try:
- self._sock.sendall(text.encode('utf-8'))
+ msg = json.dumps(data) + '\n'
+ self._sock.sendall(msg.encode('utf-8'))
return True
except Exception:
self._connected = False
@@ -211,14 +235,17 @@ class DashboardApp(tk.Tk):
self.rowconfigure(1, weight=1)
self.rowconfigure(2, weight=0)
+
self._running = True
- self._chat_queue: 'queue.Queue[str]' = queue.Queue()
+ self._game_queue: 'queue.Queue[dict]' = queue.Queue()
self._scraping_queue: 'queue.Queue[tuple[str, ...]]' = queue.Queue()
self._traffic_last = psutil.net_io_counters() if psutil else None
self._resource_history = {'cpu': [], 'mem': [], 'threads': []}
self._resource_poll_job: str | None = None
- self.chat_client = ChatClient(self._enqueue_chat_message)
+ self.game_client = GameClient(self._enqueue_game_message, self._on_game_disconnect)
+ self._game_phase = 'LOBBY' # LOBBY, PLACING, PLAYING
+ self.grid_buttons = {} # (x, y) -> tk.Button
self.alarm_counter = 1
self.active_alarms: list[dict[str, datetime.datetime | str]] = []
self.game_window_canvas = None
@@ -267,7 +294,7 @@ class DashboardApp(tk.Tk):
except Exception:
pass
self._resource_poll_job = self.after(1000, self._resource_poll_tick)
- threading.Thread(target=self._chat_loop, daemon=True).start()
+ threading.Thread(target=self._game_loop, daemon=True).start()
self.after(100, self._process_scraping_queue)
self.after(1000, self._refresh_alarms_loop)
@@ -699,61 +726,385 @@ class DashboardApp(tk.Tk):
self.notes.pack(fill='both', expand=True, padx=6, pady=6)
def _build_right_panel(self) -> None:
- right = tk.Frame(self, width=280, bg=PANEL_BG, bd=0)
+ right = tk.Frame(self, width=700, bg=PANEL_BG, bd=0)
right.grid(row=1, column=2, sticky='nse', padx=6, pady=(50,6))
right.grid_propagate(False)
- tk.Label(right, text='Chat', font=('Arial', 20, 'bold'), fg='red', bg='white').pack(pady=(6,4))
- self.chat_display = scrolledtext.ScrolledText(right, width=30, height=12, state='disabled')
- self.chat_display.pack(padx=6, pady=4)
+ # Header Juego
+ self.game_header = tk.Frame(right, bg=ACCENT_COLOR, pady=8)
+ self.game_header.pack(fill='x')
+ self.game_title_label = tk.Label(self.game_header, text='๐ฎ Minesweeper Multijugador',
+ font=('Arial', 18, 'bold'), fg='white', bg=ACCENT_COLOR)
+ self.game_title_label.pack()
- tk.Label(right, text='Mensaje', bg='white').pack(anchor='w', padx=6)
- self.message_entry = tk.Text(right, height=4)
- self.message_entry.pack(padx=6, pady=4)
- tk.Button(right, text='enviar', bg='#d6f2ce', command=self._send_chat).pack(pady=4)
+ # Panel de Estado (Vidas, Ronda)
+ stats = tk.Frame(right, bg='white', pady=8)
+ stats.pack(pady=6, fill='x', padx=10)
+
+ self.lbl_round = tk.Label(stats, text='Ronda: -', font=('Arial', 14, 'bold'), bg='white', fg=TEXT_COLOR)
+ self.lbl_round.pack(side='left', padx=10)
+
+ self.lbl_bombs = tk.Label(stats, text='๐ฃ Bombas: -', font=('Arial', 14, 'bold'), fg='#e67e22', bg='white')
+ self.lbl_bombs.pack(side='left', padx=10)
+
+ self.lbl_lives = tk.Label(stats, text='โค๏ธ Vidas: 3', font=('Arial', 14, 'bold'), fg='#c44569', bg='white')
+ self.lbl_lives.pack(side='right', padx=10)
- conn = tk.Frame(right, bg='white')
- conn.pack(pady=4)
- tk.Label(conn, text='Host:', bg='white').grid(row=0, column=0)
- self.host_entry = tk.Entry(conn, width=12)
+ # Panel de Juego (Grid) - MรS GRANDE
+ self.game_frame = tk.Frame(right, bg='#eeeeee', bd=3, relief='sunken')
+ self.game_frame.pack(fill='both', expand=True, padx=10, pady=10)
+
+ # Mensaje inicial en el grid
+ tk.Label(self.game_frame, text='Conecta al servidor\npara comenzar',
+ font=('Arial', 14), bg='#eeeeee', fg=SUBTEXT_COLOR).place(relx=0.5, rely=0.5, anchor='center')
+
+ # Botones de Acciรณn - MรS GRANDES
+ actions = tk.Frame(right, bg='white', pady=6)
+ actions.pack(pady=6, fill='x', padx=10)
+
+ self.btn_game_action = tk.Button(actions, text='๐ฏ Iniciar Juego', bg='#90ee90',
+ font=('Arial', 12, 'bold'), command=self._start_game_req,
+ relief='raised', bd=3, padx=15, pady=8, state='disabled')
+ self.btn_game_action.pack(side='left', padx=5, expand=True, fill='x')
+
+ # Check Done Button
+ self.btn_check_done = tk.Button(actions, text='โ Zona Despejada', bg='#fff3cd',
+ font=('Arial', 12, 'bold'), command=self._check_dungeon_cleared,
+ relief='raised', bd=3, padx=15, pady=8, state='disabled')
+ self.btn_check_done.pack(side='left', padx=5, expand=True, fill='x')
+
+ # Log de juego - MรS GRANDE
+ log_frame = tk.LabelFrame(right, text='๐ Log del Juego', bg='white',
+ font=('Arial', 11, 'bold'), fg=TEXT_COLOR)
+ log_frame.pack(padx=10, pady=6, fill='both', expand=False)
+
+ self.game_log = scrolledtext.ScrolledText(log_frame, width=50, height=8,
+ state='normal', font=('Consolas', 10),
+ bg='#f8f9fa', fg=TEXT_COLOR)
+ self.game_log.pack(padx=6, pady=6, fill='both', expand=True)
+ self.game_log.insert('end', "๐ฎ Bienvenido al Minesweeper Multijugador\n")
+ self.game_log.insert('end', "โ" * 50 + "\n")
+ self.game_log.insert('end', "1. Conecta al servidor\n")
+ self.game_log.insert('end', "2. Espera a que otro jugador se conecte\n")
+ self.game_log.insert('end', "3. Haz clic en 'Iniciar Juego'\n")
+ self.game_log.insert('end', "โ" * 50 + "\n")
+ self.game_log.config(state='disabled')
+
+ # Conexiรณn - MEJORADO
+ conn_frame = tk.LabelFrame(right, text='๐ Conexiรณn al Servidor', bg='white',
+ font=('Arial', 11, 'bold'), fg=TEXT_COLOR)
+ conn_frame.pack(pady=6, fill='x', padx=10)
+
+ conn = tk.Frame(conn_frame, bg='white')
+ conn.pack(pady=8, padx=10)
+
+ tk.Label(conn, text='Host:', bg='white', font=('Arial', 11)).grid(row=0, column=0, sticky='e', padx=5)
+ self.host_entry = tk.Entry(conn, width=15, font=('Arial', 11))
self.host_entry.insert(0, SERVER_HOST_DEFAULT)
- self.host_entry.grid(row=0, column=1)
- tk.Label(conn, text='Puerto:', bg='white').grid(row=1, column=0)
- self.port_entry = tk.Entry(conn, width=6)
+ self.host_entry.grid(row=0, column=1, padx=5)
+
+ tk.Label(conn, text='Puerto:', bg='white', font=('Arial', 11)).grid(row=1, column=0, sticky='e', padx=5)
+ self.port_entry = tk.Entry(conn, width=8, font=('Arial', 11))
self.port_entry.insert(0, str(SERVER_PORT_DEFAULT))
- self.port_entry.grid(row=1, column=1)
- tk.Button(conn, text='Conectar', command=self._connect_chat).grid(row=0, column=2, rowspan=2, padx=4)
+ self.port_entry.grid(row=1, column=1, padx=5, sticky='w')
+
+ tk.Button(conn, text='๐ Conectar', command=self._connect_game,
+ bg='#4CAF50', fg='white', font=('Arial', 11, 'bold'),
+ relief='raised', bd=3, padx=20, pady=5).grid(row=0, column=2, rowspan=2, padx=10)
- tk.Label(right, text='Alumnos', font=('Arial', 14, 'bold'), bg='white').pack(pady=(10,4))
- for name in ('Alumno 1', 'Alumno 2', 'Alumno 3'):
- frame = tk.Frame(right, bg='white', bd=1, relief='groove')
- frame.pack(fill='x', padx=6, pady=4)
- tk.Label(frame, text=name, bg='white', font=('Arial', 11, 'bold')).pack(anchor='w')
- tk.Label(frame, text='Lorem ipsum dolor sit amet, consectetur adipiscing elit.', wraplength=220, justify='left', bg='white').pack(anchor='w')
+ # Reproductor mรบsica (mini)
+ player = tk.Frame(right, bg='#fdf5f5', bd=1, relief='flat', padx=4, pady=4)
+ player.pack(fill='x', padx=8, pady=(4,6))
+ tk.Label(player, text='๐ต Mรบsica', font=self.font_small, bg='#fdf5f5').pack(side='left')
+ tk.Button(player, text='โถ', command=self._resume_music, width=3).pack(side='left', padx=2)
+ tk.Button(player, text='||', command=self._pause_music, width=3).pack(side='left', padx=2)
+ tk.Button(player, text='๐', command=self._select_music, width=3).pack(side='left', padx=2)
- player = tk.Frame(right, bg='#fdf5f5', bd=1, relief='flat', padx=8, pady=8)
- player.pack(fill='x', padx=8, pady=(10,6))
- tk.Label(player, text='Reproductor mรบsica', font=self.font_header, bg='#fdf5f5', fg=ACCENT_DARK).pack(pady=(0,6))
+ # ------------------ Lรณgica Juego ------------------
+ def _enqueue_game_message(self, msg: dict):
+ self._game_queue.put(msg)
- def styled_btn(parent, text, command):
- return tk.Button(
- parent,
- text=text,
- command=command,
- bg='#ffe3df',
- activebackground='#ffd2ca',
- fg=TEXT_COLOR,
- relief='flat',
- width=20,
- pady=4
- )
+ def _on_game_disconnect(self):
+ self._game_queue.put({"type": "DISCONNECT"})
+
+ def _game_loop(self):
+ while self._running:
+ try:
+ msg = self._game_queue.get(timeout=0.1)
+ self.after(0, self._process_game_message, msg)
+ except queue.Empty:
+ continue
+
+ def _connect_game(self):
+ """Conecta al servidor de juego"""
+ host = self.host_entry.get().strip()
+ if not host:
+ messagebox.showerror("Error", "Debes especificar un host")
+ return
+
+ try:
+ port = int(self.port_entry.get())
+ if port < 1 or port > 65535:
+ raise ValueError("Puerto fuera de rango")
+ except ValueError:
+ messagebox.showerror("Error", "Puerto invรกlido (debe ser 1-65535)")
+ return
+
+ self._log_game(f"Conectando a {host}:{port}...")
+ try:
+ if self.game_client.connect(host, port):
+ self._log_game("โ Conectado al servidor exitosamente")
+ self.btn_game_action.config(state='normal')
+ messagebox.showinfo("Conectado", f"Conectado a {host}:{port}")
+ else:
+ self._log_game("โ Error al conectar")
+ messagebox.showerror("Error", "No se pudo conectar al servidor")
+ except Exception as e:
+ self._log_game(f"โ Excepciรณn: {e}")
+ messagebox.showerror("Error", f"Error de conexiรณn: {e}")
+
+ def _start_game_req(self):
+ print("[CLIENT] Enviando START_GAME al servidor")
+ self.game_client.send({"type": "START_GAME"})
+ self._log_game("Solicitando inicio de juego...")
+
+ def _check_dungeon_cleared(self):
+ self.game_client.send({"type": "CHECK_DUNGEON_CLEARED"})
+
+ def _log_game(self, text):
+ self.game_log.config(state='normal')
+ self.game_log.insert('end', f"> {text}\n")
+ self.game_log.see('end')
+ self.game_log.config(state='disabled')
+
+ def _process_game_message(self, msg: dict):
+ mtype = msg.get('type')
+
+ if mtype == 'DISCONNECT':
+ self._log_game("Desconectado del servidor.")
+ self._game_phase = 'LOBBY'
+ return
+
+ if mtype == 'NEW_ROUND':
+ r = msg.get('round')
+ size = msg.get('grid_size')
+ status = msg.get('status')
+ bombs_per_player = msg.get('total_bombs_per_player', 3)
+
+ # Guardar bombas restantes para el contador
+ self._bombs_remaining = bombs_per_player
+ self.lbl_bombs.config(text=f"๐ฃ Bombas: {self._bombs_remaining}")
+
+ self.lbl_round.config(text=f"Ronda: {r}")
+ self._log_game(f"=== Ronda {r} ({size}x{size}) ===")
+ self._log_game(f"Cada jugador debe poner {bombs_per_player} bombas")
+ self._log_game(status)
+ self._build_grid(size)
+ self._game_phase = 'PLACING'
+ self.btn_game_action.config(state='disabled')
+ self.btn_check_done.config(state='disabled')
+
+ elif mtype == 'TURN_NOTIFY':
+ player = msg.get('active_player')
+ text = msg.get('msg')
+ self._log_game(f"๐ฏ {text}")
+
+ # Cambiar color del header si es mi turno
+ if hasattr(self.game_client, 'my_address') and player in self.game_client.my_address:
+ self.game_header.config(bg='#4CAF50') # Verde
+ self.game_title_label.config(bg='#4CAF50', text='๐ฎ ยกTU TURNO! - Coloca Bombas')
+ else:
+ self.game_header.config(bg=ACCENT_COLOR) # Rojo
+ self.game_title_label.config(bg=ACCENT_COLOR, text='๐ฎ Esperando Turno...')
+
+ elif mtype == 'BOMB_flash':
+ x, y = msg.get('x'), msg.get('y')
+ who = msg.get('who')
+ self._log_game(f"๐ฃ {who} colocรณ una bomba")
+
+ # Decrementar contador de bombas SI es mi turno (mi bomba)
+ if hasattr(self.game_client, 'my_address') and who in self.game_client.my_address:
+ if hasattr(self, '_bombs_remaining') and self._bombs_remaining > 0:
+ self._bombs_remaining -= 1
+ self.lbl_bombs.config(text=f"๐ฃ Bombas: {self._bombs_remaining}")
+
+ btn = self._get_grid_btn(x, y)
+ if btn:
+ # Mostrar bomba temporalmente
+ orig_bg = btn.cget('bg')
+ btn.config(bg='#ff6b6b', text='๐ฃ', fg='white', font=('Arial', 14, 'bold'))
+ # Ocultar en 400ms (mรกs rรกpido)
+ def hide_bomb(b=btn, original=orig_bg):
+ try:
+ b.config(bg=original, text='', fg='black')
+ except Exception:
+ pass
+ self.after(400, hide_bomb)
+
+ elif mtype == 'PHASE_PLAY':
+ self._log_game("โ๏ธ " + msg.get('msg', 'Fase de bรบsqueda iniciada'))
+ self.btn_check_done.config(state='normal')
+ self._game_phase = 'PLAYING'
+
+ elif mtype == 'SEARCH_TURN':
+ player = msg.get('active_player')
+ text = msg.get('msg')
+ self._log_game(f"{text}")
+
+ # Cambiar color del header si es mi turno
+ if hasattr(self.game_client, 'my_address') and player in self.game_client.my_address:
+ self.game_header.config(bg='#4CAF50') # Verde
+ self.game_title_label.config(bg='#4CAF50', text='๐ฎ ยกTU TURNO! - Excava')
+ else:
+ self.game_header.config(bg=ACCENT_COLOR) # Rojo
+ self.game_title_label.config(bg=ACCENT_COLOR, text='๐ฎ Esperando Turno...')
+
+ elif mtype == 'EXPLOSION':
+ x, y = msg.get('x'), msg.get('y')
+ lives = msg.get('lives')
+ who = msg.get('who')
+ player_addr = msg.get('player_addr', who)
+ self._log_game(f"๐ฅ BOOM! {who} pisรณ una bomba (Vidas: {lives})")
+ btn = self._get_grid_btn(x, y)
+ if btn:
+ btn.config(bg='#c44569', text='๐ฅ', fg='white', font=('Arial', 16, 'bold'),
+ state='disabled', relief='sunken')
+ # SOLO actualizar vidas si es el jugador local (comparaciรณn con my_address)
+ if hasattr(self.game_client, 'my_address') and player_addr in self.game_client.my_address:
+ try:
+ self.lbl_lives.config(text=f"โค๏ธ Vidas: {lives}")
+ if lives <= 0:
+ self._log_game("โ ๏ธ ยกSin vidas! Game Over")
+ except Exception:
+ pass # App cerrรกndose
+
+ elif mtype == 'SAFE':
+ x, y = msg.get('x'), msg.get('y')
+ self._log_game(f"โ Celda ({x},{y}) segura")
+ btn = self._get_grid_btn(x, y)
+ if btn:
+ btn.config(bg='#90ee90', text='โ', fg='#2d5016', font=('Arial', 12, 'bold'),
+ state='disabled', relief='sunken')
+
+ elif mtype == 'WARNING':
+ msg_text = msg.get('msg', '')
+ self._log_game(f"โ ๏ธ {msg_text}")
+ messagebox.showwarning("Advertencia", msg_text)
+
+ elif mtype == 'ROUND_WIN':
+ msg_text = msg.get('msg', 'ยกRonda completada!')
+ self._log_game(f"๐ {msg_text}")
+ messagebox.showinfo("ยกRonda Ganada!", msg_text)
+
+ elif mtype == 'ROUND_ADVANCE':
+ msg_text = msg.get('msg', 'Pasando a la siguiente ronda...')
+ self._log_game(f"โญ๏ธ {msg_text}")
+ # Cambiar header a naranja mientras transiciona
+ self.game_header.config(bg='#FF9800')
+ self.game_title_label.config(bg='#FF9800', text='โณ Siguiente Ronda...')
+
+ elif mtype == 'GAME_WIN':
+ self._log_game("๐ ยกVICTORIA! ยกJuego completado!")
+ messagebox.showinfo("ยกVictoria Total!", "ยกHas completado todas las rondas!")
+ self._game_phase = 'LOBBY'
+ self.btn_game_action.config(state='normal')
+
+ elif mtype == 'GAME_OVER':
+ loser = msg.get('loser', '')
+ msg_text = msg.get('msg', 'ยกJuego terminado!')
+ self._log_game(f"๐ {msg_text}")
+ # Verificar si el perdedor soy yo
+ if hasattr(self.game_client, 'my_address') and loser in self.game_client.my_address:
+ self.game_header.config(bg='#c0392b') # Rojo oscuro
+ self.game_title_label.config(bg='#c0392b', text='๐ HAS PERDIDO')
+ messagebox.showerror("ยกDERROTA!", "ยกHas perdido todas tus vidas!")
+ else:
+ self.game_header.config(bg='#27ae60') # Verde
+ self.game_title_label.config(bg='#27ae60', text='๐ ยกHAS GANADO!')
+ messagebox.showinfo("ยกVICTORIA!", "ยกTu oponente ha perdido todas sus vidas!")
+ self._game_phase = 'LOBBY'
+ self.btn_game_action.config(state='normal')
+
+ def _build_grid(self, size):
+ """Construye el grid de juego dinรกmicamente segรบn el tamaรฑo de la ronda"""
+ # Limpiar grid anterior
+ for child in self.game_frame.winfo_children():
+ child.destroy()
+
+ # Configurar grid
+ for i in range(size):
+ self.game_frame.columnconfigure(i, weight=1, uniform='cell')
+ self.game_frame.rowconfigure(i, weight=1, uniform='cell')
+
+ # Calcular tamaรฑo de botรณn segรบn grid
+ # Grids mรกs grandes = botones mรกs pequeรฑos
+ if size <= 3:
+ btn_font = ('Arial', 14, 'bold')
+ btn_width = 3
+ btn_height = 1
+ elif size <= 5:
+ btn_font = ('Arial', 12, 'bold')
+ btn_width = 2
+ btn_height = 1
+ elif size <= 9:
+ btn_font = ('Arial', 10, 'bold')
+ btn_width = 2
+ btn_height = 1
+ else: # 12x12
+ btn_font = ('Arial', 8, 'bold')
+ btn_width = 1
+ btn_height = 1
+
+ self.grid_buttons = {}
+ for r in range(size):
+ for c in range(size):
+ btn = tk.Button(
+ self.game_frame,
+ bg='#e0e0e0',
+ activebackground='#d0d0d0',
+ relief='raised',
+ bd=2,
+ font=btn_font,
+ width=btn_width,
+ height=btn_height,
+ cursor='hand2'
+ )
+ btn.grid(row=r, column=c, sticky='nsew', padx=1, pady=1)
+
+ # Click handler
+ btn.config(command=lambda x=c, y=r: self._on_grid_click(x, y))
+ self.grid_buttons[(c, r)] = btn
+
+ self._log_game(f"Grid {size}x{size} creado ({size*size} celdas)")
+
+ def _on_grid_click(self, x, y):
+ """Maneja clicks en el grid segรบn la fase del juego"""
+ # Verificar que estamos conectados
+ if not self.game_client._connected:
+ messagebox.showwarning("No conectado", "Debes conectarte al servidor primero")
+ return
+
+ btn = self._get_grid_btn(x, y)
+ if not btn:
+ return
+
+ # No permitir clicks en celdas ya procesadas
+ if btn.cget('state') == 'disabled':
+ return
+
+ if self._game_phase == 'PLACING':
+ # Colocar bomba
+ self.game_client.send({"type": "PLACE_BOMB", "x": x, "y": y})
+ elif self._game_phase == 'PLAYING':
+ # Buscar/revelar celda
+ self.game_client.send({"type": "CLICK_CELL", "x": x, "y": y})
+ else:
+ # En LOBBY, no hacer nada
+ pass
+
+ def _get_grid_btn(self, x, y):
+ return self.grid_buttons.get((x, y))
- styled_btn(player, 'Seleccionar archivo', self._select_music).pack(pady=3)
- action_row = tk.Frame(player, bg='#fdf5f5')
- action_row.pack(pady=3)
- styled_btn(action_row, 'Pausa', self._pause_music).pack(side='left', padx=4)
- styled_btn(action_row, 'Reanudar', self._resume_music).pack(side='left', padx=4)
- styled_btn(player, 'Quitar', self._stop_music).pack(pady=3)
def _build_status_bar(self) -> None:
status = tk.Frame(self, bg='#f1f1f1', bd=2, relief='ridge')
@@ -2167,15 +2518,42 @@ class DashboardApp(tk.Tk):
self.notes.see('end')
def on_close(self) -> None:
+ """Cierra la aplicaciรณn correctamente"""
+ print("Cerrando aplicaciรณn...")
self._running = False
+
+ # Cancelar jobs programados
if self._resource_poll_job is not None:
try:
self.after_cancel(self._resource_poll_job)
except Exception:
pass
self._resource_poll_job = None
- self.chat_client.close()
- self.destroy()
+
+ # Cerrar cliente de juego
+ try:
+ if hasattr(self, 'game_client'):
+ self.game_client.close()
+ except Exception as e:
+ print(f"Error cerrando game_client: {e}")
+
+ # Detener mรบsica si estรก sonando
+ try:
+ if pygame and pygame.mixer.get_init():
+ pygame.mixer.music.stop()
+ pygame.mixer.quit()
+ except Exception:
+ pass
+
+ # Destruir ventana
+ try:
+ self.destroy()
+ except Exception:
+ pass
+
+ # Forzar salida si es necesario
+ import sys
+ sys.exit(0)
def main() -> None:
diff --git a/cliente_juego.py b/cliente_juego.py
new file mode 100644
index 0000000..b38a623
--- /dev/null
+++ b/cliente_juego.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+import tkinter as tk
+from tkinter import messagebox, scrolledtext, simpledialog
+import socket
+import threading
+import json
+import time
+import queue
+
+SERVER_HOST = '127.0.0.1'
+SERVER_PORT = 3333
+
+class GameClient:
+ def __init__(self, root):
+ self.root = root
+ self.root.title("Minesweeper Multiplayer - Cliente Dedicado")
+ self.root.geometry("600x700")
+
+ # Conexiรณn
+ self.sock = None
+ self.connected = False
+ self.msg_queue = queue.Queue()
+
+ # Estado UI
+ self.buttons = {}
+ self.game_phase = 'LOBBY'
+
+ self.build_ui()
+
+ # Loop de mensajes UI
+ self.root.after(100, self.process_queue)
+
+ def build_ui(self):
+ # Frame Superior (Conexiรณn)
+ conn_frame = tk.Frame(self.root, pady=5)
+ conn_frame.pack(fill='x', padx=10, pady=5)
+
+ tk.Label(conn_frame, text="Host:").pack(side='left')
+ self.ent_host = tk.Entry(conn_frame, width=15)
+ self.ent_host.insert(0, SERVER_HOST)
+ self.ent_host.pack(side='left', padx=5)
+
+ tk.Label(conn_frame, text="Port:").pack(side='left')
+ self.ent_port = tk.Entry(conn_frame, width=6)
+ self.ent_port.insert(0, str(SERVER_PORT))
+ self.ent_port.pack(side='left', padx=5)
+
+ self.btn_connect = tk.Button(conn_frame, text="Conectar", command=self.connect)
+ self.btn_connect.pack(side='left', padx=10)
+
+ self.btn_start = tk.Button(conn_frame, text="Iniciar Juego", command=self.start_game, bg='#90ee90', state='disabled')
+ self.btn_start.pack(side='right', padx=10)
+
+ # Frame Stats
+ stats_frame = tk.Frame(self.root, pady=5)
+ stats_frame.pack(fill='x', padx=20)
+ self.lbl_round = tk.Label(stats_frame, text="Ronda: -", font=('Arial', 14))
+ self.lbl_round.pack(side='left')
+ self.lbl_lives = tk.Label(stats_frame, text="Vidas: -", font=('Arial', 14, 'bold'), fg='red')
+ self.lbl_lives.pack(side='right')
+
+ # Frame Juego
+ self.game_frame = tk.Frame(self.root, bg='#cccccc')
+ self.game_frame.pack(fill='both', expand=True, padx=20, pady=10)
+
+ # Logs
+ self.log_area = scrolledtext.ScrolledText(self.root, height=8)
+ self.log_area.pack(fill='x', padx=10, pady=10)
+
+ # Botones Control
+ ctrl_frame = tk.Frame(self.root)
+ ctrl_frame.pack(pady=5)
+ self.btn_done = tk.Button(ctrl_frame, text="ยกZona Limpia!", command=self.check_cleared, bg='gold', state='disabled')
+ self.btn_done.pack()
+
+ def log(self, text):
+ self.log_area.insert('end', f"> {text}\n")
+ self.log_area.see('end')
+
+ def connect(self):
+ host = self.ent_host.get()
+ try:
+ port = int(self.ent_port.get())
+ except ValueError:
+ messagebox.showerror("Error", "Puerto invรกlido")
+ return
+
+ try:
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.sock.connect((host, port))
+ self.connected = True
+ threading.Thread(target=self.recv_loop, daemon=True).start()
+ self.log(f"Conectado a {host}:{port}")
+ self.btn_connect.config(state='disabled')
+ self.btn_start.config(state='normal')
+ except Exception as e:
+ messagebox.showerror("Error Conexiรณn", str(e))
+
+ def send(self, data):
+ if not self.connected or not self.sock:
+ return
+ try:
+ msg = json.dumps(data) + '\n'
+ self.sock.sendall(msg.encode('utf-8'))
+ except Exception as e:
+ self.log(f"Error enviando: {e}")
+
+ def recv_loop(self):
+ while self.connected:
+ try:
+ data = self.sock.recv(4096)
+ if not data: break
+ text = data.decode('utf-8', errors='replace')
+ for line in text.split('\n'):
+ line = line.strip()
+ if not line: continue
+ try:
+ self.msg_queue.put(json.loads(line))
+ except: pass
+ except:
+ break
+ self.connected = False
+ self.msg_queue.put({"type": "DISCONNECT"})
+
+ def process_queue(self):
+ while not self.msg_queue.empty():
+ msg = self.msg_queue.get()
+ self.handle_message(msg)
+ self.root.after(100, self.process_queue)
+
+ def handle_message(self, msg):
+ mtype = msg.get('type')
+
+ if mtype == 'DISCONNECT':
+ self.log("Desconectado del servidor.")
+ self.btn_connect.config(state='normal')
+ self.btn_start.config(state='disabled')
+ return
+
+ if mtype == 'NEW_ROUND':
+ r = msg.get('round')
+ size = msg.get('grid_size')
+ self.lbl_round.config(text=f"Ronda: {r}")
+ self.log(f"--- NUEVA RONDA {r} ---")
+ self.game_phase = 'PLACING'
+ self.build_grid(size)
+ self.btn_done.config(state='disabled')
+
+ elif mtype == 'TURN_NOTIFY':
+ self.log(msg.get('msg', 'Es tu turno'))
+
+ elif mtype == 'BOMB_flash':
+ x, y = msg.get('x'), msg.get('y')
+ who = msg.get('who')
+ self.log(f"Bomba puesta por {who}")
+ btn = self.buttons.get((x,y))
+ if btn:
+ orig = btn.cget('bg')
+ btn.config(bg='orange', text='๐ฃ')
+ self.root.after(1000, lambda b=btn: b.config(bg=orig, text=''))
+
+ elif mtype == 'PHASE_PLAY':
+ self.game_phase = 'PLAYING'
+ self.log(msg.get('msg'))
+ self.btn_done.config(state='normal')
+
+ elif mtype == 'EXPLOSION':
+ x, y = msg.get('x'), msg.get('y')
+ lives = msg.get('lives')
+ who = msg.get('who')
+ self.log(f"EXPLOSIรN de {who}!")
+ self.lbl_lives.config(text=f"Vidas: {lives}")
+ btn = self.buttons.get((x,y))
+ if btn: btn.config(bg='red', text='๐ฅ')
+
+ elif mtype == 'SAFE':
+ x, y = msg.get('x'), msg.get('y')
+ btn = self.buttons.get((x,y))
+ if btn: btn.config(bg='lightgreen', relief='sunken')
+
+ elif mtype == 'WARNING':
+ self.log(f"[AVISO] {msg.get('msg')}")
+
+ elif mtype == 'ROUND_WIN':
+ self.log(f"GANADOR: {msg.get('msg')}")
+ messagebox.showinfo("Ronda", msg.get('msg'))
+
+ elif mtype == 'GAME_WIN':
+ messagebox.showinfo("Victoria", "Juego Completado!")
+
+ def build_grid(self, size):
+ for child in self.game_frame.winfo_children():
+ child.destroy()
+ self.buttons = {}
+ for r in range(size):
+ self.game_frame.rowconfigure(r, weight=1)
+ self.game_frame.columnconfigure(r, weight=1)
+ for c in range(size):
+ btn = tk.Button(self.game_frame, bg='#dddddd')
+ btn.grid(row=r, column=c, sticky='nsew', padx=1, pady=1)
+ btn.config(command=lambda x=c, y=r: self.on_click(x,y))
+ self.buttons[(c,r)] = btn
+
+ def on_click(self, x, y):
+ if self.game_phase == 'PLACING':
+ self.send({"type": "PLACE_BOMB", "x": x, "y": y})
+ elif self.game_phase == 'PLAYING':
+ self.send({"type": "CLICK_CELL", "x": x, "y": y})
+
+ def start_game(self):
+ self.send({"type": "START_GAME"})
+
+ def check_cleared(self):
+ self.send({"type": "CHECK_DUNGEON_CLEARED"})
+
+if __name__ == "__main__":
+ root = tk.Tk()
+ app = GameClient(root)
+ root.mainloop()
diff --git a/servidor.py b/servidor.py
index a7d89c4..409cb0d 100644
--- a/servidor.py
+++ b/servidor.py
@@ -1,84 +1,370 @@
#!/usr/bin/env python3
"""
-Servidor de mensajerรญa simple (broadcast) - puerto 3333
-
-Ejecutar en un terminal separado:
- python3 servidor.py
-
+Servidor de juego Minesweeper Multiplayer
+Protocolo basado en JSON sobre TCP.
"""
import socket
import threading
+import json
+import time
+import random
HOST = '0.0.0.0'
PORT = 3333
-clients = []
-clients_lock = threading.Lock()
+# Estados del juego
+STATE_LOBBY = 'LOBBY'
+STATE_PLACING = 'PLACING' # Jugadores ponen bombas por turnos
+STATE_PLAYING = 'PLAYING' # Jugadores buscan
-def broadcast(message: bytes, sender: socket.socket):
- with clients_lock:
- for client in list(clients):
- if client is sender:
- continue
+class GameServer:
+ def __init__(self):
+ self.clients = {} # socket -> address
+ self.client_list = [] # List of addresses to maintain order
+ self.lock = threading.Lock()
+
+ # Estado del juego
+ self.state = STATE_LOBBY
+ self.round = 1
+ self.lives = {} # address -> int
+ self.grid_size = 3
+ self.bombs = set() # Set of coordinates (x, y)
+ self.revealed = set() # Set of coordinates (x, y)
+
+ # Turn control for placing
+ self.placing_turn_index = 0
+ self.bombs_to_place_per_player = 0
+ self.current_player_bombs_placed = 0
+
+ # Turn control for playing (search phase)
+ self.playing_turn_index = 0
+
+ def _broadcast_unlocked(self, message_dict):
+ """Envรญa mensaje JSON a todos. NOTA: Debe llamarse con self.lock ya adquirido"""
+ data = (json.dumps(message_dict) + '\n').encode('utf-8')
+ print(f"[BROADCAST] Enviando {len(data)} bytes a {len(self.clients)} clientes")
+ for client in list(self.clients.keys()):
try:
- client.sendall(message)
- except Exception:
- try:
- client.close()
- except Exception:
- pass
- clients.remove(client)
+ client.sendall(data)
+ print(f"[BROADCAST] โ Enviado a {self.clients[client]}")
+ except Exception as e:
+ print(f"[BROADCAST] โ Error enviando a {self.clients.get(client)}: {e}")
+ # No llamar remove_client aquรญ porque ya tenemos el lock
+ # Solo marcar para remover despuรฉs
+
+ def broadcast(self, message_dict):
+ """Envรญa mensaje JSON a todos con newline (adquiere lock)"""
+ with self.lock:
+ self._broadcast_unlocked(message_dict)
+
+ def remove_client(self, client):
+ if client in self.clients:
+ addr = self.clients[client]
+ del self.clients[client]
+ if addr in self.client_list:
+ self.client_list.remove(addr)
+ print(f"[DESCONECTADO] {addr}")
+ # Si no quedan jugadores, reiniciar
+ if not self.clients:
+ self.reset_game()
+
+ def reset_game(self):
+ print("Reiniciando juego...")
+ self.state = STATE_LOBBY
+ self.round = 1
+ self.lives = {}
+ self.bombs = set()
+ self.revealed = set()
+ self.client_list = []
+
+ def get_grid_size(self):
+ # Tamaรฑos de grid por ronda (mรกximo 14x14)
+ if self.round == 1: return 3
+ if self.round == 2: return 5
+ if self.round == 3: return 8
+ if self.round == 4: return 11
+ return 14 # Ronda 5+
+
+ def get_bombs_per_player(self):
+ # Bombas por ronda: 3, 5, 9, 12, 15
+ if self.round == 1: return 3
+ if self.round == 2: return 5
+ if self.round == 3: return 9
+ if self.round == 4: return 12
+ return 15 # Ronda 5+
+
+ def start_round(self):
+ """Inicia una nueva ronda. NOTA: Debe llamarse con self.lock ya adquirido"""
+ self.grid_size = self.get_grid_size()
+ self.bombs = set()
+ self.revealed = set()
+ self.state = STATE_PLACING
+ self.placing_turn_index = 0
+ self.current_player_bombs_placed = 0
+ self.bombs_to_place_per_player = self.get_bombs_per_player()
+
+ # Actualizar lista de clientes para turnos (lock ya adquirido por caller)
+ self.client_list = list(self.clients.values())
+
+ print(f"[ROUND {self.round}] Grid: {self.grid_size}x{self.grid_size}, Bombas/jugador: {self.bombs_to_place_per_player}")
+ print(f"[ROUND {self.round}] Jugadores: {self.client_list}")
+
+ msg = {
+ "type": "NEW_ROUND",
+ "round": self.round,
+ "grid_size": self.grid_size,
+ "status": f"Ronda {self.round}: Fase de Colocaciรณn",
+ "total_bombs_per_player": self.bombs_to_place_per_player
+ }
+ print(f"[BROADCAST] NEW_ROUND: {msg}")
+ self._broadcast_unlocked(msg)
+ self.next_placement_turn()
+
+ def start_round_with_lock(self):
+ """Versiรณn de start_round que adquiere el lock (para usar con Timer)"""
+ with self.lock:
+ self.start_round()
+
+ def next_placement_turn(self):
+ print(f"[TURN] Index: {self.placing_turn_index}, Total jugadores: {len(self.client_list)}")
+ if self.placing_turn_index >= len(self.client_list):
+ # Todos han puesto bombas - iniciar fase de bรบsqueda
+ print("[PHASE] Cambiando a PLAYING")
+ self.state = STATE_PLAYING
+ self.playing_turn_index = 0 # Empieza el primer jugador
+ self._broadcast_unlocked({
+ "type": "PHASE_PLAY",
+ "msg": "ยกA buscar! Recordad dรณnde estaban las bombas."
+ })
+ # Notificar turno del primer jugador
+ self.notify_playing_turn()
+ return
+
+ current_addr = self.client_list[self.placing_turn_index]
+ self.current_player_bombs_placed = 0
+
+ turn_msg = {
+ "type": "TURN_NOTIFY",
+ "active_player": str(current_addr),
+ "msg": f"Turno de {current_addr} para poner bombas."
+ }
+ print(f"[BROADCAST] TURN_NOTIFY: {turn_msg}")
+ self._broadcast_unlocked(turn_msg)
+
+ def notify_playing_turn(self):
+ """Notifica el turno actual en la fase de bรบsqueda"""
+ if self.playing_turn_index >= len(self.client_list):
+ self.playing_turn_index = 0 # Volver al principio
+
+ current_addr = self.client_list[self.playing_turn_index]
+ turn_msg = {
+ "type": "SEARCH_TURN",
+ "active_player": str(current_addr),
+ "msg": f"๐ Turno de {current_addr} para excavar."
+ }
+ print(f"[SEARCH_TURN] Jugador {self.playing_turn_index}: {current_addr}")
+ self._broadcast_unlocked(turn_msg)
+
+ def handle_client(self, client_sock, addr):
+ print(f"[CONECTADO] {addr}")
+ with self.lock:
+ self.clients[client_sock] = addr
+ if addr not in self.lives:
+ self.lives[addr] = 3
-def handle_client(client_socket: socket.socket, client_address):
- print(f"[NUEVO CLIENTE] {client_address} conectado.")
- try:
- while True:
- data = client_socket.recv(4096)
- if not data:
- break
- text = data.decode('utf-8', errors='replace')
- print(f"[{client_address}] {text}")
- # Re-enviar a los demรกs
- broadcast(data, client_socket)
- except Exception as e:
- print(f"[ERROR] {client_address}:", e)
- finally:
- with clients_lock:
- if client_socket in clients:
- clients.remove(client_socket)
try:
- client_socket.close()
- except Exception:
- pass
- print(f"[DESCONECTADO] {client_address} cerrado.")
+ while True:
+ data = client_sock.recv(4096)
+ if not data: break
+
+ try:
+ text_chunk = data.decode('utf-8', errors='replace')
+ lines = text_chunk.split('\n')
+ for line in lines:
+ line = line.strip()
+ if not line: continue
+ try:
+ msg = json.loads(line)
+ self.process_message(client_sock, addr, msg)
+ except json.JSONDecodeError:
+ pass
+ except Exception as e:
+ print(f"Error decode {addr}: {e}")
+ except Exception as e:
+ print(f"Error {addr}: {e}")
+ finally:
+ with self.lock:
+ self.remove_client(client_sock)
+
+ def process_message(self, client, addr, msg):
+ msg_type = msg.get('type')
+ print(f"[MSG] {addr}: {msg_type}") # Debug log
+
+ with self.lock:
+ if msg_type == 'START_GAME':
+ print(f"[START_GAME] Estado actual: {self.state}, Clientes: {len(self.clients)}")
+ if self.state == STATE_LOBBY:
+ # Reiniciar juego pero mantener clientes
+ self.round = 1
+ self.bombs = set()
+ self.revealed = set()
+
+ # Re-registrar vidas para los presentes
+ self.lives = {}
+ for c_addr in self.clients.values():
+ self.lives[c_addr] = 3
+
+ print(f"[START_GAME] Iniciando ronda 1 con {len(self.clients)} jugadores")
+ self.start_round()
+ else:
+ print(f"[START_GAME] Ignorado - estado: {self.state}")
+
+ elif msg_type == 'PLACE_BOMB':
+ if self.state == STATE_PLACING:
+ # Verificar turno
+ current_turn_addr = self.client_list[self.placing_turn_index]
+ if str(addr) != str(current_turn_addr):
+ return # No es su turno
+
+ x, y = msg['x'], msg['y']
+ # Evitar poner bomba donde ya hay (opcional, o permite solapar)
+ if (x,y) in self.bombs:
+ return # Ya hay bomba aqui
+
+ self.bombs.add((x, y))
+ self.current_player_bombs_placed += 1
+
+ # FLASH: Mostrar bomba 1 segundo
+ self._broadcast_unlocked({
+ "type": "BOMB_flash",
+ "x": x, "y": y,
+ "who": str(addr)
+ })
+
+ if self.current_player_bombs_placed >= self.bombs_to_place_per_player:
+ self.placing_turn_index += 1
+ self.next_placement_turn()
+
+ elif msg_type == 'CLICK_CELL':
+ if self.state == STATE_PLAYING:
+ # Verificar turno
+ current_turn_addr = self.client_list[self.playing_turn_index]
+ if str(addr) != str(current_turn_addr):
+ print(f"[CLICK_CELL] Ignorado - no es el turno de {addr}")
+ return # No es su turno
+
+ x, y = msg['x'], msg['y']
+
+ # Verificar si ya fue revelada
+ if (x, y) in self.revealed:
+ print(f"[CLICK_CELL] Celda {x},{y} ya revelada")
+ return # Ya revelada, no contar como turno
+
+ if (x, y) in self.bombs:
+ # BOOM logic - el jugador pierde una vida
+ self.lives[addr] -= 1
+
+ # Notificar explosiรณn a todos
+ self._broadcast_unlocked({
+ "type": "EXPLOSION",
+ "x": x, "y": y,
+ "who": str(addr),
+ "lives": self.lives[addr],
+ "player_addr": str(addr)
+ })
+
+ # LรGICA DE AVANCE DE RONDA
+ # Verificar si el jugador perdiรณ todas sus vidas
+ if self.lives[addr] <= 0:
+ # ยกEste jugador perdiรณ! El otro gana
+ self._broadcast_unlocked({
+ "type": "GAME_OVER",
+ "loser": str(addr),
+ "msg": f"๐ ยก{addr} ha perdido todas sus vidas! ยกGAME OVER!"
+ })
+ self.reset_game()
+ return
+
+ # Si estamos en ronda 5 o mรกs, repetir la ronda
+ if self.round >= 5:
+ self._broadcast_unlocked({
+ "type": "ROUND_ADVANCE",
+ "msg": f"๐ฅ ยกExplosiรณn! Repitiendo ronda final..."
+ })
+ # Repetir misma ronda (no incrementar)
+ threading.Timer(3.0, self.start_round_with_lock).start()
+ else:
+ # Avanzar a siguiente ronda
+ self.round += 1
+ self._broadcast_unlocked({
+ "type": "ROUND_ADVANCE",
+ "msg": f"๐ฅ ยกExplosiรณn! Pasando a ronda {self.round}..."
+ })
+ threading.Timer(3.0, self.start_round_with_lock).start()
+ return # No avanzar turno, la ronda terminรณ
+
+ else:
+ # Safe
+ self.revealed.add((x, y))
+ self._broadcast_unlocked({
+ "type": "SAFE",
+ "x": x, "y": y
+ })
+
+ # Avanzar turno
+ self.playing_turn_index += 1
+ if self.playing_turn_index >= len(self.client_list):
+ self.playing_turn_index = 0
+ self.notify_playing_turn()
+
+ elif msg_type == 'CHECK_DUNGEON_CLEARED':
+ # Solo puede verificar el jugador que tiene el turno
+ if self.state == STATE_PLAYING:
+ current_turn_addr = self.client_list[self.playing_turn_index]
+ if str(addr) != str(current_turn_addr):
+ self._broadcast_unlocked({
+ "type": "WARNING",
+ "msg": "๐ ยกSolo el jugador activo puede reclamar victoria!"
+ })
+ return
+
+ # Validar
+ bombs_exploded = 0 # No trackeamos exploded aparte, pero asumimos que siguen en self.bombs
+
+ # Logic: Si quedan celdas NO reveladas y NO son bombas -> Faltan cosas
+ # Grid total
+ total_cells = self.grid_size * self.grid_size
+ safe_cells_count = total_cells - len(self.bombs)
+
+ # Count safely revealed
+ # revealed set only contains SAFE cells based on logic above
+ if len(self.revealed) >= safe_cells_count:
+ # Win Round
+ self._broadcast_unlocked({"type": "ROUND_WIN", "msg": "ยกZona despejada!"})
+ self.round += 1
+ if self.round > 5:
+ self._broadcast_unlocked({"type": "GAME_WIN", "msg": "ยกJUEGO COMPLETADO!"})
+ self.reset_game()
+ else:
+ threading.Timer(3.0, self.start_round).start()
+ else:
+ self._broadcast_unlocked({
+ "type": "WARNING",
+ "msg": "ยกAรบn hay zonas sin explorar!"
+ })
def start_server():
- server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- server.bind((HOST, PORT))
- server.listen(10)
- print(f"[INICIO] Servidor escuchando en {HOST}:{PORT}")
-
- try:
- while True:
- client_socket, client_address = server.accept()
- with clients_lock:
- clients.append(client_socket)
- t = threading.Thread(target=handle_client, args=(client_socket, client_address), daemon=True)
- t.start()
- except KeyboardInterrupt:
- print('\n[APAGANDO] Servidor detenido por el usuario')
- finally:
- with clients_lock:
- for c in clients:
- try:
- c.close()
- except Exception:
- pass
- try:
- server.close()
- except Exception:
- pass
+ server = GameServer()
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ s.bind((HOST, PORT))
+ s.listen(5)
+ print(f"Servidor Juego escuchando en {HOST}:{PORT}")
+
+ while True:
+ conn, addr = s.accept()
+ t = threading.Thread(target=server.handle_client, args=(conn, addr), daemon=True)
+ t.start()
if __name__ == '__main__':
start_server()