feat: Actualizar roles y facciones a Francia Ocupada

- Cambiar nombre del juego de 'La Resistencia' a 'Francia Ocupada'
- Actualizar roles: Marlene, Capitán Philippe, Partisano, Comandante Schmidt, Francotirador, Agente Doble, Infiltrado, Colaboracionista
- Actualizar facciones: Aliados vs Alemanes
- Implementar timer de votación de líder con auto-avance
- Eliminar componentes de debug
This commit is contained in:
Resistencia Dev
2025-12-07 00:20:33 +01:00
parent 8f95413782
commit 9e0e343868
8 changed files with 177 additions and 118 deletions

View File

@@ -5,7 +5,7 @@ import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'La Resistencia: WWII',
title: 'Francia Ocupada: WWII',
description: 'Juego de deducción social ambientado en la Segunda Guerra Mundial',
}

View File

@@ -173,7 +173,7 @@ export default function Home() {
<div className="flex items-center gap-4">
<Image src="/assets/images/ui/logo.png" alt="Logo" width={150} height={50} className="object-contain filter drop-shadow hidden md:block" />
<h1 className="text-2xl font-bold tracking-widest uppercase text-yellow-600">
La Resistencia
Francia Ocupada
</h1>
</div>
{view === 'lobby' && (

View File

@@ -63,26 +63,6 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
actions.voteMission(vote);
};
// Componente de Debug para mostrar facción
const DebugInfo = () => (
<div className="fixed top-2 left-2 bg-black/80 text-white p-3 rounded-lg text-xs z-50 border border-yellow-500 font-mono shadow-xl pointer-events-none">
<div className="font-bold text-yellow-400 mb-1 flex items-center gap-2">
<span>🐛 DEBUG INFO</span>
</div>
<div className="space-y-1">
<div>Fase: <span className="text-cyan-400 font-bold">{gameState.phase}</span></div>
<div>ID: <span className="text-green-400">{currentPlayerId}</span></div>
<div>Facción: <span className={`font-bold ${currentPlayer?.faction === 'spies' ? 'text-red-500' : 'text-blue-400'}`}>
{currentPlayer?.faction || 'UNDEFINED'}
</span></div>
<div>Rol: <span className="text-purple-400">{currentPlayer?.role || 'UNDEFINED'}</span></div>
{gameState.currentLeaderId && (
<div>Líder: <span className="text-yellow-400">{gameState.currentLeaderId === currentPlayerId ? 'TÚ' : gameState.currentLeaderId}</span></div>
)}
</div>
</div>
);
// Coordenadas porcentuales de los hexágonos de misión en el mapa
const missionCoords = [
@@ -109,12 +89,14 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
Guerra Total
</h1>
{/* Audio Auto-Play */}
<audio
src="/assets/audio/Intro.ogg"
autoPlay
onEnded={() => isHost && actions.finishIntro()}
/>
{/* Audio Auto-Play - Solo para el host */}
{isHost && (
<audio
src="/assets/audio/Intro.ogg"
autoPlay
onEnded={() => actions.finishIntro()}
/>
)}
{isHost && (
<button
@@ -132,31 +114,31 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
if (gameState.phase === 'reveal_role' as any) {
// Determinar imagen basada en el rol
// Mapeo básico:
// Merlin -> good_merlin.png
// Percival -> good_percival.png
// Servant -> good_soldier_X.png (random)
// Assassin -> evil_assassin.png
// Morgana -> evil_morgana.png
// Mordred -> evil_mordred.png
// Oberon -> evil_oberon.png
// Minion -> evil_minion_X.png
// Mapeo actualizado:
// Marlene -> good_merlin.png
// Capitán Philippe -> good_percival.png
// Partisano -> good_soldier_X.png (random)
// Francotirador -> evil_assassin.png
// Agente Doble -> evil_morgana.png
// Comandante Schmidt -> evil_mordred.png
// Infiltrado -> evil_oberon.png
// Colaboracionista -> evil_minion_X.png
let roleImage = '/assets/images/characters/good_soldier_1.png'; // Default
const role = currentPlayer?.role;
if (role === 'merlin') roleImage = '/assets/images/characters/good_merlin.png';
else if (role === 'assassin') roleImage = '/assets/images/characters/evil_assassin.png';
else if (role === 'percival') roleImage = '/assets/images/characters/good_percival.png';
else if (role === 'morgana') roleImage = '/assets/images/characters/evil_morgana.png';
else if (role === 'mordred') roleImage = '/assets/images/characters/evil_mordred.png';
else if (role === 'oberon') roleImage = '/assets/images/characters/evil_oberon.png';
else if (role === 'loyal_servant') {
if (role === 'marlene') roleImage = '/assets/images/characters/good_merlin.png';
else if (role === 'francotirador') roleImage = '/assets/images/characters/evil_assassin.png';
else if (role === 'capitan_philippe') roleImage = '/assets/images/characters/good_percival.png';
else if (role === 'agente_doble') roleImage = '/assets/images/characters/evil_morgana.png';
else if (role === 'comandante_schmidt') roleImage = '/assets/images/characters/evil_mordred.png';
else if (role === 'infiltrado') roleImage = '/assets/images/characters/evil_oberon.png';
else if (role === 'partisano') {
// Random soldier 1-5
const idx = (currentPlayerId.charCodeAt(0) % 5) + 1;
roleImage = `/assets/images/characters/good_soldier_${idx}.png`;
}
else if (role === 'minion') {
else if (role === 'colaboracionista') {
// Random minion 1-3
const idx = (currentPlayerId.charCodeAt(0) % 3) + 1;
roleImage = `/assets/images/characters/evil_minion_${idx}.png`;
@@ -228,8 +210,7 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
if (gameState.phase === 'roll_call' as any) {
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white font-mono">
{/* Debug Info */}
<DebugInfo />
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_roll_call.png" alt="Resistance HQ" fill className="object-cover" />
@@ -251,8 +232,6 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{gameState.players.map((p, i) => {
// Asignar avatar determinista basado en charCode
const avatarIdx = (p.name.length % 3) + 1;
return (
<motion.div
key={p.id}
@@ -263,7 +242,7 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
>
<div className="w-32 h-32 rounded-full border-4 border-gray-400 overflow-hidden relative shadow-2xl bg-black">
<Image
src={`/assets/images/characters/avatar_${avatarIdx}.png`}
src={`/assets/images/characters/${p.avatar}`}
alt="Avatar"
fill
className="object-cover grayscale contrast-125"
@@ -283,8 +262,6 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
return (
<div className="relative w-full h-screen flex flex-col items-center overflow-hidden">
{/* Debug Info */}
<DebugInfo />
<div className="absolute inset-0 z-0 opacity-40">
<Image src="/assets/images/ui/bg_game.png" alt="Game Background" fill className="object-cover" />
@@ -310,8 +287,12 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
return (
<div
key={idx}
className="absolute w-[12%] aspect-square flex items-center justify-center transform -translate-x-1/2 -translate-y-1/2"
style={{ left: coord.left, top: coord.top }}
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 && (
@@ -335,17 +316,21 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
{result === true && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20"
className="absolute inset-0 z-20 flex items-center justify-center"
>
<Image src="/assets/images/tokens/marker_score_blue.png" alt="Success" fill className="object-contain drop-shadow-lg" />
<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"
className="absolute inset-0 z-20 flex items-center justify-center"
>
<Image src="/assets/images/tokens/marker_score_red.png" alt="Fail" fill className="object-contain drop-shadow-lg" />
<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>
@@ -633,6 +618,7 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
{gameState.phase === 'mission_result' as any && (
<MissionResult
gameState={gameState}
isHost={isHost}
onContinue={() => isHost && actions.finishMissionResult()}
/>
)}

View File

@@ -4,9 +4,10 @@ import { GameState } from '../../../shared/types';
interface MissionResultProps {
gameState: GameState;
onContinue: () => void;
isHost: boolean;
}
export default function MissionResult({ gameState, onContinue }: MissionResultProps) {
export default function MissionResult({ gameState, onContinue, isHost }: MissionResultProps) {
// Obtener la última misión del historial
const lastMission = gameState.missionHistory[gameState.missionHistory.length - 1];
@@ -27,7 +28,7 @@ export default function MissionResult({ gameState, onContinue }: MissionResultPr
animate={{ opacity: 1 }}
>
<motion.h2
className={`text - 6xl md: text - 7xl font - bold mb - 8 ${ isSuccess ? 'text-blue-500' : 'text-red-500' } `}
className={`text-6xl md:text-7xl font-bold mb-8 ${isSuccess ? 'text-blue-500' : 'text-red-500'}`}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
@@ -58,17 +59,28 @@ export default function MissionResult({ gameState, onContinue }: MissionResultPr
</p>
</motion.div>
<motion.button
onClick={onContinue}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-bold py-4 px-8 rounded-lg text-xl shadow-lg transform transition-all hover:scale-105"
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 1.5 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
CONTINUAR
</motion.button>
{isHost ? (
<motion.button
onClick={onContinue}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-bold py-4 px-8 rounded-lg text-xl shadow-lg transform transition-all hover:scale-105"
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 1.5 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
CONTINUAR
</motion.button>
) : (
<motion.div
className="text-white text-lg font-mono bg-black/50 px-6 py-3 rounded-full border border-white/20 animate-pulse"
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 1.5 }}
>
Esperando al comandante...
</motion.div>
)}
</motion.div>
);
}

View File

@@ -1,5 +1,6 @@
import { motion } from 'framer-motion';
import { useEffect } from 'react';
import Image from 'next/image';
interface MissionRevealProps {
votes: boolean[];
@@ -7,11 +8,11 @@ interface MissionRevealProps {
}
export default function MissionReveal({ votes, onFinished }: MissionRevealProps) {
// Timer de seguridad: 10 segundos y avanza
// Timer de seguridad: 5 segundos y avanza
useEffect(() => {
const timer = setTimeout(() => {
if (onFinished) onFinished();
}, 10000);
}, 5000);
return () => clearTimeout(timer);
}, [onFinished]);
@@ -30,20 +31,22 @@ export default function MissionReveal({ votes, onFinished }: MissionRevealProps)
{votes.map((vote, idx) => (
<motion.div
key={idx}
className={`w-48 h-72 rounded-xl flex items-center justify-center text-7xl font-bold text-white shadow-2xl border-4 ${vote
? 'bg-gradient-to-br from-blue-600 to-blue-900 border-blue-400 shadow-blue-500/50'
: 'bg-gradient-to-br from-red-600 to-red-900 border-red-400 shadow-red-500/50'
}`}
className="w-48 h-72 rounded-xl flex items-center justify-center shadow-2xl relative overflow-hidden"
initial={{ scale: 0, rotateY: 180 }}
animate={{ scale: 1, rotateY: 0 }}
transition={{
delay: idx * 0.8, // Más lento para drama
delay: idx * 0.3,
type: "spring",
stiffness: 200,
damping: 20
}}
>
{vote ? '✓' : '✗'}
<Image
src={vote ? '/assets/images/tokens/vote_approve.png' : '/assets/images/tokens/vote_reject.png'}
alt={vote ? 'Éxito' : 'Sabotaje'}
fill
className="object-contain"
/>
</motion.div>
))}
</div>
@@ -52,7 +55,7 @@ export default function MissionReveal({ votes, onFinished }: MissionRevealProps)
className="text-white text-xl font-mono mt-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: votes.length * 0.8 + 1 }}
transition={{ delay: votes.length * 0.3 + 0.5 }}
>
<span className="animate-pulse">Analizando resultado estratégico...</span>
</motion.div>

View File

@@ -28,6 +28,9 @@ const io = new Server(server, {
// En el futuro esto podría estar en Redis o Postgres
const games: Record<string, Game> = {};
// Almacén de timers para auto-resolución de votaciones
const voteTimers: Record<string, NodeJS.Timeout> = {};
// --- LOBBY MANAGEMENT ---
const MISSION_NAMES = [
@@ -38,6 +41,24 @@ const MISSION_NAMES = [
"Operación Eiche", "Operación León Marino", "Operación Urano"
];
// Helper para iniciar timer de votación de líder
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();
io.to(roomId).emit('game_state', g.state);
// Si sigue en vote_leader (nuevo líder), reiniciar timer
if (g.state.phase === 'vote_leader') {
startLeaderVoteTimer(roomId);
}
}
}, 11000); // 11 segundos
}
const generateRoomName = () => {
const idx = Math.floor(Math.random() * MISSION_NAMES.length);
const suffix = Math.floor(100 + Math.random() * 900); // 3 digit code
@@ -156,6 +177,10 @@ io.on('connection', (socket) => {
// ERROR CORREGIDO: No llamar a startGame() de nuevo porque re-baraja los roles.
// Simplemente avanzamos a la fase de votación de líder que ya estaba configurada.
game.state.phase = 'vote_leader' as any;
// Iniciar timer de 11 segundos para forzar cambio de líder
startLeaderVoteTimer(roomId);
io.to(roomId).emit('game_state', game.state);
}
});
@@ -165,8 +190,23 @@ io.on('connection', (socket) => {
socket.on('vote_leader', ({ roomId, approve }) => {
const game = games[roomId];
if (game) {
const previousPhase = game.state.phase;
game.voteLeader(socket.id, approve);
io.to(roomId).emit('game_state', game.state);
// Si cambió de fase (líder aprobado o rechazado)
if (game.state.phase !== previousPhase) {
// Limpiar timer actual
if (voteTimers[roomId]) {
clearTimeout(voteTimers[roomId]);
delete voteTimers[roomId];
}
// Si pasó a vote_leader de nuevo (líder rechazado), iniciar nuevo timer
if (game.state.phase === 'vote_leader') {
startLeaderVoteTimer(roomId);
}
}
}
});

View File

@@ -47,9 +47,21 @@ export class Game {
}
addPlayer(id: string, name: string): Player {
// Asignar avatar aleatorio persistente (rebel001.jpg - rebel010.jpg)
const avatarIdx = Math.floor(Math.random() * 10) + 1;
const avatarStr = `rebel${avatarIdx.toString().padStart(3, '0')}.jpg`;
// 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,
@@ -92,13 +104,13 @@ export class Game {
// ... assignRoles se mantiene igual ...
private assignRoles(goodCount: number, evilCount: number) {
// Roles obligatorios
const roles: Role[] = [Role.MERLIN, Role.ASSASSIN];
const roles: Role[] = [Role.MARLENE, Role.FRANCOTIRADOR]; // Updated roles
// Rellenar resto de malos
for (let i = 0; i < evilCount - 1; i++) roles.push(Role.MINION);
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.LOYAL_SERVANT);
for (let i = 0; i < goodCount - 1; i++) roles.push(Role.PARTISANO); // Updated role
// Barajar roles
const shuffledRoles = roles.sort(() => Math.random() - 0.5);
@@ -107,10 +119,10 @@ export class Game {
this.state.players.forEach((player, index) => {
player.role = shuffledRoles[index];
// Asignar facción basada en el rol
if ([Role.MERLIN, Role.PERCIVAL, Role.LOYAL_SERVANT].includes(player.role)) {
player.faction = Faction.RESISTANCE;
if ([Role.MARLENE, Role.PARTISANO].includes(player.role)) { // Updated roles
player.faction = Faction.ALIADOS;
} else {
player.faction = Faction.SPIES;
player.faction = Faction.ALEMANES;
}
});
}
@@ -125,12 +137,21 @@ export class Game {
}
}
// Método para forzar la resolución de votos (llamado por timeout)
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;
}
});
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).length;
// Los nulos (timeout) no suman a ninguno, o cuentan como reject implícito?
// "Si llega a 0... su voto no cuenta". Simplemente no suma.
const rejects = votes.filter(v => v === false || v === null).length; // null cuenta como rechazo
this.log(`Votación de Líder: ${approves} A favor - ${rejects} En contra.`);
@@ -189,7 +210,7 @@ export class Game {
this.state.proposedTeam = [];
if (this.state.failedVotesCount >= 5) {
this.endGame(Faction.SPIES, 'Se han rechazado 5 equipos consecutivos.');
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.');
@@ -244,10 +265,7 @@ export class Game {
this.log(`Misión ${round} completada. Revelando votos...`);
// Auto-avanzar a MISSION_RESULT después de 5 segundos
setTimeout(() => {
this.finishReveal();
}, 5000);
// El cliente controlará el avance a MISSION_RESULT con su timer
}
@@ -268,7 +286,7 @@ export class Game {
const failures = this.state.questResults.filter(r => r === false).length;
if (failures >= 3) {
this.endGame(Faction.SPIES, 'Tres misiones han fracasado.');
this.endGame(Faction.ALEMANES, 'Tres misiones han fracasado.'); // Updated Faction
} else if (successes >= 3) {
this.state.phase = GamePhase.ASSASSIN_PHASE;
this.log('¡La Resistencia ha triunfado! Pero el Asesino tiene una última oportunidad...');
@@ -284,14 +302,14 @@ export class Game {
assassinKill(targetId: string) {
const target = this.state.players.find(p => p.id === targetId);
if (target && target.role === Role.MERLIN) {
this.endGame(Faction.SPIES, '¡El Asesino ha eliminado a Marlenne (Merlín)!');
if (target && target.role === Role.MARLENE) { // Updated Role
this.endGame(Faction.ALEMANES, '¡El Asesino ha eliminado a Marlene!'); // Updated Faction and message
} else {
this.endGame(Faction.RESISTANCE, 'El Asesino ha fallado. ¡La Resistencia gana!');
this.endGame(Faction.ALIADOS, 'El Asesino ha fallado. ¡La Resistencia gana!'); // Updated Faction
}
}
private nextLeader() {
nextLeader() {
const currentIdx = this.state.players.findIndex(p => p.id === this.state.currentLeaderId);
const nextIdx = (currentIdx + 1) % this.state.players.length;

View File

@@ -1,20 +1,20 @@
export enum Role {
// Bando del Bien (Resistencia Francesa)
MERLIN = 'merlin', // Marlenne
PERCIVAL = 'percival',
LOYAL_SERVANT = 'loyal_servant', // Soldado Resistencia
// Bando Aliado (Resistencia Francesa)
MARLENE = 'marlene', // Agente de inteligencia (antes Merlin)
CAPITAN_PHILIPPE = 'capitan_philippe', // Oficial que conoce a Marlene (antes Percival)
PARTISANO = 'partisano', // Miembro leal de la resistencia (antes Loyal Servant)
// Bando del Mal (Ocupación Alemana)
MORDRED = 'mordred',
ASSASSIN = 'assassin',
MORGANA = 'morgana',
OBERON = 'oberon',
MINION = 'minion', // Soldado Alemán
// Bando Alemán (Ocupación Nazi)
COMANDANTE_SCHMIDT = 'comandante_schmidt', // Oficial nazi oculto (antes Mordred)
FRANCOTIRADOR = 'francotirador', // Puede eliminar a Marlene (antes Assassin)
AGENTE_DOBLE = 'agente_doble', // Se hace pasar por Marlene (antes Morgana)
INFILTRADO = 'infiltrado', // Espía solitario (antes Oberon)
COLABORACIONISTA = 'colaboracionista', // Espía genérico (antes Minion/Spy)
}
export enum Faction {
RESISTANCE = 'resistance',
SPIES = 'spies',
ALIADOS = 'aliados', // Antes RESISTANCE
ALEMANES = 'alemanes', // Antes SPIES
}
export enum GamePhase {