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:
@@ -48,6 +48,22 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
|
|||||||
}
|
}
|
||||||
}, [gameState.phase, gameState.currentLeaderId]);
|
}, [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 currentPlayer = gameState.players.find(p => p.id === currentPlayerId);
|
||||||
const isLeader = gameState.currentLeaderId === currentPlayerId; // FIX: Usar currentLeaderId del estado
|
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
|
{ 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 ---
|
// --- UI/Efectos para FASES TEMPRANAS ---
|
||||||
const isHost = gameState.hostId === currentPlayerId;
|
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">
|
<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]">
|
<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
|
{showBoard ? (
|
||||||
src="/assets/images/ui/board_map.jpg"
|
<>
|
||||||
alt="Tactical Map"
|
{/* TABLERO CON TOKENS */}
|
||||||
fill
|
<Image
|
||||||
className="object-contain"
|
src="/assets/images/ui/board_map.jpg"
|
||||||
/>
|
alt="Tactical Map"
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* TOKENS SOBRE EL MAPA */}
|
{/* TOKENS SOBRE EL MAPA */}
|
||||||
{missionCoords.map((coord, idx) => {
|
{missionCoords.map((coord, idx) => {
|
||||||
const result = gameState.questResults[idx];
|
const result = gameState.questResults[idx];
|
||||||
const isCurrent = gameState.currentRound === idx + 1;
|
const isCurrent = gameState.currentRound === idx + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="absolute w-[10%] aspect-square flex items-center justify-center"
|
className="absolute w-[10%] aspect-square flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
left: coord.left,
|
left: coord.left,
|
||||||
top: coord.top,
|
top: coord.top,
|
||||||
transform: 'translate(-50%, -50%)'
|
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 }}
|
|
||||||
>
|
>
|
||||||
<Image
|
{/* Marcador de Ronda Actual */}
|
||||||
src="/assets/images/tokens/marker_round.png"
|
{isCurrent && (
|
||||||
alt="Current Round"
|
<motion.div
|
||||||
fill
|
layoutId="round-marker"
|
||||||
className="object-contain drop-shadow-lg"
|
className="absolute inset-0 z-10"
|
||||||
/>
|
initial={{ scale: 1.5, opacity: 0 }}
|
||||||
</motion.div>
|
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) */}
|
{/* Resultado de Misión (Éxito/Fracaso) */}
|
||||||
{result === true && (
|
{result === true && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }} animate={{ scale: 1 }}
|
initial={{ scale: 0 }} animate={{ scale: 1 }}
|
||||||
className="absolute inset-0 z-20 flex items-center justify-center"
|
className="absolute inset-0 z-20 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<div className="w-[80%] h-[80%] relative">
|
<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" />
|
<Image src="/assets/images/tokens/marker_score_blue.png" alt="Success" fill className="object-contain drop-shadow-lg" />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{result === false && (
|
{result === false && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }} animate={{ scale: 1 }}
|
initial={{ scale: 0 }} animate={{ scale: 1 }}
|
||||||
className="absolute inset-0 z-20 flex items-center justify-center"
|
className="absolute inset-0 z-20 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<div className="w-[80%] h-[80%] relative">
|
<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" />
|
<Image src="/assets/images/tokens/marker_score_red.png" alt="Fail" fill className="object-contain drop-shadow-lg" />
|
||||||
</div>
|
</div>
|
||||||
</motion.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>
|
</div>
|
||||||
);
|
</>
|
||||||
})}
|
) : (
|
||||||
|
/* CARTA DE MISIÓN CON TÍTULO */
|
||||||
{/* 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">
|
<Image
|
||||||
<div className="text-[10px] text-gray-300 uppercase mb-1 text-center">Votos Rechazados</div>
|
src={`/assets/images/missions/mission${gameState.currentRound}.png`}
|
||||||
<div className="flex gap-1">
|
alt={`Mission ${gameState.currentRound}`}
|
||||||
{[...Array(5)].map((_, i) => (
|
fill
|
||||||
<div key={i} className={`w-3 h-3 rounded-full border border-gray-500 ${i < gameState.failedVotesCount ? 'bg-red-500' : 'bg-transparent'}`} />
|
className="object-contain"
|
||||||
))}
|
/>
|
||||||
</div>
|
{/* Título y subtítulo sobre la carta */}
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* --- ÁREA DE JUEGO (CARTAS Y ACCIONES) --- */}
|
{/* --- ÁREA DE JUEGO (CARTAS Y ACCIONES) --- */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import cors from 'cors';
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { Game } from './models/Game';
|
import { Game } from './models/Game';
|
||||||
|
import { GamePhase } from '../../shared/types';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -251,9 +252,15 @@ io.on('connection', (socket) => {
|
|||||||
// 5.2 FINALIZAR PANTALLA DE RESULTADO
|
// 5.2 FINALIZAR PANTALLA DE RESULTADO
|
||||||
socket.on('finish_mission_result', ({ roomId }) => {
|
socket.on('finish_mission_result', ({ roomId }) => {
|
||||||
const game = games[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();
|
game.finishMissionResult();
|
||||||
io.to(roomId).emit('game_state', game.state);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user