feat: Mejoras UI - Timer, mapa resultado misión y tokens

- Mover timer de votación a esquina superior izquierda (fixed, 20px margen)
- Eliminar contador de votos rechazados en resultado de misión
- Ajustar posiciones de tokens de victoria/fracaso en el mapa
- Mantener mapa visible durante toda la fase MISSION_RESULT (eliminar timeout de 7s)
- Cambiar título intro de 'Guerra Total' a 'Traidores en París'
- Ajustar tamaño de cartas aceptar/rechazar líder a cuadradas (w-32 h-32)

TODO: Afinar posiciones de tokens 3, 4 y 5 en el mapa
This commit is contained in:
Resistencia Dev
2025-12-13 01:18:52 +01:00
parent c67f97845a
commit 13d56c2431
33 changed files with 229 additions and 115 deletions

View File

@@ -236,13 +236,15 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
}
return (
<div className="relative w-full h-screen flex flex-col items-center overflow-hidden">
<div className="relative w-full h-screen flex flex-col overflow-hidden">
{/* Fondo */}
<div className="absolute inset-0 z-0 opacity-40">
<Image src="/assets/images/ui/bg_game.png" alt="Game Background" fill className="object-cover" />
<div className="absolute inset-0 bg-black/60" />
</div>
<div className="relative z-10 w-full flex flex-col items-center">
{/* Contenedor principal con altura calculada */}
<div className={`relative z-10 w-full flex flex-col items-center ${gameState.players.length > 6 ? 'h-[70%]' : 'h-[85%]'}`}>
{/* --- MAPA TÁCTICO (TABLERO) --- */}
<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]">
@@ -315,7 +317,7 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
</div>
{/* --- ÁREA DE JUEGO (CARTAS Y ACCIONES) --- */}
<div className="flex-1 w-full max-w-6xl relative mt-4 px-4">
<div className="flex-1 w-full max-w-6xl relative mt-4 px-4 overflow-y-auto">
<AnimatePresence mode="wait">
{/* FASE: VOTACIÓN DE LÍDER */}
@@ -488,57 +490,184 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
</AnimatePresence>
</div>
</div>
{/* JUGADORES (TIENDA DE CAMPAÑA) */}
<div className="z-10 w-full overflow-x-auto pb-4">
<div className="flex justify-center gap-4 min-w-max px-4">
{gameState.players.map((player) => {
const isSelected = selectedTeam.includes(player.id);
const isMe = player.id === currentPlayerId;
{/* Área de jugadores fija en el pie - Fuera del contenedor principal */}
<div className={`z-20 w-full bg-black/80 border-t border-white/10 backdrop-blur-md ${gameState.players.length > 6 ? 'h-[30vh]' : 'h-[15vh]'}`}>
<div className={`w-full h-full p-2 ${gameState.players.length > 6 ? 'grid grid-cols-5 auto-rows-fr gap-y-2 gap-x-4 content-center justify-items-center' : 'flex items-center justify-center gap-6'}`}>
{gameState.players.map((player) => {
const isSelected = selectedTeam.includes(player.id);
const isMe = player.id === currentPlayerId;
// Avatar logic
const avatarSrc = `/assets/images/characters/${player.avatar}`;
// Avatar logic
const avatarSrc = `/assets/images/characters/${player.avatar}`;
return (
<div
key={player.id}
onClick={() => isLeader && gameState.phase === GamePhase.TEAM_BUILDING && toggleTeamSelection(player.id)}
className={`
relative flex flex-col items-center cursor-pointer transition-all duration-300 group
${isSelected ? 'scale-110 z-10' : 'scale-100 opacity-70 hover:opacity-100 hover:scale-105'}
`}
>
{/* Avatar */}
<div className={`
relative rounded-full border-2 overflow-hidden shadow-lg bg-black transition-all
${gameState.players.length > 6 ? 'w-12 h-12' : 'w-20 h-20'}
${isSelected ? 'border-yellow-400 ring-4 ring-yellow-400/30 shadow-yellow-400/20' : 'border-gray-500 group-hover:border-gray-300'}
${player.isLeader ? 'ring-2 ring-white' : ''}
`}>
<Image
src={avatarSrc}
alt={player.name}
fill
className="object-cover"
/>
{/* Icono de Líder */}
{player.isLeader && (
<div className={`absolute bottom-0 right-0 bg-yellow-500 rounded-full p-1 flex items-center justify-center text-[10px] text-black font-bold border border-white z-20 shadow-sm ${gameState.players.length > 6 ? 'w-5 h-5' : 'w-6 h-6'}`}>
L
</div>
)}
</div>
{/* Nombre */}
<span className={`
mt-1 text-xs font-mono px-2 py-0.5 rounded shadow-sm whitespace-nowrap max-w-[100px] truncate
${isMe ? 'bg-blue-600 text-white font-bold' : 'bg-black/60 text-gray-300 border border-white/10'}
`}>
{player.name}
</span>
</div>
);
})}
</div>
</div>
{/* HISTÓRICO DE MISIONES (Esquina superior derecha) */}
{gameState.missionHistory.length > 0 && (
<div className="absolute top-4 right-4 bg-black/80 p-3 rounded-lg border border-white/20 backdrop-blur-sm z-50">
<div className="text-[10px] text-gray-400 uppercase mb-2 text-center font-bold tracking-wider">Historial</div>
<div className="flex gap-2">
{gameState.missionHistory.map((mission, idx) => {
return (
<div
key={player.id}
onClick={() => isLeader && gameState.phase === GamePhase.TEAM_BUILDING && toggleTeamSelection(player.id)}
className={`
relative flex flex-col items-center cursor-pointer transition-all duration-300
${isSelected ? 'scale-110' : 'scale-100 opacity-80 hover:opacity-100'}
`}
>
{/* Avatar */}
<div className={`
w-16 h-16 rounded-full border-2 overflow-hidden relative shadow-lg bg-black
${isSelected ? 'border-yellow-400 ring-4 ring-yellow-400/30' : 'border-gray-400'}
${player.isLeader ? 'ring-2 ring-white' : ''}
`}>
<Image
src={avatarSrc}
alt={player.name}
fill
className="object-cover"
/>
{/* Icono de Líder */}
{player.isLeader && (
<div className="absolute bottom-0 right-0 bg-yellow-500 rounded-full p-1 w-6 h-6 flex items-center justify-center text-xs text-black font-bold border border-white z-10">
L
</div>
)}
<div key={idx} className="relative">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold border-2 ${mission.isSuccess
? 'bg-blue-600 border-blue-400 text-white'
: 'bg-red-600 border-red-400 text-white'
}`}
title={`Misión ${mission.round}: ${mission.isSuccess ? 'Éxito' : 'Fracaso'} (${mission.successes}${mission.fails}✗)`}
>
{mission.round}
</div>
{/* Nombre */}
<span className={`mt-2 text-xs font-mono px-2 py-0.5 rounded ${isMe ? 'bg-blue-600 text-white' : 'bg-black/50 text-gray-300'}`}>
{player.name}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
{/* HISTÓRICO DE MISIONES (Esquina superior derecha) */}
{gameState.missionHistory.length > 0 && (
<div className="absolute top-4 right-4 bg-black/80 p-3 rounded-lg border border-white/20 backdrop-blur-sm">
<div className="text-[10px] text-gray-400 uppercase mb-2 text-center font-bold tracking-wider">Historial</div>
// Subcomponente para el Timer de Votación
function VotingTimer({ onTimeout }: { onTimeout: () => void }) {
const [timeLeft, setTimeLeft] = useState(10);
useEffect(() => {
if (timeLeft <= 0) {
onTimeout();
return;
}
const interval = setInterval(() => setTimeLeft(t => t - 1), 1000);
return () => clearInterval(interval);
}, [timeLeft, onTimeout]);
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">
{timeLeft}
</div>
);
}
// Subcomponente para la revelación de cartas de misión
function MissionReveal({ votes, onComplete }: { votes: boolean[], onComplete: () => void }) {
const [revealedCount, setRevealedCount] = useState(0);
useEffect(() => {
if (revealedCount < votes.length) {
const timer = setTimeout(() => {
setRevealedCount(prev => prev + 1);
}, 1500);
return () => clearTimeout(timer);
} else if (revealedCount === votes.length) {
const timer = setTimeout(() => {
onComplete();
}, 2000);
return () => clearTimeout(timer);
}
}, [revealedCount, votes.length, onComplete]);
return (
<motion.div
className="flex flex-col items-center gap-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<h2 className="text-3xl font-bold text-white mb-4 drop-shadow-lg">Revelando Votos...</h2>
<div className="flex gap-4 flex-wrap justify-center">
{votes.map((vote, idx) => (
<motion.div
key={idx}
initial={{ rotateY: 180, opacity: 0 }}
animate={idx < revealedCount ? { rotateY: 0, opacity: 1 } : { rotateY: 180, opacity: 0 }}
transition={{ duration: 0.6, delay: 0 }}
className="w-32 h-48 relative"
>
<Image
src={vote ? '/assets/images/tokens/mission_success.png' : '/assets/images/tokens/mission_fail.png'}
alt={vote ? 'Success' : 'Fail'}
fill
className="object-contain"
/>
</motion.div>
))}
</div>
</motion.div>
);
}
// Subcomponente para el resultado de la misión
function MissionResult({ gameState, onContinue }: { gameState: GameState, onContinue: () => void }) {
const lastResult = gameState.questResults[gameState.currentRound - 1];
const lastMission = gameState.missionHistory[gameState.missionHistory.length - 1];
return (
<motion.div
className="flex flex-col items-center gap-6"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
<h2 className={`text-5xl font-bold mb-4 drop-shadow-lg ${lastResult ? 'text-blue-400' : 'text-red-400'}`}>
{lastResult ? '¡MISIÓN EXITOSA!' : '¡MISIÓN FALLIDA!'}
</h2>
<div className="bg-black/80 p-6 rounded-lg border border-white/20">
<div className="text-white text-xl mb-4">
Votos de Éxito: <span className="text-blue-400 font-bold">{lastMission?.successes || 0}</span>
</div>
<div className="text-white text-xl">
Votos de Sabotaje: <span className="text-red-400 font-bold">{lastMission?.fails || 0}</span>
</div>
</div>
<button
onClick={onContinue}
className="bg-white/20 hover:bg-white/40 border border-white px-8 py-3 rounded text-white font-bold uppercase tracking-widest backdrop-blur-sm transition-all"
>
Continuar
</button>
</motion.div>
);
}