feat: Implement multiplayer Minesweeper game server with JSON protocol, game states, and turn-based logic, alongside a new game client.

This commit is contained in:
marcos 2026-02-02 17:22:11 +01:00
parent a8f99f02a8
commit afac3609cb
4 changed files with 1429 additions and 172 deletions

468
README.md
View File

@ -1,73 +1,447 @@
<p align="center">
<img src="https://img.shields.io/badge/Python-3.8+-blue?style=for-the-badge&logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/Tkinter-GUI-green?style=for-the-badge&logo=python&logoColor=white" alt="Tkinter">
<img src="https://img.shields.io/badge/TCP/IP-Multiplayer-orange?style=for-the-badge&logo=socketdotio&logoColor=white" alt="Sockets">
<img src="https://img.shields.io/badge/License-Educational-purple?style=for-the-badge" alt="License">
</p>
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.
<h1 align="center">💣 Minesweeper Multiplayer + Dashboard</h1>
## 🎬 Demo en video
<p align="center">
<strong>Un proyecto completo de programación de servicios y procesos</strong><br>
<em>Juego de buscaminas competitivo en red + Panel de control integral</em>
</p>
- YouTube: https://youtu.be/HgJwU_HagD8
- Drive: https://drive.google.com/file/d/14wGkkyZ9ASbV__O2xp1zIZJxSA6gpJoF/view?usp=sharing
<p align="center">
<a href="#-características">Características</a>
<a href="#-arquitectura">Arquitectura</a>
<a href="#-instalación">Instalación</a>
<a href="#-uso">Uso</a>
<a href="#-mecánicas-del-juego">Mecánicas</a>
<a href="#-tecnologías">Tecnologías</a>
</p>
## Foto
---
<img width="2879" height="1799" alt="image" src="https://github.com/user-attachments/assets/066099a3-316d-4443-a871-6c2a70bf2d5c" />
## 📸 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
<details>
<summary>📦 <strong>Ver dependencias detalladas</strong></summary>
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
</details>
---
## 🎯 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
<table>
<tr>
<td align="center" width="150">
<img src="https://raw.githubusercontent.com/devicons/devicon/master/icons/python/python-original.svg" width="48" height="48" alt="Python" />
<br><strong>Python 3</strong>
<br><em>Lenguaje base</em>
</td>
<td align="center" width="150">
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/Virtualbox_logo.png/64px-Virtualbox_logo.png" width="48" height="48" alt="Tkinter" />
<br><strong>Tkinter</strong>
<br><em>Interfaz gráfica</em>
</td>
<td align="center" width="150">
<img src="https://raw.githubusercontent.com/devicons/devicon/master/icons/socketio/socketio-original.svg" width="48" height="48" alt="Sockets" />
<br><strong>TCP Sockets</strong>
<br><em>Comunicación en red</em>
</td>
</tr>
<tr>
<td align="center">
<img src="https://matplotlib.org/stable/_static/logo_light.svg" width="48" height="48" alt="Matplotlib" />
<br><strong>Matplotlib</strong>
<br><em>Gráficos</em>
</td>
<td align="center">
<img src="https://raw.githubusercontent.com/devicons/devicon/master/icons/json/json-original.svg" width="48" height="48" alt="JSON" />
<br><strong>JSON</strong>
<br><em>Protocolo mensajes</em>
</td>
<td align="center">
<img src="https://www.vectorlogo.zone/logos/pocoo_flask/pocoo_flask-icon.svg" width="48" height="48" alt="Requests" />
<br><strong>Requests</strong>
<br><em>APIs HTTP</em>
</td>
</tr>
</table>
### 🧵 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**.
---
<p align="center">
<strong>Desarrollado con ❤️ por Marcos Ferrandiz</strong>
</p>
<p align="center">
<em>Proyecto 1º Evaluación - PSP (Programación de Servicios y Procesos)</em>
</p>
---
<p align="center">
<img src="https://img.shields.io/badge/Estado-Completado-success?style=flat-square" alt="Estado">
<img src="https://img.shields.io/badge/Versión-1.0-blue?style=flat-square" alt="Versión">
<img src="https://img.shields.io/badge/Made%20with-Python-yellow?style=flat-square&logo=python" alt="Python">
</p>

496
app.py
View File

@ -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)
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)
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)
# 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.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')
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)
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))
# 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)
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
)
# ------------------ Lógica Juego ------------------
def _enqueue_game_message(self, msg: dict):
self._game_queue.put(msg)
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:

219
cliente_juego.py Normal file
View File

@ -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()

View File

@ -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}")
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}")
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
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()