feat: Sistema completo de fin de juego y pantallas de victoria
Nuevas funcionalidades: - Pantallas de victoria diferenciadas (NAZIS_WIN / ALLIED_WIN) * Diseño visual diferenciado (rojo para Nazis, azul para Aliados) * Timer de 30 segundos con auto-finalización * Estadísticas de misiones (exitosas vs fracasadas) * Opciones para el host: NUEVA PARTIDA o TERMINAR * Mensaje de espera para jugadores no-host - Sistema de reinicio de partida * Método restartGame() que resetea todas las variables * Reasigna roles y líder aleatorios * Vuelve a fase REVEAL_ROLE manteniendo jugadores - Sistema de finalización y expulsión * Método finalizeGame() que expulsa a todos después de 5s * Auto-expulsión si el host no decide en 30s * Limpieza de partida del servidor Mejoras en MISSION_RESULT: - Eliminado oscurecimiento de fondo (bg-transparent) - Tiempo de visualización aumentado de 5 a 7 segundos - Ahora se puede ver claramente el tablero con las fichas Lógica de transiciones: - 3 misiones fracasadas → NAZIS_WIN - 3 misiones exitosas → ASSASSIN_PHASE * Asesino acierta (mata a Marlene) → NAZIS_WIN * Asesino falla → ALLIED_WIN Archivos modificados: - shared/types.ts: Nuevas fases NAZIS_WIN y ALLIED_WIN - server/src/models/Game.ts: Métodos restartGame() y finalizeGame() - server/src/index.ts: Eventos restart_game y finalize_game - client/src/hooks/useSocket.ts: Acciones restartGame() y finalizeGame() - client/src/components/GameBoard.tsx: Renderizado de VictoryScreen - client/src/components/MissionResult.tsx: Sin oscurecimiento, 7s - client/src/components/VictoryScreen.tsx: NUEVO componente
This commit is contained in:
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
||||
import { GameState, GamePhase, Player, GAME_CONFIG, Faction } from '../../../shared/types';
|
||||
import MissionReveal from './MissionReveal';
|
||||
import MissionResult from './MissionResult';
|
||||
import VictoryScreen from './VictoryScreen';
|
||||
|
||||
interface GameBoardProps {
|
||||
gameState: GameState;
|
||||
@@ -51,13 +52,13 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
|
||||
// Estado para controlar cuándo mostrar el tablero
|
||||
const [showBoard, setShowBoard] = useState(false);
|
||||
|
||||
// Mostrar tablero solo 5 segundos después de MISSION_RESULT
|
||||
// Mostrar tablero 7 segundos después de MISSION_RESULT
|
||||
useEffect(() => {
|
||||
if (gameState.phase === GamePhase.MISSION_RESULT) {
|
||||
setShowBoard(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowBoard(false);
|
||||
}, 5000); // 5 segundos
|
||||
}, 7000); // 7 segundos
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowBoard(false);
|
||||
@@ -679,6 +680,26 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FASE: VICTORIA NAZIS */}
|
||||
{gameState.phase === GamePhase.NAZIS_WIN && (
|
||||
<VictoryScreen
|
||||
gameState={gameState}
|
||||
isHost={isHost}
|
||||
onRestart={() => actions.restartGame()}
|
||||
onFinalize={() => actions.finalizeGame()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FASE: VICTORIA ALIADOS */}
|
||||
{gameState.phase === GamePhase.ALLIED_WIN && (
|
||||
<VictoryScreen
|
||||
gameState={gameState}
|
||||
isHost={isHost}
|
||||
onRestart={() => actions.restartGame()}
|
||||
onFinalize={() => actions.finalizeGame()}
|
||||
/>
|
||||
)}
|
||||
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function MissionResult({ gameState, onContinue, isHost }: Mission
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-black/90 z-50"
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-transparent z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
|
||||
126
client/src/components/VictoryScreen.tsx
Normal file
126
client/src/components/VictoryScreen.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { GameState, Faction } from '../../../shared/types';
|
||||
|
||||
interface VictoryScreenProps {
|
||||
gameState: GameState;
|
||||
isHost: boolean;
|
||||
onRestart: () => void;
|
||||
onFinalize: () => void;
|
||||
}
|
||||
|
||||
export default function VictoryScreen({ gameState, isHost, onRestart, onFinalize }: VictoryScreenProps) {
|
||||
const [timeLeft, setTimeLeft] = useState(30);
|
||||
const isNazisWin = gameState.winner === Faction.ALEMANES;
|
||||
|
||||
// Timer de 30 segundos
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
// Se acabó el tiempo, finalizar automáticamente
|
||||
onFinalize();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [onFinalize]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-gradient-to-b from-black via-zinc-900 to-black z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
{/* Título de victoria */}
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
|
||||
>
|
||||
<h1 className={`text-7xl md:text-8xl font-bold mb-4 ${isNazisWin ? 'text-red-600' : 'text-blue-500'}`}>
|
||||
{isNazisWin ? '¡VICTORIA NAZI!' : '¡VICTORIA ALIADA!'}
|
||||
</h1>
|
||||
<p className="text-3xl text-gray-300">
|
||||
{isNazisWin ? 'Los Nazis han conquistado Francia' : 'La Resistencia ha triunfado'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Información del juego */}
|
||||
<motion.div
|
||||
className="bg-black/50 p-8 rounded-xl border border-white/20 mb-8 max-w-2xl"
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-6 text-center">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm uppercase mb-2">Misiones Exitosas</p>
|
||||
<p className="text-4xl font-bold text-blue-400">
|
||||
{gameState.questResults.filter(r => r === true).length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm uppercase mb-2">Misiones Fracasadas</p>
|
||||
<p className="text-4xl font-bold text-red-400">
|
||||
{gameState.questResults.filter(r => r === false).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Botones para el host */}
|
||||
{isHost ? (
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-4"
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 1 }}
|
||||
>
|
||||
<p className="text-yellow-500 font-bold text-xl mb-2">
|
||||
Tiempo restante: {timeLeft}s
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<motion.button
|
||||
onClick={onRestart}
|
||||
className="bg-green-600 hover:bg-green-500 text-white font-bold py-4 px-8 rounded-lg text-xl shadow-lg"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
🔄 NUEVA PARTIDA
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={onFinalize}
|
||||
className="bg-red-600 hover:bg-red-500 text-white font-bold py-4 px-8 rounded-lg text-xl shadow-lg"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
❌ TERMINAR
|
||||
</motion.button>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Si no decides, la partida terminará automáticamente
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 1 }}
|
||||
>
|
||||
<p className="text-white text-2xl font-mono bg-black/50 px-8 py-4 rounded-full border border-white/20 animate-pulse">
|
||||
Esperando decisión del comandante...
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-4">
|
||||
Tiempo restante: {timeLeft}s
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,9 @@ export const useSocket = () => {
|
||||
finishReveal: () => socket?.emit('finish_reveal', { roomId: gameState?.roomId }),
|
||||
finishRollCall: () => socket?.emit('finish_roll_call', { roomId: gameState?.roomId }),
|
||||
finishMissionReveal: () => socket?.emit('finish_reveal', { roomId: gameState?.roomId }),
|
||||
finishMissionResult: () => socket?.emit('finish_mission_result', { roomId: gameState?.roomId })
|
||||
finishMissionResult: () => socket?.emit('finish_mission_result', { roomId: gameState?.roomId }),
|
||||
restartGame: () => socket?.emit('restart_game', { roomId: gameState?.roomId }),
|
||||
finalizeGame: () => socket?.emit('finalize_game', { roomId: gameState?.roomId })
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -274,7 +274,32 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 7. DESCONEXIÓN
|
||||
// 7. REINICIAR PARTIDA
|
||||
socket.on('restart_game', ({ roomId }) => {
|
||||
const game = games[roomId];
|
||||
if (game && game.hostId === socket.id) {
|
||||
game.restartGame();
|
||||
io.to(roomId).emit('game_state', game.state);
|
||||
}
|
||||
});
|
||||
|
||||
// 8. FINALIZAR Y EXPULSAR JUGADORES
|
||||
socket.on('finalize_game', ({ roomId }) => {
|
||||
const game = games[roomId];
|
||||
if (game && game.hostId === socket.id) {
|
||||
game.finalizeGame();
|
||||
io.to(roomId).emit('game_state', game.state);
|
||||
|
||||
// Esperar 5 segundos y luego eliminar la partida
|
||||
setTimeout(() => {
|
||||
delete games[roomId];
|
||||
// Desconectar a todos los jugadores de la sala
|
||||
io.in(roomId).socketsLeave(roomId);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// 9. DESCONEXIÓN
|
||||
socket.on('disconnect', () => {
|
||||
// Buscar en qué partida estaba y sacarlo (opcional, por ahora solo notificamos)
|
||||
console.log('Desconectado:', socket.id);
|
||||
|
||||
@@ -312,10 +312,14 @@ export class Game {
|
||||
const failures = this.state.questResults.filter(r => r === false).length;
|
||||
|
||||
if (failures >= 3) {
|
||||
this.endGame(Faction.ALEMANES, 'Tres misiones han fracasado.'); // Updated Faction
|
||||
// Los Nazis ganan directamente
|
||||
this.state.winner = Faction.ALEMANES;
|
||||
this.state.phase = GamePhase.NAZIS_WIN;
|
||||
this.log('¡Los Nazis han ganado! Tres misiones han fracasado.');
|
||||
} else if (successes >= 3) {
|
||||
// Los Aliados han completado 3 misiones, pero el Asesino tiene una oportunidad
|
||||
this.state.phase = GamePhase.ASSASSIN_PHASE;
|
||||
this.log('¡La Resistencia ha triunfado! Pero el Asesino tiene una última oportunidad...');
|
||||
this.log('¡La Resistencia ha triunfado! Pero el Francotirador tiene una última oportunidad...');
|
||||
} else {
|
||||
// Siguiente ronda
|
||||
this.state.currentRound++;
|
||||
@@ -328,10 +332,16 @@ export class Game {
|
||||
|
||||
assassinKill(targetId: string) {
|
||||
const target = this.state.players.find(p => p.id === targetId);
|
||||
if (target && target.role === Role.MARLENE) { // Updated Role
|
||||
this.endGame(Faction.ALEMANES, '¡El Asesino ha eliminado a Marlene!'); // Updated Faction and message
|
||||
if (target && target.role === Role.MARLENE) {
|
||||
// El Francotirador acierta: Nazis ganan
|
||||
this.state.winner = Faction.ALEMANES;
|
||||
this.state.phase = GamePhase.NAZIS_WIN;
|
||||
this.log('¡El Francotirador ha eliminado a Marlene! Los Nazis ganan.');
|
||||
} else {
|
||||
this.endGame(Faction.ALIADOS, 'El Asesino ha fallado. ¡La Resistencia gana!'); // Updated Faction
|
||||
// El Francotirador falla: Aliados ganan
|
||||
this.state.winner = Faction.ALIADOS;
|
||||
this.state.phase = GamePhase.ALLIED_WIN;
|
||||
this.log('El Francotirador ha fallado. ¡La Resistencia gana!');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +364,43 @@ export class Game {
|
||||
this.log(`FIN DEL JUEGO. Victoria para ${winner}. Razón: ${reason}`);
|
||||
}
|
||||
|
||||
// Método para reiniciar la partida (volver a REVEAL_ROLE)
|
||||
restartGame() {
|
||||
this.log('=== REINICIANDO PARTIDA ===');
|
||||
|
||||
// Resetear variables de juego
|
||||
this.state.currentRound = 1;
|
||||
this.state.failedVotesCount = 0;
|
||||
this.state.questResults = [null, null, null, null, null];
|
||||
this.state.leaderVotes = {};
|
||||
this.state.proposedTeam = [];
|
||||
this.state.teamVotes = {};
|
||||
this.state.missionVotes = [];
|
||||
this.state.revealedVotes = [];
|
||||
this.state.missionHistory = [];
|
||||
this.state.winner = undefined;
|
||||
|
||||
// Reasignar roles
|
||||
const count = this.state.players.length;
|
||||
const config = GAME_CONFIG[count as keyof typeof GAME_CONFIG];
|
||||
this.assignRoles(config.good, config.evil);
|
||||
|
||||
// Asignar nuevo líder aleatorio
|
||||
const leaderIndex = Math.floor(Math.random() * count);
|
||||
this.state.players.forEach((p, i) => p.isLeader = i === leaderIndex);
|
||||
this.state.currentLeaderId = this.state.players[leaderIndex].id;
|
||||
|
||||
// Volver a REVEAL_ROLE
|
||||
this.state.phase = GamePhase.REVEAL_ROLE;
|
||||
this.log('Nueva partida iniciada. Revelando roles...');
|
||||
}
|
||||
|
||||
// Método para finalizar definitivamente y preparar expulsión
|
||||
finalizeGame() {
|
||||
this.state.phase = GamePhase.GAME_OVER;
|
||||
this.log('Partida finalizada. Los jugadores serán expulsados.');
|
||||
}
|
||||
|
||||
private log(message: string) {
|
||||
this.state.history.push(message);
|
||||
// Mantener solo los últimos 50 mensajes
|
||||
|
||||
@@ -28,7 +28,9 @@ export enum GamePhase {
|
||||
MISSION = 'mission', // Los elegidos votan éxito/fracaso
|
||||
MISSION_REVEAL = 'mission_reveal', // Mostrar cartas una a una
|
||||
MISSION_RESULT = 'mission_result', // Pantalla de resumen
|
||||
ASSASSIN_PHASE = 'assassin_phase', // Si gana el bien, el asesino intenta matar a Merlín
|
||||
ASSASSIN_PHASE = 'assassin_phase', // Si gana el bien, el asesino intenta matar a Marlene
|
||||
NAZIS_WIN = 'nazis_win', // Pantalla de victoria de los Nazis
|
||||
ALLIED_WIN = 'allied_win', // Pantalla de victoria de los Aliados
|
||||
GAME_OVER = 'game_over',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user