fix: Sistema de votación de líder completamente refactorizado
- Timer de 10 segundos que se reinicia correctamente al cambiar de líder - Votación por mayoría calculada sobre votos emitidos, no total de jugadores - Si nadie vota, líder rechazado automáticamente - Alemanes pueden ver carta de sabotaje en misiones - Reset correcto de selectedTeam al cambiar de fase - Contador de votos fallidos incrementa correctamente - Logs mejorados para debugging Fixes: - Timer visual se reinicia con key basada en currentLeaderId - Facción verificada correctamente (Faction.ALEMANES vs 'spies') - forceResolveLeaderVote llama a resolución con votos actuales - selectedTeam se limpia al salir de TEAM_BUILDING
BIN
client/public/assets/audio/Rondas (Copiar).ogg
Normal file
BIN
client/public/assets/audio/Rondas.mp3
Normal file
BIN
client/public/assets/images/missions/mission1.png
Normal file
|
After Width: | Height: | Size: 764 KiB |
BIN
client/public/assets/images/missions/mission2.png
Normal file
|
After Width: | Height: | Size: 720 KiB |
BIN
client/public/assets/images/missions/mission3.png
Normal file
|
After Width: | Height: | Size: 680 KiB |
BIN
client/public/assets/images/missions/mission4.png
Normal file
|
After Width: | Height: | Size: 716 KiB |
BIN
client/public/assets/images/missions/mission5.png
Normal file
|
After Width: | Height: | Size: 691 KiB |
BIN
client/public/assets/images/missions2/army.jpg
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
client/public/assets/images/missions2/bandera.jpg
Normal file
|
After Width: | Height: | Size: 475 KiB |
BIN
client/public/assets/images/missions2/bonds.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
client/public/assets/images/missions2/martell.jpg
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
client/public/assets/images/missions2/mujer.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
client/public/assets/images/missions2/mujer2.jpg
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
client/public/assets/images/missions2/soldados.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
client/public/assets/images/missions2/soldados2.jpg
Normal file
|
After Width: | Height: | Size: 710 KiB |
BIN
client/public/assets/images/missions2/soldados3.jpg
Normal file
|
After Width: | Height: | Size: 119 KiB |
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { GameState, GamePhase, Player, GAME_CONFIG } from '../../../shared/types';
|
||||
import { GameState, GamePhase, Player, GAME_CONFIG, Faction } from '../../../shared/types';
|
||||
import MissionReveal from './MissionReveal';
|
||||
import MissionResult from './MissionResult';
|
||||
|
||||
@@ -41,6 +41,13 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
|
||||
}
|
||||
}, [gameState.phase]);
|
||||
|
||||
// Reset selectedTeam cuando no estamos en TEAM_BUILDING o cambia el líder
|
||||
useEffect(() => {
|
||||
if (gameState.phase !== GamePhase.TEAM_BUILDING) {
|
||||
setSelectedTeam([]);
|
||||
}
|
||||
}, [gameState.phase, gameState.currentLeaderId]);
|
||||
|
||||
|
||||
const currentPlayer = gameState.players.find(p => p.id === currentPlayerId);
|
||||
const isLeader = gameState.currentLeaderId === currentPlayerId; // FIX: Usar currentLeaderId del estado
|
||||
@@ -368,9 +375,9 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
|
||||
¿Aceptas a <span className="text-yellow-400">{gameState.players.find(p => p.id === gameState.currentLeaderId)?.name}</span> como Líder?
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
{/* Timer visual (solo muestra el tiempo, el servidor controla el timeout) */}
|
||||
{!gameState.leaderVotes?.[currentPlayerId] && (
|
||||
<VotingTimer onTimeout={() => actions.voteLeader(null)} />
|
||||
<VotingTimer key={gameState.currentLeaderId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -547,8 +554,8 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
{/* Carta de Sabotaje segundo (solo para espías) */}
|
||||
{currentPlayer?.faction === 'spies' && (
|
||||
{/* Carta de Sabotaje segundo (solo para alemanes) */}
|
||||
{currentPlayer?.faction === Faction.ALEMANES && (
|
||||
<button onClick={() => handleMissionVote(false)} className="group transition-opacity" style={{ opacity: missionVote !== null && missionVote !== false ? 0.5 : 1 }} disabled={missionVote !== null}>
|
||||
<motion.div
|
||||
className="w-64 h-96 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50"
|
||||
@@ -563,8 +570,8 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Carta de Sabotaje primero (solo para espías) */}
|
||||
{currentPlayer?.faction === 'spies' && (
|
||||
{/* Carta de Sabotaje primero (solo para alemanes) */}
|
||||
{currentPlayer?.faction === Faction.ALEMANES && (
|
||||
<button onClick={() => handleMissionVote(false)} className="group transition-opacity" style={{ opacity: missionVote !== null && missionVote !== false ? 0.5 : 1 }} disabled={missionVote !== null}>
|
||||
<motion.div
|
||||
className="w-64 h-96 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50"
|
||||
@@ -713,18 +720,17 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
|
||||
);
|
||||
}
|
||||
|
||||
// Subcomponente para el Timer de Votación
|
||||
function VotingTimer({ onTimeout }: { onTimeout: () => void }) {
|
||||
// Subcomponente para el Timer de Votación (solo visual, el servidor controla el timeout real)
|
||||
function VotingTimer() {
|
||||
const [timeLeft, setTimeLeft] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft <= 0) {
|
||||
onTimeout();
|
||||
return;
|
||||
return; // El servidor se encargará de forzar la resolución
|
||||
}
|
||||
const interval = setInterval(() => setTimeLeft(t => t - 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [timeLeft, onTimeout]);
|
||||
}, [timeLeft]);
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 bg-red-600/80 text-white w-16 h-16 rounded-full flex items-center justify-center border-4 border-red-400 animate-pulse text-2xl font-bold font-mono">
|
||||
|
||||
@@ -97,7 +97,7 @@ export const useSocket = () => {
|
||||
proposeTeam,
|
||||
voteTeam,
|
||||
voteMission,
|
||||
voteLeader: (approve: boolean | null) => socket?.emit('vote_leader', { roomId: gameState?.roomId, approve }),
|
||||
voteLeader: (approve: boolean) => socket?.emit('vote_leader', { roomId: gameState?.roomId, approve }),
|
||||
assassinKill,
|
||||
finishIntro: () => socket?.emit('finish_intro', { roomId: gameState?.roomId }),
|
||||
finishReveal: () => socket?.emit('finish_reveal', { roomId: gameState?.roomId }),
|
||||
|
||||
@@ -41,22 +41,22 @@ const MISSION_NAMES = [
|
||||
"Operación Eiche", "Operación León Marino", "Operación Urano"
|
||||
];
|
||||
|
||||
// Helper para iniciar timer de votación de líder
|
||||
// Helper para iniciar timer de votación de líder (10 segundos)
|
||||
function startLeaderVoteTimer(roomId: string) {
|
||||
if (voteTimers[roomId]) clearTimeout(voteTimers[roomId]);
|
||||
voteTimers[roomId] = setTimeout(() => {
|
||||
const g = games[roomId];
|
||||
if (g && g.state.phase === 'vote_leader') {
|
||||
// Pasar al siguiente líder sin resolver (rechazar implícitamente)
|
||||
g.nextLeader();
|
||||
// Forzar resolución de votos cuando se acaba el tiempo
|
||||
g.forceResolveLeaderVote();
|
||||
io.to(roomId).emit('game_state', g.state);
|
||||
|
||||
// Si sigue en vote_leader (nuevo líder), reiniciar timer
|
||||
// Si sigue en vote_leader (líder rechazado, nuevo líder), reiniciar timer
|
||||
if (g.state.phase === 'vote_leader') {
|
||||
startLeaderVoteTimer(roomId);
|
||||
}
|
||||
}
|
||||
}, 11000); // 11 segundos
|
||||
}, 10000); // 10 segundos
|
||||
}
|
||||
|
||||
const generateRoomName = () => {
|
||||
|
||||
@@ -128,7 +128,11 @@ export class Game {
|
||||
}
|
||||
|
||||
// --- LOGICA DE VOTACIÓN DE LÍDER ---
|
||||
voteLeader(playerId: string, approve: boolean | null) {
|
||||
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
|
||||
@@ -137,33 +141,55 @@ export class Game {
|
||||
}
|
||||
}
|
||||
|
||||
// Método para forzar la resolución de votos (llamado por timeout)
|
||||
// Método para forzar la resolución de votos cuando se acaba el tiempo
|
||||
forceResolveLeaderVote() {
|
||||
// Registrar votos null para los que no votaron
|
||||
this.state.players.forEach(player => {
|
||||
if (!(player.id in this.state.leaderVotes)) {
|
||||
this.state.leaderVotes[player.id] = null;
|
||||
}
|
||||
});
|
||||
// Si nadie votó o no todos votaron, se considera fracaso
|
||||
this.resolveLeaderVote();
|
||||
}
|
||||
|
||||
private resolveLeaderVote() {
|
||||
const votes = Object.values(this.state.leaderVotes);
|
||||
const approves = votes.filter(v => v === true).length;
|
||||
const rejects = votes.filter(v => v === false || v === null).length; // null cuenta como rechazo
|
||||
const totalPlayers = this.state.players.length;
|
||||
const votesCount = Object.keys(this.state.leaderVotes).length;
|
||||
|
||||
this.log(`Votación de Líder: ${approves} A favor - ${rejects} En contra.`);
|
||||
// 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 (approves > rejects) {
|
||||
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.log('Líder confirmado. Ahora debe proponer un equipo.');
|
||||
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 -> Siguiente líder
|
||||
this.log('Líder rechazado. Pasando turno al siguiente jugador.');
|
||||
this.nextLeader(); // Esto pondrá phase en VOTE_LEADER de nuevo
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||