feat: Títulos de misiones y fix timer votación post-misión

Nuevas funcionalidades:
- Títulos y subtítulos en cartas de misión
  * Título: 'MISIÓN X' (blanco, mayúsculas)
  * Subtítulo: Nombre de la misión (amarillo dorado)
  * Objeto missionNames con 5 nombres editables:
    1. Sabotaje en el Tren
    2. Rescate del Prisionero
    3. Destrucción del Puente
    4. Robo de Documentos
    5. Asalto al Cuartel General

Correcciones:
- Timer de votación de líder ahora se inicia correctamente después de terminar una misión
- Importado GamePhase en server/src/index.ts para comparaciones de fase
- Agregada lógica en finish_mission_result para iniciar timer cuando vuelve a VOTE_LEADER
- Votación se resuelve automáticamente si no todos votan (mayoría sobre votos emitidos)

Archivos modificados:
- client/src/components/GameBoard.tsx: Títulos de misiones
- server/src/index.ts: Fix timer post-misión
This commit is contained in:
Resistencia Dev
2025-12-08 13:13:33 +01:00
parent 06d2171871
commit 774e1b982d
2 changed files with 127 additions and 71 deletions

View File

@@ -48,6 +48,22 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
}
}, [gameState.phase, gameState.currentLeaderId]);
// Estado para controlar cuándo mostrar el tablero
const [showBoard, setShowBoard] = useState(false);
// Mostrar tablero solo 5 segundos después de MISSION_RESULT
useEffect(() => {
if (gameState.phase === GamePhase.MISSION_RESULT) {
setShowBoard(true);
const timer = setTimeout(() => {
setShowBoard(false);
}, 5000); // 5 segundos
return () => clearTimeout(timer);
} else {
setShowBoard(false);
}
}, [gameState.phase]);
const currentPlayer = gameState.players.find(p => p.id === currentPlayerId);
const isLeader = gameState.currentLeaderId === currentPlayerId; // FIX: Usar currentLeaderId del estado
@@ -80,6 +96,15 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
{ left: '82%', top: '40%' }, // Misión 5
];
// Nombres de las misiones
const missionNames = [
'Sabotaje en el Tren',
'Rescate del Prisionero',
'Destrucción del Puente',
'Robo de Documentos',
'Asalto al Cuartel General'
];
// --- UI/Efectos para FASES TEMPRANAS ---
const isHost = gameState.hostId === currentPlayerId;
@@ -277,82 +302,106 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
<div className="relative z-10 w-full flex flex-col items-center">
{/* --- MAPA TÁCTICO (TABLERO) --- */}
{/* --- MAPA TÁCTICO (TABLERO) O CARTA DE MISIÓN --- */}
<div className="relative w-full max-w-5xl aspect-video mt-4 shadow-2xl border-4 border-gray-800 rounded-lg overflow-hidden bg-[#2a2a2a]">
<Image
src="/assets/images/ui/board_map.jpg"
alt="Tactical Map"
fill
className="object-contain"
/>
{showBoard ? (
<>
{/* TABLERO CON TOKENS */}
<Image
src="/assets/images/ui/board_map.jpg"
alt="Tactical Map"
fill
className="object-contain"
/>
{/* TOKENS SOBRE EL MAPA */}
{missionCoords.map((coord, idx) => {
const result = gameState.questResults[idx];
const isCurrent = gameState.currentRound === idx + 1;
{/* TOKENS SOBRE EL MAPA */}
{missionCoords.map((coord, idx) => {
const result = gameState.questResults[idx];
const isCurrent = gameState.currentRound === idx + 1;
return (
<div
key={idx}
className="absolute w-[10%] aspect-square flex items-center justify-center"
style={{
left: coord.left,
top: coord.top,
transform: 'translate(-50%, -50%)'
}}
>
{/* Marcador de Ronda Actual */}
{isCurrent && (
<motion.div
layoutId="round-marker"
className="absolute inset-0 z-10"
initial={{ scale: 1.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
return (
<div
key={idx}
className="absolute w-[10%] aspect-square flex items-center justify-center"
style={{
left: coord.left,
top: coord.top,
transform: 'translate(-50%, -50%)'
}}
>
<Image
src="/assets/images/tokens/marker_round.png"
alt="Current Round"
fill
className="object-contain drop-shadow-lg"
/>
</motion.div>
)}
{/* Marcador de Ronda Actual */}
{isCurrent && (
<motion.div
layoutId="round-marker"
className="absolute inset-0 z-10"
initial={{ scale: 1.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<Image
src="/assets/images/tokens/marker_round.png"
alt="Current Round"
fill
className="object-contain drop-shadow-lg"
/>
</motion.div>
)}
{/* Resultado de Misión (Éxito/Fracaso) */}
{result === true && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20 flex items-center justify-center"
>
<div className="w-[80%] h-[80%] relative">
<Image src="/assets/images/tokens/marker_score_blue.png" alt="Success" fill className="object-contain drop-shadow-lg" />
</div>
</motion.div>
)}
{result === false && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20 flex items-center justify-center"
>
<div className="w-[80%] h-[80%] relative">
<Image src="/assets/images/tokens/marker_score_red.png" alt="Fail" fill className="object-contain drop-shadow-lg" />
</div>
</motion.div>
)}
{/* Resultado de Misión (Éxito/Fracaso) */}
{result === true && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20 flex items-center justify-center"
>
<div className="w-[80%] h-[80%] relative">
<Image src="/assets/images/tokens/marker_score_blue.png" alt="Success" fill className="object-contain drop-shadow-lg" />
</div>
</motion.div>
)}
{result === false && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20 flex items-center justify-center"
>
<div className="w-[80%] h-[80%] relative">
<Image src="/assets/images/tokens/marker_score_red.png" alt="Fail" fill className="object-contain drop-shadow-lg" />
</div>
</motion.div>
)}
</div>
);
})}
{/* TRACK DE VOTOS FALLIDOS */}
<div className="absolute bottom-[5%] left-[2%] bg-black/60 p-2 rounded border border-white/20">
<div className="text-[10px] text-gray-300 uppercase mb-1 text-center">Votos Rechazados</div>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<div key={i} className={`w-3 h-3 rounded-full border border-gray-500 ${i < gameState.failedVotesCount ? 'bg-red-500' : 'bg-transparent'}`} />
))}
</div>
</div>
);
})}
{/* TRACK DE VOTOS FALLIDOS (Pequeño indicador en la esquina inferior izquierda del mapa) */}
<div className="absolute bottom-[5%] left-[2%] bg-black/60 p-2 rounded border border-white/20">
<div className="text-[10px] text-gray-300 uppercase mb-1 text-center">Votos Rechazados</div>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<div key={i} className={`w-3 h-3 rounded-full border border-gray-500 ${i < gameState.failedVotesCount ? 'bg-red-500' : 'bg-transparent'}`} />
))}
</div>
</div>
</>
) : (
/* CARTA DE MISIÓN CON TÍTULO */
<>
<Image
src={`/assets/images/missions/mission${gameState.currentRound}.png`}
alt={`Mission ${gameState.currentRound}`}
fill
className="object-contain"
/>
{/* Título y subtítulo sobre la carta */}
<div className="absolute top-4 left-0 right-0 flex flex-col items-center z-10">
<h2 className="text-4xl font-bold text-white drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] mb-2 uppercase tracking-wider">
Misión {gameState.currentRound}
</h2>
<h3 className="text-2xl font-semibold text-yellow-400 drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] uppercase tracking-wide">
{missionNames[gameState.currentRound - 1]}
</h3>
</div>
</>
)}
</div>
{/* --- ÁREA DE JUEGO (CARTAS Y ACCIONES) --- */}

View File

@@ -5,6 +5,7 @@ import cors from 'cors';
import dotenv from 'dotenv';
import crypto from 'crypto';
import { Game } from './models/Game';
import { GamePhase } from '../../shared/types';
dotenv.config();
@@ -251,9 +252,15 @@ io.on('connection', (socket) => {
// 5.2 FINALIZAR PANTALLA DE RESULTADO
socket.on('finish_mission_result', ({ roomId }) => {
const game = games[roomId];
if (game && game.hostId === socket.id && game.state.phase === 'mission_result') {
if (game && game.hostId === socket.id && game.state.phase === GamePhase.MISSION_RESULT) {
game.finishMissionResult();
io.to(roomId).emit('game_state', game.state);
// Si volvió a vote_leader (nueva ronda), iniciar timer
// TypeScript no detecta que finishMissionResult() cambia la fase, usamos type assertion
if ((game.state.phase as GamePhase) === GamePhase.VOTE_LEADER) {
startLeaderVoteTimer(roomId);
}
}
});