feat: Implementar sesiones persistentes y botones de salida

- Añadido sistema de sesiones con localStorage
- Nuevo hook useSessionStorage para manejar sesiones
- Botón de salir de la partida (ExitGameButton) en todas las pantallas del juego
- Botón de logout (LogoutButton) solo en el lobby
- Evento leave_game en servidor para cerrar partida cuando alguien sale
- Evento reconnect_session para reconectar jugadores después de recargar
- Actualizado GameBoard para incluir botón de salida
- Actualizado page.tsx para manejar sesiones y logout
This commit is contained in:
Resistencia Dev
2025-12-22 16:51:35 +01:00
parent be15983455
commit 53a5e3886e
9 changed files with 497 additions and 4 deletions

View File

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

112
SESIONES-IMPLEMENTACION.md Normal file
View File

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

View File

@@ -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<ViewState>('login');
@@ -35,13 +38,32 @@ export default function Home() {
const [passwordPromptRoomId, setPasswordPromptRoomId] = useState<string | null>(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 (
<GameBoard
gameState={gameState}
currentPlayerId={socket.id}
currentPlayerId={socket.id || ''}
actions={actions}
fullPlayerName={fullPlayerName}
/>
);
}
@@ -185,6 +227,9 @@ export default function Home() {
)}
</div>
{/* Botón de Logout - solo en lobby */}
{view === 'lobby' && <LogoutButton onClick={handleLogout} />}
<div className="z-10 w-full flex-1 flex flex-col items-center justify-center p-4">
<AnimatePresence mode="wait">

View File

@@ -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 (
<>
<motion.button
onClick={() => 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"
>
<div className="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
<span className="text-xs font-bold uppercase tracking-wider hidden md:inline">Salir</span>
</div>
</motion.button>
{/* Modal de confirmación */}
{showConfirm && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-zinc-900 p-6 rounded-lg border border-red-700/50 w-full max-w-md mx-4 shadow-2xl"
>
<h3 className="text-xl font-bold text-red-400 mb-4 uppercase flex items-center gap-2">
Abandonar Partida
</h3>
<p className="text-gray-300 mb-2">
¿Estás seguro de que quieres abandonar la partida?
</p>
<p className="text-sm text-gray-400 mb-6">
La partida se cerrará para todos los jugadores y se perderá todo el progreso.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowConfirm(false)}
className="flex-1 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded font-bold uppercase text-sm transition-colors"
>
Cancelar
</button>
<button
onClick={handleConfirmExit}
className="flex-1 py-3 bg-red-900 hover:bg-red-800 text-white rounded font-bold uppercase text-sm transition-colors"
>
Salir
</button>
</div>
</motion.div>
</div>
)}
</>
);
}

View File

@@ -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<string[]>([]);
// Hooks para FASE REVEAL ROLE
@@ -298,6 +300,14 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
return (
<div className="relative w-full h-screen flex flex-col overflow-hidden">
{/* Botón de Salir de la Partida - No mostrar en pantallas de victoria */}
{gameState.phase !== GamePhase.ALLIED_WIN && gameState.phase !== GamePhase.NAZIS_WIN && (
<ExitGameButton
onExit={() => actions.leaveGame()}
playerName={fullPlayerName}
/>
)}
{/* Fondo */}
<div className="absolute inset-0 z-0 opacity-40">
<Image

View File

@@ -0,0 +1,33 @@
import { motion } from 'framer-motion';
interface LogoutButtonProps {
onClick: () => void;
}
export default function LogoutButton({ onClick }: LogoutButtonProps) {
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="fixed top-4 left-4 z-50 bg-red-900/80 hover:bg-red-800 text-white p-3 rounded-lg border border-red-700/50 backdrop-blur-sm shadow-lg transition-all group"
title="Salir del juego"
>
<div className="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z"
clipRule="evenodd"
/>
</svg>
<span className="text-xs font-bold uppercase tracking-wider hidden md:inline">Salir</span>
</div>
</motion.button>
);
}

View File

@@ -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<SessionData | null>(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<SessionData>) => {
if (session) {
const updated = { ...session, ...partial };
saveSession(updated);
}
};
// Limpiar sesión
const clearSession = () => {
localStorage.removeItem('resistencia_session');
setSession(null);
};
return {
session,
saveSession,
updateSession,
clearSession
};
}

View File

@@ -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 }),

View File

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