Files
FranciaOcupada/server/src/models/Game.ts
Resistencia Dev b836c53002 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
2025-12-08 13:41:44 +01:00

410 lines
15 KiB
TypeScript

import {
GameState,
Player,
GamePhase,
Role,
Faction,
GAME_CONFIG
} from '../../../shared/types';
export class Game {
public state: GameState;
public roomName: string;
public hostId: string;
public maxPlayers: number;
public password?: string;
constructor(
roomId: string,
roomName: string,
hostId: string,
maxPlayers: number,
password?: string
) {
this.roomName = roomName;
this.hostId = hostId;
this.maxPlayers = maxPlayers;
this.password = password;
this.state = {
roomId,
phase: GamePhase.LOBBY,
players: [],
currentRound: 1,
failedVotesCount: 0,
questResults: [null, null, null, null, null],
currentLeaderId: '',
leaderVotes: {},
proposedTeam: [],
teamVotes: {},
missionVotes: [],
missionHistory: [],
revealedVotes: [],
history: [],
hostId: hostId
};
}
addPlayer(id: string, name: string): Player {
// Asignar avatar aleatorio sin repetir (rebel001.jpg - rebel010.jpg)
// Obtener avatares ya usados
const usedAvatars = this.state.players.map(p => p.avatar);
// Crear lista de avatares disponibles
const allAvatars = Array.from({ length: 10 }, (_, i) =>
`rebel${(i + 1).toString().padStart(3, '0')}.jpg`
);
const availableAvatars = allAvatars.filter(av => !usedAvatars.includes(av));
// Si no hay avatares disponibles (más de 10 jugadores), usar cualquiera
const avatarStr = availableAvatars.length > 0
? availableAvatars[Math.floor(Math.random() * availableAvatars.length)]
: allAvatars[Math.floor(Math.random() * allAvatars.length)];
const player: Player = {
id,
name,
avatar: avatarStr,
isLeader: false
};
this.state.players.push(player);
this.log(`${name} se ha unido a la partida.`);
return player;
}
removePlayer(id: string) {
this.state.players = this.state.players.filter(p => p.id !== id);
}
startGame(): boolean {
const count = this.state.players.length;
if (count < 5 || count > 10) return false;
const config = GAME_CONFIG[count as keyof typeof GAME_CONFIG];
// 1. Asignar Roles
this.assignRoles(config.good, config.evil);
// 2. Asignar Líder inicial aleatorio
const leaderIndex = Math.floor(Math.random() * count);
this.state.players[leaderIndex].isLeader = true;
this.state.currentLeaderId = this.state.players[leaderIndex].id;
// 3. Iniciar juego - Fase Votación de Líder
this.state.phase = GamePhase.VOTE_LEADER;
this.state.leaderVotes = {}; // Reset votes
this.state.currentRound = 1;
this.log('¡La partida ha comenzado! Ahora deben votar para confirmar al Líder.');
return true;
}
// ... assignRoles se mantiene igual ...
private assignRoles(goodCount: number, evilCount: number) {
// Roles obligatorios
const roles: Role[] = [Role.MARLENE, Role.FRANCOTIRADOR]; // Updated roles
// Rellenar resto de malos
for (let i = 0; i < evilCount - 1; i++) roles.push(Role.COLABORACIONISTA); // Updated role
// Rellenar resto de buenos
for (let i = 0; i < goodCount - 1; i++) roles.push(Role.PARTISANO); // Updated role
// Barajar roles
const shuffledRoles = roles.sort(() => Math.random() - 0.5);
// Asignar a jugadores
this.state.players.forEach((player, index) => {
player.role = shuffledRoles[index];
// Asignar facción basada en el rol
if ([Role.MARLENE, Role.PARTISANO].includes(player.role)) { // Updated roles
player.faction = Faction.ALIADOS;
} else {
player.faction = Faction.ALEMANES;
}
});
}
// --- LOGICA DE VOTACIÓN DE LÍDER ---
voteLeader(playerId: string, approve: boolean) {
// Solo registrar el voto si el jugador existe y aún no ha votado
const player = this.state.players.find(p => p.id === playerId);
if (!player) return;
this.state.leaderVotes[playerId] = approve;
// Comprobar si todos han votado
if (Object.keys(this.state.leaderVotes).length === this.state.players.length) {
this.resolveLeaderVote();
}
}
// Método para forzar la resolución de votos cuando se acaba el tiempo
forceResolveLeaderVote() {
// Si nadie votó o no todos votaron, se considera fracaso
this.resolveLeaderVote();
}
private resolveLeaderVote() {
const totalPlayers = this.state.players.length;
const votesCount = Object.keys(this.state.leaderVotes).length;
// Si nadie votó, rechazar automáticamente
if (votesCount === 0) {
this.log(`Votación de Líder: nadie votó. Líder rechazado.`);
this.state.failedVotesCount++;
if (this.state.failedVotesCount >= 5) {
this.endGame(Faction.ALEMANES, 'Se han rechazado 5 líderes consecutivos.');
} else {
this.nextLeader();
}
return;
}
// Contar votos de aprobación y rechazo
const approves = Object.values(this.state.leaderVotes).filter(v => v === true).length;
const rejects = Object.values(this.state.leaderVotes).filter(v => v === false).length;
// La mayoría se calcula sobre los que votaron, no sobre el total
const isSuccess = approves > rejects;
this.log(`Votación de Líder: ${approves} a favor, ${rejects} en contra (${votesCount}/${totalPlayers} votaron)`);
if (isSuccess) {
// Líder Aprobado -> Fase de Construcción de Equipo
this.state.phase = GamePhase.TEAM_BUILDING;
this.state.proposedTeam = []; // Reset team selection
this.state.failedVotesCount = 0; // Reset contador al aprobar líder
this.log(`Líder ${this.state.players.find(p => p.id === this.state.currentLeaderId)?.name} confirmado con ${approves} votos a favor.`);
} else {
// Líder Rechazado -> Incrementar contador y pasar al siguiente líder
this.state.failedVotesCount++;
this.log(`Líder rechazado: ${rejects} votos en contra superan a ${approves} a favor. Pasando turno al siguiente jugador.`);
// Verificar si se alcanzó el límite de rechazos (5 votos fallidos = alemanes ganan)
if (this.state.failedVotesCount >= 5) {
this.endGame(Faction.ALEMANES, 'Se han rechazado 5 líderes consecutivos.');
} else {
this.nextLeader(); // Esto pondrá phase en VOTE_LEADER de nuevo
}
}
}
proposeTeam(playerIds: string[]): boolean {
// Validar tamaño del equipo según la ronda
const config = GAME_CONFIG[this.state.players.length as keyof typeof GAME_CONFIG];
const requiredSize = config.quests[this.state.currentRound - 1];
if (playerIds.length !== requiredSize) return false;
this.state.proposedTeam = playerIds;
this.state.phase = GamePhase.MISSION; // Ir directamente a la misión
this.state.missionVotes = []; // Resetear votos de misión
this.state.failedVotesCount = 0; // Reset contador de rechazos
this.log(`El líder ha seleccionado un equipo de ${playerIds.length} personas. ¡Comienza la misión!`);
return true;
}
voteForTeam(playerId: string, approve: boolean) {
this.state.teamVotes[playerId] = approve;
// Comprobar si todos han votado
if (Object.keys(this.state.teamVotes).length === this.state.players.length) {
this.resolveTeamVote();
}
}
private resolveTeamVote() {
const votes = Object.values(this.state.teamVotes);
const approves = votes.filter(v => v).length;
const rejects = votes.length - approves;
this.log(`Votación completada: ${approves} A favor - ${rejects} En contra.`);
if (approves > rejects) {
// Equipo Aprobado
this.state.phase = GamePhase.MISSION;
this.state.missionVotes = [];
this.state.failedVotesCount = 0;
this.log('El equipo ha sido aprobado. ¡Comienza la misión!');
} else {
// Equipo Rechazado
this.state.failedVotesCount++;
this.state.proposedTeam = [];
if (this.state.failedVotesCount >= 5) {
this.endGame(Faction.ALEMANES, 'Se han rechazado 5 equipos consecutivos.'); // Updated Faction
} else {
this.nextLeader(); // Pasa a VOTE_LEADER
this.log('El equipo fue rechazado. El liderazgo pasa al siguiente jugador.');
}
}
}
voteMission(success: boolean) {
this.state.missionVotes.push(success);
// Comprobar si todos los miembros del equipo han votado
if (this.state.missionVotes.length === this.state.proposedTeam.length) {
this.resolveMission();
}
}
private resolveMission() {
const fails = this.state.missionVotes.filter(v => !v).length;
const successes = this.state.missionVotes.filter(v => v).length;
const playerCount = this.state.players.length;
const round = this.state.currentRound;
// Regla especial: 4ta misión con 7+ jugadores necesita 2 fallos
let failsRequired = 1;
if (playerCount >= 7 && round === 4) {
failsRequired = 2;
}
const isSuccess = fails < failsRequired;
// 1. BARAJAR los votos para que no se sepa quién votó qué
const shuffledVotes = [...this.state.missionVotes].sort(() => Math.random() - 0.5);
// 2. Guardar en el histórico
const missionRecord = {
round,
team: [...this.state.proposedTeam],
votes: shuffledVotes,
successes,
fails,
isSuccess,
leaderId: this.state.currentLeaderId
};
this.state.missionHistory.push(missionRecord);
// 3. Actualizar resultado de la quest
this.state.questResults[round - 1] = isSuccess;
// 4. Pasar a fase MISSION_REVEAL (mostrar cartas una a una)
this.state.phase = GamePhase.MISSION_REVEAL;
this.state.revealedVotes = shuffledVotes; // Las cartas a revelar
this.log(`Misión ${round} completada. Revelando votos...`);
// El cliente controlará el avance a MISSION_RESULT con su timer
}
// Método para avanzar desde MISSION_REVEAL a MISSION_RESULT
finishReveal() {
this.state.phase = GamePhase.MISSION_RESULT;
}
// Método para avanzar desde MISSION_RESULT y continuar el juego
finishMissionResult() {
const round = this.state.currentRound;
const isSuccess = this.state.questResults[round - 1];
this.log(`Misión ${round}: ${isSuccess ? 'ÉXITO' : 'FRACASO'}`);
// Comprobar condiciones de victoria
const successes = this.state.questResults.filter(r => r === true).length;
const failures = this.state.questResults.filter(r => r === false).length;
if (failures >= 3) {
// 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 Francotirador tiene una última oportunidad...');
} else {
// Siguiente ronda
this.state.currentRound++;
this.nextLeader(); // Esto pone phase = VOTE_LEADER
this.state.proposedTeam = [];
this.state.missionVotes = [];
this.state.revealedVotes = [];
}
}
assassinKill(targetId: string) {
const target = this.state.players.find(p => p.id === targetId);
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 {
// 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!');
}
}
nextLeader() {
const currentIdx = this.state.players.findIndex(p => p.id === this.state.currentLeaderId);
const nextIdx = (currentIdx + 1) % this.state.players.length;
this.state.players[currentIdx].isLeader = false;
this.state.players[nextIdx].isLeader = true;
this.state.currentLeaderId = this.state.players[nextIdx].id;
// Fase de confirmar al nuevo líder
this.state.phase = GamePhase.VOTE_LEADER;
this.state.leaderVotes = {};
}
private endGame(winner: Faction, reason: string) {
this.state.winner = winner;
this.state.phase = GamePhase.GAME_OVER;
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
if (this.state.history.length > 50) this.state.history.shift();
}
}