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

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