diff --git a/.agent/workflows/sesiones-y-botones.md b/.agent/workflows/sesiones-y-botones.md new file mode 100644 index 0000000..70db478 --- /dev/null +++ b/.agent/workflows/sesiones-y-botones.md @@ -0,0 +1,62 @@ +--- +description: Implementación de sesiones persistentes y botones de salida +--- + +# Implementación de Sesiones y Botones de Salida + +## Objetivo +Implementar tres mejoras principales: +1. Sistema de sesiones persistentes +2. Botón de salir de la partida (en todas las pantallas de juego) +3. Botón de salir del juego (solo en lobby) + +## Tareas + +### 1. Sistema de Sesiones Persistentes + +**Cliente:** +- Crear hook `useSessionStorage` para manejar sesiones +- Guardar en localStorage: + - `playerName` y `fullPlayerName` + - `currentView` (login/lobby/game) + - `roomId` si está en una partida +- Al cargar la app, verificar si hay sesión activa +- Si hay sesión, reconectar al servidor y recuperar estado + +**Servidor:** +- Implementar evento `reconnect_session` para validar y recuperar estado +- Mantener mapping de `socketId` a `playerId` persistente +- Al reconectar, actualizar el socketId del jugador en la partida + +### 2. Botón de Salir de la Partida + +**Cliente:** +- Crear componente `ExitGameButton` con icono de flecha +- Posicionar arriba a la izquierda +- Mostrar en todas las fases del juego (no en lobby) +- Al hacer clic, emitir evento `leave_game` + +**Servidor:** +- Implementar evento `leave_game` +- Notificar a todos los jugadores que alguien salió +- Eliminar la partida de la BD +- Devolver a todos al lobby + +### 3. Botón de Salir del Juego + +**Cliente:** +- Crear componente `LogoutButton` con icono de apagar +- Posicionar arriba a la izquierda solo en lobby +- Al hacer clic: + - Limpiar localStorage + - Volver a vista de login + - Desconectar socket si está en partida + +## Orden de Implementación + +1. Crear hooks y utilidades para sesiones +2. Implementar botón de salir del juego (logout) +3. Implementar botón de salir de la partida +4. Implementar lógica de reconexión en servidor +5. Integrar sistema de sesiones en el cliente +6. Pruebas diff --git a/SESIONES-IMPLEMENTACION.md b/SESIONES-IMPLEMENTACION.md new file mode 100644 index 0000000..046d711 --- /dev/null +++ b/SESIONES-IMPLEMENTACION.md @@ -0,0 +1,112 @@ +# Resumen de Implementación: Sesiones y Botones de Salida + +## Cambios Realizados + +### 1. Sistema de Sesiones Persistentes ✅ + +#### Cliente +- **Nuevo hook**: `client/src/hooks/useSessionStorage.ts` + - Maneja el almacenamiento y recuperación de sesiones en localStorage + - Guarda: playerName, fullPlayerName, currentView, roomId + +- **Actualización de `page.tsx`**: + - Integrado hook `useSessionStorage` + - Al iniciar sesión, se guarda la sesión + - Al cargar la app, se restaura la sesión si existe + - Al cambiar de vista (lobby/game), se actualiza la sesión + - Función `handleLogout` para limpiar sesión + +#### Servidor +- **Nuevo evento**: `reconnect_session` + - Permite a un jugador reconectarse a una partida existente + - Actualiza el socketId del jugador en la partida + - Envía el estado actualizado al jugador reconectado + +### 2. Botón de Salir de la Partida ✅ + +#### Cliente +- **Nuevo componente**: `client/src/components/ExitGameButton.tsx` + - Botón con icono de flecha + - Posicionado arriba a la izquierda (fixed top-4 left-4) + - Modal de confirmación antes de salir + - Se muestra en todas las fases del juego excepto en pantallas de victoria + +- **Actualización de `GameBoard.tsx`**: + - Agregado prop `fullPlayerName` + - Integrado `ExitGameButton` + - Llama a `actions.leaveGame()` al confirmar + +- **Actualización de `useSocket.ts`**: + - Nueva acción: `leaveGame()` + - Nuevo listener: `player_left_game` + +#### Servidor +- **Nuevo evento**: `leave_game` + - Notifica a todos los jugadores que alguien abandonó + - Elimina la partida de la base de datos + - Limpia timers asociados + - Actualiza la lista de salas + - Desconecta a todos los jugadores de la sala + +### 3. Botón de Salir del Juego (Logout) ✅ + +#### Cliente +- **Nuevo componente**: `client/src/components/LogoutButton.tsx` + - Botón con icono de apagar + - Posicionado arriba a la izquierda (fixed top-4 left-4) + - Solo visible en el lobby + +- **Actualización de `page.tsx`**: + - Función `handleLogout()`: + - Limpia la sesión de localStorage + - Vuelve a la vista de login + - Si está en una partida, llama a `leaveGame()` + +## Flujo de Uso + +### Sesiones Persistentes +1. Usuario se loguea → sesión guardada en localStorage +2. Usuario cierra navegador +3. Usuario vuelve a abrir → sesión restaurada automáticamente +4. Si estaba en una partida, intenta reconectar + +### Salir de la Partida +1. Usuario hace clic en botón de flecha (arriba izquierda) +2. Aparece modal de confirmación +3. Al confirmar: + - Servidor notifica a todos: "Jugador X ha abandonado" + - Partida eliminada de la BD + - Todos vuelven al lobby + +### Salir del Juego (Logout) +1. Usuario hace clic en botón de apagar (arriba izquierda, solo en lobby) +2. Sesión eliminada de localStorage +3. Vuelve a pantalla de login + +## Archivos Modificados + +### Nuevos Archivos +- `client/src/hooks/useSessionStorage.ts` +- `client/src/components/LogoutButton.tsx` +- `client/src/components/ExitGameButton.tsx` +- `.agent/workflows/sesiones-y-botones.md` + +### Archivos Modificados +- `client/src/app/page.tsx` +- `client/src/components/GameBoard.tsx` +- `client/src/hooks/useSocket.ts` +- `server/src/index.ts` + +## Próximos Pasos + +1. **Probar la aplicación**: + - Verificar que las sesiones persisten correctamente + - Probar el botón de salir de la partida + - Probar el botón de logout + - Verificar reconexión después de recargar + +2. **Posibles mejoras**: + - Agregar notificación toast cuando alguien abandona + - Mejorar el manejo de errores en reconexión + - Agregar timeout de sesión (expiración automática) + - Guardar más información en la sesión (configuración, preferencias) diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index a345c76..a5f073e 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -2,9 +2,11 @@ import { useState, useEffect } from 'react'; import { useSocket } from '../hooks/useSocket'; +import { useSessionStorage } from '../hooks/useSessionStorage'; import { motion, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; import GameBoard from '../components/GameBoard'; +import LogoutButton from '../components/LogoutButton'; import { GameRoom } from '../../../shared/types'; // Constantes de apellidos @@ -21,6 +23,7 @@ type ViewState = 'login' | 'lobby' | 'game'; export default function Home() { const { isConnected, gameState, roomsList, actions, socket } = useSocket(); + const { session, saveSession, updateSession, clearSession } = useSessionStorage(); // Estados locales de UI const [view, setView] = useState('login'); @@ -35,13 +38,32 @@ export default function Home() { const [passwordPromptRoomId, setPasswordPromptRoomId] = useState(null); const [joinPassword, setJoinPassword] = useState(''); + // Restaurar sesión al cargar + useEffect(() => { + if (session && isConnected) { + setPlayerName(session.playerName); + setFullPlayerName(session.fullPlayerName); + setView(session.currentView); + + // Si había una partida activa, intentar reconectar + if (session.roomId && session.currentView === 'game') { + actions.reconnectSession({ playerName: session.fullPlayerName, roomId: session.roomId }); + } else if (session.currentView === 'lobby') { + actions.refreshRooms(); + } + } + }, [session, isConnected]); + // Efecto para cambiar a vista de juego cuando el servidor nos une useEffect(() => { if (gameState?.roomId) { setView('game'); + // Guardar en sesión + updateSession({ currentView: 'game', roomId: gameState.roomId }); } else if (view === 'game' && !gameState) { // Si estábamos en juego y volvemos a null, volver al lobby setView('lobby'); + updateSession({ currentView: 'lobby', roomId: undefined }); } }, [gameState]); @@ -50,13 +72,32 @@ export default function Home() { if (playerName) { // Generar apellido aleatorio const randomSurname = SURNAMES[Math.floor(Math.random() * SURNAMES.length)]; - setFullPlayerName(`${playerName} ${randomSurname}`); + const fullName = `${playerName} ${randomSurname}`; + setFullPlayerName(fullName); + + // Guardar sesión + saveSession({ + playerName, + fullPlayerName: fullName, + currentView: 'lobby' + }); setView('lobby'); actions.refreshRooms(); } }; + const handleLogout = () => { + clearSession(); + setView('login'); + setPlayerName(''); + setFullPlayerName(''); + // Si está en una partida, salir + if (gameState?.roomId) { + actions.leaveGame(); + } + }; + const handleCreateGame = (e: React.FormEvent) => { e.preventDefault(); actions.createGame(fullPlayerName, createConfig.maxPlayers, createConfig.password); @@ -149,8 +190,9 @@ export default function Home() { return ( ); } @@ -185,6 +227,9 @@ export default function Home() { )} + {/* Botón de Logout - solo en lobby */} + {view === 'lobby' && } +
diff --git a/client/src/components/ExitGameButton.tsx b/client/src/components/ExitGameButton.tsx new file mode 100644 index 0000000..738b4ac --- /dev/null +++ b/client/src/components/ExitGameButton.tsx @@ -0,0 +1,80 @@ +import { motion } from 'framer-motion'; +import { useState } from 'react'; + +interface ExitGameButtonProps { + onExit: () => void; + playerName: string; +} + +export default function ExitGameButton({ onExit, playerName }: ExitGameButtonProps) { + const [showConfirm, setShowConfirm] = useState(false); + + const handleConfirmExit = () => { + setShowConfirm(false); + onExit(); + }; + + return ( + <> + setShowConfirm(true)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + className="fixed top-4 left-4 z-50 bg-yellow-900/80 hover:bg-yellow-800 text-white p-3 rounded-lg border border-yellow-700/50 backdrop-blur-sm shadow-lg transition-all group" + title="Abandonar partida" + > +
+ + + + Salir +
+
+ + {/* Modal de confirmación */} + {showConfirm && ( +
+ +

+ ⚠️ Abandonar Partida +

+

+ ¿Estás seguro de que quieres abandonar la partida? +

+

+ La partida se cerrará para todos los jugadores y se perderá todo el progreso. +

+ +
+ + +
+
+
+ )} + + ); +} diff --git a/client/src/components/GameBoard.tsx b/client/src/components/GameBoard.tsx index f1b289f..1734043 100644 --- a/client/src/components/GameBoard.tsx +++ b/client/src/components/GameBoard.tsx @@ -5,14 +5,16 @@ import { GameState, GamePhase, Player, GAME_CONFIG, Faction } from '../../../sha import MissionReveal from './MissionReveal'; import MissionResult from './MissionResult'; import VictoryScreen from './VictoryScreen'; +import ExitGameButton from './ExitGameButton'; interface GameBoardProps { gameState: GameState; currentPlayerId: string; actions: any; + fullPlayerName: string; } -export default function GameBoard({ gameState, currentPlayerId, actions }: GameBoardProps) { +export default function GameBoard({ gameState, currentPlayerId, actions, fullPlayerName }: GameBoardProps) { const [selectedTeam, setSelectedTeam] = useState([]); // Hooks para FASE REVEAL ROLE @@ -298,6 +300,14 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB return (
+ {/* Botón de Salir de la Partida - No mostrar en pantallas de victoria */} + {gameState.phase !== GamePhase.ALLIED_WIN && gameState.phase !== GamePhase.NAZIS_WIN && ( + actions.leaveGame()} + playerName={fullPlayerName} + /> + )} + {/* Fondo */}
void; +} + +export default function LogoutButton({ onClick }: LogoutButtonProps) { + return ( + +
+ + + + Salir +
+
+ ); +} diff --git a/client/src/hooks/useSessionStorage.ts b/client/src/hooks/useSessionStorage.ts new file mode 100644 index 0000000..445e151 --- /dev/null +++ b/client/src/hooks/useSessionStorage.ts @@ -0,0 +1,53 @@ +import { useState, useEffect } from 'react'; + +interface SessionData { + playerName: string; + fullPlayerName: string; + currentView: 'login' | 'lobby' | 'game'; + roomId?: string; +} + +export function useSessionStorage() { + const [session, setSession] = useState(null); + + // Cargar sesión al iniciar + useEffect(() => { + const savedSession = localStorage.getItem('resistencia_session'); + if (savedSession) { + try { + const parsed = JSON.parse(savedSession); + setSession(parsed); + } catch (e) { + console.error('Error parsing session:', e); + localStorage.removeItem('resistencia_session'); + } + } + }, []); + + // Guardar sesión + const saveSession = (data: SessionData) => { + localStorage.setItem('resistencia_session', JSON.stringify(data)); + setSession(data); + }; + + // Actualizar sesión parcialmente + const updateSession = (partial: Partial) => { + if (session) { + const updated = { ...session, ...partial }; + saveSession(updated); + } + }; + + // Limpiar sesión + const clearSession = () => { + localStorage.removeItem('resistencia_session'); + setSession(null); + }; + + return { + session, + saveSession, + updateSession, + clearSession + }; +} diff --git a/client/src/hooks/useSocket.ts b/client/src/hooks/useSocket.ts index 3dc1775..5e09733 100644 --- a/client/src/hooks/useSocket.ts +++ b/client/src/hooks/useSocket.ts @@ -50,6 +50,12 @@ export const useSocket = () => { setGameState(null); // Resetear estado para volver al lobby }); + // Manejar cuando un jugador abandona la partida + socketInstance.on('player_left_game', ({ playerName }: { playerName: string }) => { + console.log(`${playerName} ha abandonado la partida`); + // El servidor ya habrá cerrado la partida, solo mostramos mensaje + }); + setSocket(socketInstance); return () => { @@ -90,6 +96,14 @@ export const useSocket = () => { socket?.emit('assassin_kill', { roomId: gameState?.roomId, targetId }); }; + const leaveGame = () => { + socket?.emit('leave_game', { roomId: gameState?.roomId }); + }; + + const reconnectSession = (sessionData: { playerName: string; roomId?: string }) => { + socket?.emit('reconnect_session', sessionData); + }; + return { socket, isConnected, @@ -105,6 +119,8 @@ export const useSocket = () => { voteMission, voteLeader: (approve: boolean) => socket?.emit('vote_leader', { roomId: gameState?.roomId, approve }), assassinKill, + leaveGame, + reconnectSession, finishIntro: () => socket?.emit('finish_intro', { roomId: gameState?.roomId }), finishReveal: () => socket?.emit('finish_reveal', { roomId: gameState?.roomId }), finishRollCall: () => socket?.emit('finish_roll_call', { roomId: gameState?.roomId }), diff --git a/server/src/index.ts b/server/src/index.ts index 0b4044f..d5c7e96 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -301,7 +301,89 @@ io.on('connection', (socket) => { } }); - // 9. DESCONEXIÓN + // 8. ABANDONAR PARTIDA (cualquier jugador) + socket.on('leave_game', ({ roomId }) => { + const game = games[roomId]; + if (game) { + // Encontrar el jugador que se va + const leavingPlayer = game.state.players.find(p => p.id === socket.id); + const playerName = leavingPlayer?.name || 'Un jugador'; + + // Notificar a todos los jugadores + io.to(roomId).emit('player_left_game', { playerName }); + io.to(roomId).emit('game_finalized'); + + // Eliminar la partida de la base de datos + delete games[roomId]; + + // Limpiar timer si existe + if (voteTimers[roomId]) { + clearTimeout(voteTimers[roomId]); + delete voteTimers[roomId]; + } + + // Actualizar lista de salas + io.emit('rooms_list', getRoomsList()); + + // Desconectar a todos de la sala + io.in(roomId).socketsLeave(roomId); + + console.log(`[LEAVE_GAME] ${playerName} abandonó la partida ${roomId}. Partida eliminada.`); + } + }); + + // 9. RECONECTAR SESIÓN + socket.on('reconnect_session', ({ playerName, roomId }) => { + console.log(`[RECONNECT_SESSION] Intento de reconexión: ${playerName} a sala ${roomId}`); + + if (roomId) { + const game = games[roomId]; + if (game) { + // Buscar si el jugador existe en la partida + const existingPlayer = game.state.players.find(p => p.name === playerName); + + if (existingPlayer) { + // Actualizar el socket ID del jugador + existingPlayer.id = socket.id; + + // Unir al socket a la sala + socket.join(roomId); + + // Enviar estado actualizado + socket.emit('game_joined', { roomId, state: game.state }); + io.to(roomId).emit('game_state', game.state); + + console.log(`[RECONNECT_SESSION] ${playerName} reconectado exitosamente a ${roomId}`); + } else { + console.log(`[RECONNECT_SESSION] Jugador ${playerName} no encontrado en partida ${roomId}`); + socket.emit('error', 'No se pudo reconectar a la partida'); + } + } else { + console.log(`[RECONNECT_SESSION] Partida ${roomId} no existe`); + socket.emit('error', 'La partida ya no existe'); + } + } + }); + + // 10. FINALIZAR Y EXPULSAR JUGADORES (solo host) + socket.on('finalize_game', ({ roomId }) => { + const game = games[roomId]; + if (game && game.hostId === socket.id) { + // Notificar a todos los jugadores que la partida ha sido finalizada + io.to(roomId).emit('game_finalized'); + + // Eliminar la partida inmediatamente del registro + delete games[roomId]; + + // Actualizar lista de salas para todos los clientes + io.emit('rooms_list', getRoomsList()); + + // Desconectar a todos los jugadores de la sala + io.in(roomId).socketsLeave(roomId); + } + }); + + // 11. DESCONEXIÓN socket.on('disconnect', () => { // Buscar en qué partida estaba y sacarlo (opcional, por ahora solo notificamos) console.log('Desconectado:', socket.id);