Estado actual con errores de sintaxis en GameBoard.tsx

This commit is contained in:
Resistencia Dev
2025-12-05 22:07:20 +01:00
commit 8d423ac19d
75 changed files with 2228 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
dist
.env
.DS_Store

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.next/
dist/
*.log
.env
.DS_Store
postgres_data/

23
client/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:20-alpine
# Create app directory
WORKDIR /app
# Copy shared module first (as a sibling to client)
COPY shared ./shared
# Setup Client directory
WORKDIR /app/client
# Install dependencies
COPY client/package*.json ./
RUN npm install
# Copy client source code
COPY client/ .
# Expose port
EXPOSE 3000
# Run Next.js in dev mode
CMD ["npx", "next", "dev"]

5
client/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

4
client/next.config.js Normal file
View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

32
client/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "resistencia-client",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.0.0",
"framer-motion": "^10.16.4",
"lucide-react": "^0.294.0",
"next": "14.0.3",
"react": "^18",
"react-dom": "^18",
"socket.io-client": "^4.7.2",
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.0.3",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

View File

@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}

22
client/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'La Resistencia: WWII',
description: 'Juego de deducción social ambientado en la Segunda Guerra Mundial',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="es">
<body className={inter.className}>{children}</body>
</html>
)
}

387
client/src/app/page.tsx Normal file
View File

@@ -0,0 +1,387 @@
'use client';
import { useState, useEffect } from 'react';
import { useSocket } from '../hooks/useSocket';
import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image';
import GameBoard from '../components/GameBoard';
import { GameRoom } from '../../../shared/types';
// Constantes de apellidos
const SURNAMES = [
// Franceses
'Dubois', 'Leroy', 'Moreau', 'Petit', 'Lefebvre', 'Michel', 'Durand',
// Británicos
'Smith', 'Jones', 'Williams', 'Brown', 'Taylor', 'Wilson', 'Evans',
// Americanos
'Miller', 'Davis', 'Garcia', 'Rodriguez', 'Martinez', 'Hernandez'
];
type ViewState = 'login' | 'lobby' | 'game';
export default function Home() {
const { isConnected, gameState, roomsList, actions, socket } = useSocket();
// Estados locales de UI
const [view, setView] = useState<ViewState>('login');
const [playerName, setPlayerName] = useState('');
// El apellido se genera al loguearse
const [fullPlayerName, setFullPlayerName] = useState('');
// UI Create/Join
const [showCreateModal, setShowCreateModal] = useState(false);
const [createConfig, setCreateConfig] = useState({ maxPlayers: 5, password: '' });
const [passwordPromptRoomId, setPasswordPromptRoomId] = useState<string | null>(null);
const [joinPassword, setJoinPassword] = useState('');
// Efecto para cambiar a vista de juego cuando el servidor nos une
useEffect(() => {
if (gameState?.roomId) {
setView('game');
} else if (view === 'game' && !gameState) {
// Si estábamos en juego y volvemos a null, volver al lobby
setView('lobby');
}
}, [gameState]);
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (playerName) {
// Generar apellido aleatorio
const randomSurname = SURNAMES[Math.floor(Math.random() * SURNAMES.length)];
setFullPlayerName(`${playerName} ${randomSurname}`);
setView('lobby');
actions.refreshRooms();
}
};
const handleCreateGame = (e: React.FormEvent) => {
e.preventDefault();
actions.createGame(fullPlayerName, createConfig.maxPlayers, createConfig.password);
setShowCreateModal(false);
};
const requestJoinGame = (room: GameRoom) => {
if (room.isPrivate) {
setPasswordPromptRoomId(room.id);
setJoinPassword('');
} else {
actions.joinGame(room.id, fullPlayerName);
}
};
const submitJoinPassword = () => {
if (passwordPromptRoomId) {
actions.joinGame(passwordPromptRoomId, fullPlayerName, joinPassword);
setPasswordPromptRoomId(null);
}
};
// --- RENDER DE JUEGO O SALA DE ESPERA ---
if (view === 'game' && gameState && socket) {
// ¿Estamos en fase de lobby dentro de la partida?
if (gameState.phase === 'lobby') {
const isHost = gameState.hostId === socket.id;
// Podríamos obtener maxPlayers del array players si no lo tenemos en gameState,
// pero lo ideal sería tenerlo. Por ahora asumimos que si ya estamos dentro,
// sabemos cuantos somos.
// NOTA: GameState no tiene maxPlayers explicitamente, pero podemos deducirlo o añadirlo.
// Por simplicidad, usaremos el length para validar minimo.
return (
<main className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-zinc-900 font-mono text-gray-200">
<div className="absolute inset-0 z-0 opacity-40">
<Image src="/assets/images/ui/bg_game.png" alt="War Room" fill className="object-cover" />
<div className="absolute inset-0 bg-black/70" />
</div>
<div className="z-10 bg-black/80 p-8 rounded border border-white/20 max-w-2xl w-full mx-4 backdrop-blur-md">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-yellow-500 mb-2 uppercase tracking-widest">Sala de Espera</h2>
<p className="text-gray-400">Operación en curso. Esperando activación...</p>
</div>
<div className="grid grid-cols-2 gap-4 mb-8">
{gameState.players.map(player => (
<div key={player.id} className="bg-white/5 p-3 rounded flex items-center gap-3 border border-white/10">
<div className={`w-3 h-3 rounded-full ${player.id === socket.id ? 'bg-green-500 shadow-green-500/50 shadow-lg' : 'bg-gray-500'}`} />
<span className={player.id === socket.id ? 'font-bold text-white' : 'text-gray-300'}>
{player.name}
</span>
{player.id === gameState.hostId && (
<span className="text-[10px] bg-yellow-900/50 text-yellow-500 px-2 py-0.5 rounded ml-auto">HOST</span>
)}
</div>
))}
{/* Rellenar huecos vacíos visualmente si quisieramos */}
</div>
<div className="flex flex-col items-center gap-4 border-t border-white/10 pt-6">
{isHost ? (
<>
<p className="text-sm text-gray-400 mb-2">
Jugadores: <span className="text-white font-bold">{gameState.players.length}</span>
{gameState.players.length < 5 && <span className="text-red-400 ml-2">(Mínimo 5 requeridos)</span>}
</p>
<button
onClick={() => actions.startGame()}
disabled={gameState.players.length < 5}
className="w-full max-w-md bg-yellow-600 hover:bg-yellow-500 disabled:bg-gray-700 disabled:cursor-not-allowed text-white font-bold py-4 rounded uppercase tracking-[0.2em] transition-all shadow-lg"
>
INICIAR MISIÓN
</button>
</>
) : (
<div className="text-center animate-pulse">
<p className="text-yellow-500 font-bold uppercase tracking-wider">Esperando al Comandante...</p>
<p className="text-xs text-gray-500 mt-2">La misión comenzará cuando el líder la orden.</p>
</div>
)}
</div>
</div>
</main>
);
}
return (
<GameBoard
gameState={gameState}
currentPlayerId={socket.id}
actions={actions}
/>
);
}
return (
<main className="relative min-h-screen flex flex-col items-center overflow-hidden bg-zinc-900 font-mono text-gray-200">
{/* FONDO COMÚN LOBBY/LOGIN */}
<div className="absolute inset-0 z-0 opacity-40">
<Image
src="/assets/images/ui/bg_lobby.png"
alt="Lobby Background"
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent" />
</div>
{/* HEADER / LOGO */}
<div className="z-10 w-full p-4 flex justify-between items-center max-w-6xl">
<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
</h1>
</div>
{view === 'lobby' && (
<div className="flex items-center gap-4 bg-black/50 px-4 py-2 rounded border border-white/10">
<span className="text-sm text-gray-400">AGENTE:</span>
<span className="font-bold text-yellow-500">{fullPlayerName}</span>
</div>
)}
</div>
<div className="z-10 w-full flex-1 flex flex-col items-center justify-center p-4">
<AnimatePresence mode="wait">
{/* --- PANTALLA DE LOGIN --- */}
{view === 'login' && (
<motion.form
key="login-form"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
onSubmit={handleLogin}
className="bg-black/80 p-8 rounded border border-white/20 shadow-2xl max-w-md w-full backdrop-blur-md"
>
<h2 className="text-xl text-center mb-6 uppercase tracking-[0.2em] text-white">Identificación</h2>
<div className="space-y-4">
<div>
<label className="text-xs uppercase text-gray-500 block mb-1">Nombre en Clave</label>
<input
required
value={playerName}
onChange={e => setPlayerName(e.target.value)}
className="w-full bg-white/10 border border-white/20 p-3 rounded text-white focus:outline-none focus:border-yellow-500 transition-colors"
placeholder="Ej: Agente"
/>
</div>
{/* APELLIDO ELIMINADO - SE GENERA AUTOMÁTICAMENTE */}
<button
type="submit"
className="w-full bg-yellow-700 hover:bg-yellow-600 text-white font-bold py-3 mt-4 rounded uppercase tracking-wider transition-all"
>
Acceder al Cuartel
</button>
</div>
</motion.form>
)}
{/* --- PANTALLA DE LOBBY (LISTA DE SALAS) --- */}
{view === 'lobby' && (
<motion.div
key="lobby-list"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="w-full max-w-5xl"
>
<div className="flex justify-between items-end mb-6 border-b border-white/20 pb-4">
<div>
<h2 className="text-3xl font-light text-white">MISIONES ACTIVAS</h2>
<p className="text-gray-400 text-sm mt-1">Selecciona una operación o inicia una nueva.</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="bg-blue-800 hover:bg-blue-700 text-white px-6 py-2 rounded uppercase text-sm font-bold tracking-wider shadow-lg border border-blue-600 transition-all"
>
+ Crear Operación
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{roomsList.length === 0 ? (
<div className="col-span-full py-20 text-center text-gray-500 bg-black/30 rounded border border-white/5 border-dashed">
No hay misiones activas en este momento.
</div>
) : (
roomsList.map((room) => (
<motion.div
key={room.id}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-black/60 border border-white/10 p-5 rounded hover:border-yellow-700/50 transition-colors group relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-2">
{room.isPrivate ? (
<span title="Privada" className="text-red-400">🔒</span>
) : (
<span title="Pública" className="text-green-400/50">🔓</span>
)}
</div>
<h3 className="text-xl font-bold text-yellow-500 mb-1 group-hover:text-yellow-400 transition-colors">
{room.name}
</h3>
<div className="text-sm text-gray-400 mb-4 flex gap-2">
<span className="bg-white/10 px-2 py-0.5 rounded textxs">
HOST:
</span>
<span className="text-white">{room.hostId.substring(0, 6)}...</span>
</div>
<div className="flex justify-between items-center mt-4">
<div className="flex items-end gap-1">
<span className="text-3xl font-bold text-white">{room.currentPlayers}</span>
<span className="text-sm text-gray-500 mb-1">/ {room.maxPlayers}</span>
</div>
<button
disabled={room.currentPlayers >= room.maxPlayers || room.status !== 'waiting'}
onClick={() => requestJoinGame(room)}
className="bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded text-xs uppercase font-bold transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{room.status === 'playing' ? 'EN CURSO' : (room.currentPlayers >= room.maxPlayers ? 'LLENA' : 'UNIRSE')}
</button>
</div>
{/* Barra de progreso visual */}
<div className="absolute bottom-0 left-0 h-1 bg-yellow-900/40 w-full">
<div
className="h-full bg-yellow-600 transition-all duration-500"
style={{ width: `${(room.currentPlayers / room.maxPlayers) * 100}%` }}
/>
</div>
</motion.div>
))
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* --- MODALES --- */}
{/* Modal Crear */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.9 }} animate={{ scale: 1 }}
className="bg-zinc-800 p-6 rounded border border-white/20 w-full max-w-sm shadow-2xl"
>
<h3 className="text-lg font-bold text-white mb-4 uppercase">Configurar Operación</h3>
<form onSubmit={handleCreateGame} className="space-y-4">
<div>
<label className="block text-xs uppercase text-gray-400 mb-1"> Jugadores</label>
<select
value={createConfig.maxPlayers}
onChange={e => setCreateConfig({ ...createConfig, maxPlayers: Number(e.target.value) })}
className="w-full bg-black/40 border border-white/10 p-2 rounded text-white"
>
{[5, 6, 7, 8, 9, 10].map(n => (
<option key={n} value={n}>{n} Jugadores</option>
))}
</select>
</div>
<div>
<label className="block text-xs uppercase text-gray-400 mb-1">Contraseña (Opcional)</label>
<input
type="password"
value={createConfig.password}
onChange={e => setCreateConfig({ ...createConfig, password: e.target.value })}
className="w-full bg-black/40 border border-white/10 p-2 rounded text-white font-mono"
placeholder="Dejar vacío para pública"
/>
</div>
<div className="flex gap-2 mt-6">
<button type="button" onClick={() => setShowCreateModal(false)} className="flex-1 py-2 text-gray-400 hover:text-white transition-colors">Cancelar</button>
<button type="submit" className="flex-1 py-2 bg-yellow-700 hover:bg-yellow-600 text-white rounded font-bold uppercase">Crear</button>
</div>
</form>
</motion.div>
</div>
)}
{/* Modal Password */}
{passwordPromptRoomId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.9 }} animate={{ scale: 1 }}
className="bg-zinc-800 p-6 rounded border border-red-900/50 w-full max-w-sm shadow-2xl"
>
<h3 className="text-lg font-bold text-red-400 mb-2 uppercase flex items-center gap-2">
🔒 Acceso Restringido
</h3>
<p className="text-xs text-gray-400 mb-4">Esta operación es clasificada. Introduce la clave de acceso.</p>
<input
type="password"
autoFocus
value={joinPassword}
onChange={e => setJoinPassword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submitJoinPassword()}
className="w-full bg-black/40 border border-red-900/30 p-2 rounded text-white font-mono mb-4 focus:border-red-500 outline-none"
placeholder="Clave de acceso..."
/>
<div className="flex gap-2">
<button onClick={() => setPasswordPromptRoomId(null)} className="flex-1 py-2 text-gray-400 hover:text-white">Cancelar</button>
<button onClick={submitJoinPassword} className="flex-1 py-2 bg-red-900 hover:bg-red-800 text-white rounded font-bold uppercase">Acceder</button>
</div>
</motion.div>
</div>
)}
<div className="absolute bottom-2 right-4 text-[10px] text-gray-600 font-mono">
{isConnected ? <span className="text-green-900"> CONEXIÓN SEGURA</span> : <span className="text-red-900"> BUSCANDO SEÑAL...</span>}
</div>
</main>
);
}

View File

@@ -0,0 +1,711 @@
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';
interface GameBoardProps {
gameState: GameState;
currentPlayerId: string;
actions: any;
}
export default function GameBoard({ gameState, currentPlayerId, actions }: GameBoardProps) {
const [selectedTeam, setSelectedTeam] = useState<string[]>([]);
// Hooks para FASE REVEAL ROLE
const [revealCard, setRevealCard] = useState(false);
// Timer para avanzar automáticamente en REVEAL_ROLE
useEffect(() => {
if (gameState.phase === 'reveal_role' as any) {
const timer = setTimeout(() => {
actions.finishReveal();
}, 10000);
return () => clearTimeout(timer);
}
}, [gameState.phase, actions]);
const currentPlayer = gameState.players.find(p => p.id === currentPlayerId);
const isLeader = currentPlayer?.isLeader;
const config = GAME_CONFIG[gameState.players.length as keyof typeof GAME_CONFIG];
const currentQuestSize = config?.quests[gameState.currentRound - 1];
// Manejar selección de equipo
const toggleTeamSelection = (playerId: string) => {
if (selectedTeam.includes(playerId)) {
setSelectedTeam(selectedTeam.filter(id => id !== playerId));
} else {
if (selectedTeam.length < currentQuestSize) {
setSelectedTeam([...selectedTeam, playerId]);
}
}
};
// Coordenadas porcentuales de los hexágonos de misión en el mapa
const missionCoords = [
{ left: '12%', top: '55%' }, // Misión 1
{ left: '28%', top: '15%' }, // Misión 2
{ left: '52%', top: '25%' }, // Misión 3
{ left: '42%', top: '70%' }, // Misión 4
{ left: '82%', top: '40%' }, // Misión 5
];
// --- UI/Efectos para FASES TEMPRANAS ---
const isHost = gameState.hostId === currentPlayerId;
// FASE INTRO
if (gameState.phase === 'intro' as any) {
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white">
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_intro.png" alt="Battlefield" fill className="object-cover" />
<div className="absolute inset-0 bg-black/40" />
</div>
<h1 className="z-10 text-5xl font-bold uppercase tracking-[0.3em] mb-8 text-yellow-500 drop-shadow-lg text-center">
Guerra Total
</h1>
{/* Audio Auto-Play */}
<audio
src="/assets/audio/Intro.ogg"
autoPlay
onEnded={() => isHost && actions.finishIntro()}
/>
{isHost && (
<button
onClick={() => actions.finishIntro()}
className="z-10 bg-white/20 hover:bg-white/40 border border-white px-6 py-2 rounded text-sm uppercase tracking-widest backdrop-blur-sm transition-all"
>
Omitir Introducción
</button>
)}
</div>
);
}
// FASE REVEAL ROLE NO HOOKS HERE
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
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') {
// Random soldier 1-5
const idx = (currentPlayerId.charCodeAt(0) % 5) + 1;
roleImage = `/assets/images/characters/good_soldier_${idx}.png`;
}
else if (role === 'minion') {
// Random minion 1-3
const idx = (currentPlayerId.charCodeAt(0) % 3) + 1;
roleImage = `/assets/images/characters/evil_minion_${idx}.png`;
}
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white font-mono">
{/* FONDO (Mismo que Roll Call) */}
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_roll_call.png" alt="Resistance HQ" fill className="object-cover" />
<div className="absolute inset-0 bg-black/70" />
</div>
<div className="z-10 flex flex-col items-center gap-8">
<h2 className="text-2xl uppercase tracking-[0.2em] text-gray-300">
Tu Identidad Secreta
</h2>
<p className="text-sm text-gray-400 mb-4 animate-pulse">
Desliza hacia arriba para revelar
</p>
<div className="relative w-64 h-96 perspective-1000">
{/* Carta Revelada (Fondo) */}
<div className="absolute inset-0 w-full h-full rounded-xl overflow-hidden shadow-2xl border-4 border-yellow-600 bg-gray-900 flex items-center justify-center">
<Image
src={roleImage}
alt="Role"
fill
className="object-cover"
/>
<div className="absolute bottom-0 w-full bg-black/80 text-center py-2 font-bold text-yellow-500 uppercase">
{role?.replace('_', ' ')}
</div>
</div>
{/* Reverso de Carta (Draggable) */}
<motion.div
drag="y"
dragConstraints={{ top: -300, bottom: 0 }}
dragElastic={0.2}
onDragEnd={(e, info) => {
// Reducir umbral a -50 para facilitar
if (info.offset.y < -50) {
setRevealCard(true);
}
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98, cursor: 'grabbing' }}
animate={revealCard ? { y: -1000, opacity: 0 } : { y: 0, opacity: 1 }}
className="absolute inset-0 w-full h-full rounded-xl overflow-hidden shadow-2xl z-20 cursor-grab active:cursor-grabbing hover:ring-2 hover:ring-white/50 transition-all"
>
<Image
src="/assets/images/characters/card_back.png"
alt="Card Back"
fill
className="object-cover pointer-events-none" // Importante: pointer-events-none en la imagen para que no capture el drag
/>
</motion.div>
</div>
</div>
</div>
);
}
// FASE ROLL CALL
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">
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_roll_call.png" alt="Resistance HQ" fill className="object-cover" />
<div className="absolute inset-0 bg-black/70" />
</div>
<div className="z-10 w-full max-w-5xl px-4">
<h2 className="text-3xl text-center mb-12 uppercase tracking-[0.2em] text-gray-300 border-b border-gray-600 pb-4">
Pasando Lista...
</h2>
{isHost && (
<audio
src="/assets/audio/Rondas.ogg"
autoPlay
onEnded={() => actions.finishRollCall()} // Host avanza cuando acaba audio
/>
)}
<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}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.3 }} // Aparecen uno a uno
className="flex flex-col items-center gap-3"
>
<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`}
alt="Avatar"
fill
className="object-cover grayscale contrast-125"
/>
</div>
<div className="bg-black/80 px-4 py-1 rounded border border-white/20 text-xl font-bold text-yellow-500 uppercase">
{p.name}
</div>
</motion.div>
);
})}
</div>
</div>
</div>
);
}
return (
<div className="relative w-full h-screen flex flex-col items-center overflow-hidden">
<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">
{/* --- 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]">
<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;
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 }}
>
{/* 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"
>
<Image src="/assets/images/tokens/marker_score_blue.png" alt="Success" fill className="object-contain drop-shadow-lg" />
</motion.div>
)}
{result === false && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20"
>
<Image src="/assets/images/tokens/marker_score_red.png" alt="Fail" fill className="object-contain drop-shadow-lg" />
</motion.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>
</div>
{/* --- ÁREA DE JUEGO (CARTAS Y ACCIONES) --- */}
<div className="flex-1 w-full max-w-6xl relative mt-4 px-4">
<AnimatePresence mode="wait">
{/* FASE: VOTACIÓN DE LÍDER */}
{gameState.phase === 'vote_leader' as any && (
<motion.div
key="vote-leader"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6"
>
<div className="bg-black/80 p-4 rounded text-white text-center border border-yellow-500/50 relative">
<h3 className="text-xl font-mono mb-2 text-yellow-500 uppercase tracking-widest">
Confirmar Líder
</h3>
<div className="text-2xl font-bold mb-2">
¿Aceptas a <span className="text-yellow-400">{gameState.players.find(p => p.id === gameState.currentLeaderId)?.name}</span> como Líder?
</div>
{/* Timer */}
{!gameState.leaderVotes?.[currentPlayerId] && (
<VotingTimer onTimeout={() => actions.voteLeader(null)} />
)}
</div>
{gameState.leaderVotes?.[currentPlayerId] === undefined ? (
<div className="flex gap-8">
<button onClick={() => actions.voteLeader(true)} className="group">
<div className="w-40 h-60 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-green-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<Image src="/assets/images/tokens/accept_leader.png" alt="Accept Leader" fill className="object-contain" />
</div>
<span className="block text-center text-white mt-2 font-bold bg-green-600 px-2 rounded uppercase tracking-widest">ACEPTAR</span>
</button>
<button onClick={() => actions.voteLeader(false)} className="group">
<div className="w-40 h-60 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-red-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<Image src="/assets/images/tokens/deny_leader.png" alt="Deny Leader" fill className="object-contain" />
</div>
<span className="block text-center text-white mt-2 font-bold bg-red-600 px-2 rounded uppercase tracking-widest">RECHAZAR</span>
</button>
</div>
) : (
<div className="text-white text-xl font-mono animate-pulse bg-black/50 px-6 py-3 rounded-full border border-white/20">
VOTO REGISTRADO. ESPERANDO AL RESTO...
</div>
)}
</motion.div>
)}
{/* FASE: CONSTRUCCIÓN DE EQUIPO */}
{gameState.phase === GamePhase.TEAM_BUILDING && (
<motion.div
key="team-building"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="bg-paper-bg text-black p-6 rounded shadow-2xl rotate-1 max-w-md w-full text-center"
>
<h2 className="text-2xl font-bold font-mono mb-2 uppercase text-resistance-blue">
{isLeader ? 'TU TURNO: ELIGE EQUIPO' : `ESPERANDO AL LÍDER...`}
</h2>
<p className="mb-4 font-serif italic text-gray-700">
Se necesitan <span className="font-bold text-red-700">{currentQuestSize} agentes</span> para esta misión.
</p>
{isLeader && (
<button
onClick={() => actions.proposeTeam(selectedTeam)}
disabled={selectedTeam.length !== currentQuestSize}
className="w-full bg-resistance-blue text-white font-bold py-3 px-4 rounded hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-widest"
>
Proponer Equipo
</button>
)}
</motion.div>
)}
{/* FASE: VOTACIÓN DE EQUIPO */}
{gameState.phase === GamePhase.VOTING_TEAM && (
<motion.div
key="voting"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center gap-6"
>
<div className="bg-black/80 p-4 rounded text-white text-center border border-white/20">
<h3 className="text-xl font-mono mb-2 text-yellow-500">PROPUESTA DE MISIÓN</h3>
<div className="flex gap-2 justify-center">
{gameState.proposedTeam.map(id => {
const p = gameState.players.find(pl => pl.id === id);
return (
<div key={id} className="bg-white/10 px-3 py-1 rounded text-sm">
{p?.name}
</div>
);
})}
</div>
</div>
{!currentPlayer?.hasVoted ? (
<div className="flex gap-8">
<button onClick={() => actions.voteTeam(true)} className="group">
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-green-500 transition-all transform group-hover:-translate-y-4">
<Image src="/assets/images/tokens/vote_approve.png" alt="Approve" width={100} height={100} />
</div>
<span className="block text-center text-white mt-2 font-bold bg-green-600 px-2 rounded">APROBAR</span>
</button>
<button onClick={() => actions.voteTeam(false)} className="group">
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-red-500 transition-all transform group-hover:-translate-y-4">
<Image src="/assets/images/tokens/vote_reject.png" alt="Reject" width={100} height={100} />
</div>
<span className="block text-center text-white mt-2 font-bold bg-red-600 px-2 rounded">RECHAZAR</span>
</button>
</div>
) : (
<div className="text-white text-xl font-mono animate-pulse">
VOTO REGISTRADO. ESPERANDO AL RESTO...
</div>
)}
</motion.div>
)}
{/* FASE: MISIÓN */}
{gameState.phase === GamePhase.MISSION && (
<motion.div key="mission" className="text-center">
{gameState.proposedTeam.includes(currentPlayerId) ? (
<div className="flex flex-col items-center gap-6">
<h2 className="text-3xl font-bold text-white mb-4 drop-shadow-lg">¡ESTÁS EN LA MISIÓN!</h2>
<div className="flex gap-8">
<button onClick={() => actions.voteMission(true)} className="group">
<div className="w-40 h-60 bg-blue-900 rounded-lg shadow-2xl border-2 border-blue-400 flex flex-col items-center justify-center p-4 transform transition-transform hover:scale-105">
<Image src="/assets/images/tokens/mission_success.png" alt="Success" width={120} height={120} />
<span className="mt-4 text-blue-200 font-bold tracking-widest">ÉXITO</span>
</div>
</button>
{/* Solo los malos pueden sabotear */}
{currentPlayer?.faction === 'spies' && (
<button onClick={() => actions.voteMission(false)} className="group">
<div className="w-40 h-60 bg-red-900 rounded-lg shadow-2xl border-2 border-red-400 flex flex-col items-center justify-center p-4 transform transition-transform hover:scale-105">
<Image src="/assets/images/tokens/mission_fail.png" alt="Fail" width={120} height={120} />
<span className="mt-4 text-red-200 font-bold tracking-widest">SABOTAJE</span>
</div>
</button>
)}
</div>
</div>
) : (
<div className="text-white text-2xl font-mono bg-black/50 p-6 rounded">
La misión está en curso...<br />
<span className="text-sm text-gray-400">Rezando por el éxito.</span>
</div>
)}
</motion.div>
)}
{/* FASE: REVELACIÓN DE CARTAS */}
{gameState.phase === 'mission_reveal' as any && (
<MissionReveal
votes={gameState.revealedVotes || []}
onComplete={() => isHost && actions.finishMissionReveal()}
/>
)}
{/* FASE: RESULTADO DE MISIÓN */}
{gameState.phase === 'mission_result' as any && (
<MissionResult
gameState={gameState}
onContinue={() => isHost && actions.finishMissionResult()}
/>
)}
</AnimatePresence>
</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;
// 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
${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>
{/* 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>
{/* 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>
<div className="flex gap-2">
{gameState.missionHistory.map((mission, idx) => (
<div
key={idx}
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>
))}
</div>
</div>
)}
</div>
</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>
);
}
// Componente para revelar cartas una a una
function MissionReveal({ votes, onComplete }: { votes: boolean[], onComplete: () => void }) {
const [revealedCount, setRevealedCount] = useState(0);
useEffect(() => {
if (revealedCount < votes.length) {
const timer = setTimeout(() => {
setRevealedCount(c => c + 1);
}, 2000); // 2 segundos entre carta y carta
return () => clearTimeout(timer);
} else if (revealedCount === votes.length && votes.length > 0) {
// Todas reveladas, esperar 2s más y avanzar
const timer = setTimeout(() => {
onComplete();
}, 2000);
return () => clearTimeout(timer);
}
}, [revealedCount, votes.length, onComplete]);
return (
<motion.div
key="mission-reveal"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center gap-8"
>
<h2 className="text-3xl font-bold text-white uppercase tracking-widest">
Revelando Votos...
</h2>
<div className="flex gap-4 flex-wrap justify-center">
{votes.slice(0, revealedCount).map((vote, idx) => (
<motion.div
key={idx}
initial={{ scale: 0, rotateY: 180 }}
animate={{ scale: 1, rotateY: 0 }}
transition={{ type: "spring", stiffness: 200 }}
className="relative w-32 h-48"
>
<Image
src={vote ? "/assets/images/tokens/mission_success.png" : "/assets/images/tokens/mission_fail.png"}
alt={vote ? "Éxito" : "Sabotaje"}
fill
className="object-contain drop-shadow-2xl"
/>
</motion.div>
))}
</div>
<div className="text-white text-lg font-mono">
{revealedCount} / {votes.length} cartas reveladas
</div>
</motion.div>
);
}
// Componente para mostrar el resultado de la misión
function MissionResult({ gameState, onContinue }: { gameState: any, onContinue: () => void }) {
const currentMission = gameState.missionHistory[gameState.missionHistory.length - 1];
const isHost = gameState.hostId === gameState.players[0]?.id; // Simplificado
useEffect(() => {
// Auto-avanzar después de 5 segundos
const timer = setTimeout(() => {
onContinue();
}, 5000);
return () => clearTimeout(timer);
}, [onContinue]);
if (!currentMission) return null;
const { isSuccess, successes, fails, team, round } = currentMission;
return (
<motion.div
key="mission-result"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
className="flex flex-col items-center gap-6 p-8 bg-black/90 rounded-xl border-4 max-w-2xl mx-auto"
style={{ borderColor: isSuccess ? '#3b82f6' : '#ef4444' }}
>
{/* Título */}
<div className="text-center">
<h2 className="text-5xl font-bold mb-2 uppercase tracking-widest" style={{ color: isSuccess ? '#3b82f6' : '#ef4444' }}>
{isSuccess ? '✓ MISIÓN EXITOSA' : '✗ MISIÓN FALLIDA'}
</h2>
<p className="text-gray-400 text-lg">Misión #{round}</p>
</div>
{/* Estadísticas */}
<div className="flex gap-8 text-center">
<div className="bg-blue-900/30 p-4 rounded-lg border border-blue-500/50">
<div className="text-4xl font-bold text-blue-400">{successes}</div>
<div className="text-sm text-gray-300 uppercase">Éxitos</div>
</div>
<div className="bg-red-900/30 p-4 rounded-lg border border-red-500/50">
<div className="text-4xl font-bold text-red-400">{fails}</div>
<div className="text-sm text-gray-300 uppercase">Sabotajes</div>
</div>
</div>
{/* Equipo */}
<div className="w-full">
<h3 className="text-xl text-white mb-3 text-center uppercase tracking-wider">Equipo de Misión:</h3>
<div className="flex gap-3 justify-center flex-wrap">
{team.map((playerId: string) => {
const player = gameState.players.find((p: any) => p.id === playerId);
return (
<div key={playerId} className="bg-white/10 px-4 py-2 rounded-full border border-white/20 text-white text-sm font-mono">
{player?.name || 'Desconocido'}
</div>
);
})}
</div>
</div>
{/* Mensaje */}
<div className="text-center text-gray-300 text-sm animate-pulse">
Continuando en breve...
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { GameState, Player } from '../../../shared/types';
const SOCKET_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
export const useSocket = () => {
const [socket, setSocket] = useState<Socket | null>(null);
const [gameState, setGameState] = useState<GameState | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [roomsList, setRoomsList] = useState<any[]>([]);
useEffect(() => {
const socketInstance = io(SOCKET_URL);
socketInstance.on('connect', () => {
console.log('Conectado al servidor');
setIsConnected(true);
});
socketInstance.on('disconnect', () => {
console.log('Desconectado del servidor');
setIsConnected(false);
});
socketInstance.on('game_state', (newState: GameState) => {
console.log('Nuevo estado del juego:', newState);
setGameState(newState);
});
socketInstance.on('rooms_list', (rooms: any[]) => {
console.log('Lista de salas actualizada:', rooms);
setRoomsList(rooms);
});
// Manejar propio unirse a partida
socketInstance.on('game_joined', ({ roomId, state }) => {
setGameState(state);
// Podríamos guardar el roomId en localStorage o similar si quisiéramos persistencia
});
socketInstance.on('error', (msg: string) => {
alert(msg); // Simple error handling for now
});
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, []);
// Funciones helper para enviar acciones
const createGame = (hostName: string, maxPlayers: number, password?: string) => {
socket?.emit('create_game', { hostName, maxPlayers, password });
};
const joinGame = (roomId: string, playerName: string, password?: string) => {
socket?.emit('join_game', { roomId, playerName, password });
};
const refreshRooms = () => {
socket?.emit('get_rooms');
};
const startGame = () => {
socket?.emit('start_game', { roomId: gameState?.roomId });
};
const proposeTeam = (teamIds: string[]) => {
socket?.emit('propose_team', { roomId: gameState?.roomId, teamIds });
};
const voteTeam = (approve: boolean) => {
socket?.emit('vote_team', { roomId: gameState?.roomId, approve });
};
const voteMission = (success: boolean) => {
socket?.emit('vote_mission', { roomId: gameState?.roomId, success });
};
const assassinKill = (targetId: string) => {
socket?.emit('assassin_kill', { roomId: gameState?.roomId, targetId });
};
return {
socket,
isConnected,
gameState,
roomsList,
actions: {
createGame,
joinGame,
refreshRooms,
startGame,
proposeTeam,
voteTeam,
voteMission,
voteLeader: (approve: boolean | null) => 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 }),
finishRollCall: () => socket?.emit('finish_roll_call', { roomId: gameState?.roomId }),
finishMissionReveal: () => socket?.emit('finish_reveal', { roomId: gameState?.roomId }),
finishMissionResult: () => socket?.emit('finish_mission_result', { roomId: gameState?.roomId })
}
};
};

27
client/tailwind.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
colors: {
// Colores temáticos para la Resistencia (Francia vs Alemania WWII)
'resistance-blue': '#1a365d', // Azul oscuro francés
'resistance-gold': '#c5a059', // Dorado clásico
'spy-red': '#7f1d1d', // Rojo oscuro enemigo
'paper-bg': '#f3f4f6', // Color papel/documento
}
},
},
plugins: [],
}
export default config

41
client/tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../shared/**/*"
],
"exclude": [
"node_modules"
]
}

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
services:
# --- FRONTEND (Next.js) ---
client:
container_name: resistencia-client
build:
context: .
dockerfile: client/Dockerfile
ports:
- "3000:3000"
volumes:
- ./client:/app/client
- ./shared:/app/shared
- /app/client/node_modules
environment:
- NEXT_PUBLIC_API_URL=http://localhost:4000
depends_on:
- server
networks:
- resistencia-net
# --- BACKEND (Node/Express + Socket.io) ---
server:
container_name: resistencia-server
build:
context: .
dockerfile: server/Dockerfile
ports:
- "4000:4000"
volumes:
- ./server:/app/server
- ./shared:/app/shared
- /app/server/node_modules
environment:
- PORT=4000
- DATABASE_URL=postgresql://postgres:password@db:5432/resistencia
- CORS_ORIGIN=http://localhost:3000
depends_on:
- db
networks:
- resistencia-net
# --- BASE DE DATOS (PostgreSQL) ---
db:
container_name: resistencia-db
image: postgres:15-alpine
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: resistencia
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- resistencia-net
networks:
resistencia-net:
driver: bridge
volumes:
postgres_data:

24
server/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:20-alpine
# Create app directory
WORKDIR /app
# Copy shared module first (as a sibling to server)
COPY shared ./shared
# Setup Server directory
WORKDIR /app/server
# Install dependencies
COPY server/package*.json ./
RUN npm install
# Copy server source code
COPY server/src ./src
COPY server/tsconfig.json ./
# Expose port
EXPOSE 4000
# Run with nodemon watching both src and shared
CMD ["npx", "nodemon", "--watch", "src", "--watch", "../shared", "--exec", "npx ts-node", "src/index.ts"]

28
server/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "resistencia-server",
"version": "1.0.0",
"description": "Backend para el juego La Resistencia",
"main": "src/index.ts",
"scripts": {
"dev": "npx nodemon --watch src --exec \"npx ts-node\" src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"socket.io": "^4.7.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"nodemon": "^3.0.1",
"ts-node": "^10.9.1",
"typescript": "^5.3.2"
}
}

254
server/src/index.ts Normal file
View File

@@ -0,0 +1,254 @@
import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
import dotenv from 'dotenv';
import crypto from 'crypto';
import { Game } from './models/Game';
dotenv.config();
const app = express();
const port = process.env.PORT || 4000;
app.use(cors({
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
methods: ["GET", "POST"]
}));
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
methods: ["GET", "POST"]
}
});
// ALMACÉN DE PARTIDAS (En memoria por ahora)
// En el futuro esto podría estar en Redis o Postgres
const games: Record<string, Game> = {};
// --- LOBBY MANAGEMENT ---
const MISSION_NAMES = [
"Operación Overlord", "Operación Market Garden", "Operación Barbarroja",
"Operación Valkiria", "Operación Torch", "Operación Husky",
"Batalla de Stalingrado", "Dunkerque", "El Alamein", "Midway",
"Operación Ciudadela", "Operación Dragoon", "Operación Fortaleza",
"Operación Eiche", "Operación León Marino", "Operación Urano"
];
const generateRoomName = () => {
const idx = Math.floor(Math.random() * MISSION_NAMES.length);
const suffix = Math.floor(100 + Math.random() * 900); // 3 digit code
return `${MISSION_NAMES[idx]} #${suffix}`;
};
io.on('connection', (socket) => {
console.log('Cliente conectado:', socket.id);
// Enviar lista de salas al conectar
socket.emit('rooms_list', getRoomsList());
// A. CREAR SALA
socket.on('create_game', ({ hostName, maxPlayers, password }) => {
console.log(`[CREATE_GAME] Host: ${hostName}, Players: ${maxPlayers}`);
const roomId = crypto.randomUUID();
const roomName = generateRoomName();
const newGame = new Game(roomId, roomName, socket.id, maxPlayers, password);
games[roomId] = newGame;
// Unir al creador automáticamente
newGame.addPlayer(socket.id, hostName);
socket.join(roomId);
// Notificar al creador
socket.emit('game_joined', { roomId, state: newGame.state });
// Actualizar lista a todos
io.emit('rooms_list', getRoomsList());
});
// B. UNIRSE A SALA
socket.on('join_game', ({ roomId, playerName, password }) => {
const game = games[roomId];
if (!game) {
socket.emit('error', 'La partida no existe');
return;
}
if (game.password && game.password !== password) {
socket.emit('error', 'Contraseña incorrecta');
return;
}
if (game.state.players.length >= game.maxPlayers) {
socket.emit('error', 'La partida está llena');
return;
}
// Evitar duplicados
const existingPlayer = game.state.players.find(p => p.id === socket.id);
if (!existingPlayer) {
game.addPlayer(socket.id, playerName);
}
socket.join(roomId);
// Enviar estado actualizado a la sala
io.to(roomId).emit('game_state', game.state);
// Avisar al usuario que entró OK
socket.emit('game_joined', { roomId, state: game.state });
// Actualizar lista de salas (cambió contador de jugadores)
io.emit('rooms_list', getRoomsList());
});
// C. REFRESCAR LISTA
socket.on('get_rooms', () => {
socket.emit('rooms_list', getRoomsList());
});
// --- GAMEPLAY DE SIEMPRE (Adaptado a roomId dinámico) ---
// 2. INICIAR PARTIDA - FASE INTRO
socket.on('start_game', ({ roomId }) => {
const game = games[roomId];
// Solo el host puede iniciar
if (game && game.hostId === socket.id && game.state.phase === 'lobby') {
game.state.phase = 'intro' as any;
io.to(roomId).emit('game_state', game.state);
io.emit('rooms_list', getRoomsList());
}
});
// 2.1 PASAR INTRO -> REVEAL ROLE
socket.on('finish_intro', ({ roomId }) => {
const game = games[roomId];
if (game && game.hostId === socket.id && game.state.phase === 'intro') {
// AQUI DISTRIBUIMOS ROLES
if (game.startGame()) {
// startGame pone phase en TEAM_BUILDING, pero nosotros queremos REVEAL_ROLE primero
game.state.phase = 'reveal_role' as any;
io.to(roomId).emit('game_state', game.state);
}
}
});
// 2.2 REVEAL ROLE -> ROLL CALL
socket.on('finish_reveal', ({ roomId }) => {
const game = games[roomId];
// Cualquiera puede llamar a esto si es auto-timer, o solo host?
// El usuario dijo "Habrá una cuenta atrás de 5 segundos".
// Lo ideal es que el Host controle el tiempo para sincronizar.
if (game && game.hostId === socket.id && game.state.phase === 'reveal_role') {
game.state.phase = 'roll_call' as any;
io.to(roomId).emit('game_state', game.state);
}
});
// 2.3 FINALIZAR ROLL CALL -> PRIMER TURNO DE JUEGO (TEAM_BUILDING)
socket.on('finish_roll_call', ({ roomId }) => {
const game = games[roomId];
if (game && game.hostId === socket.id && game.state.phase === 'roll_call') {
// Ir a VOTE_LEADER (ya que startGame lo inicializa a VOTE_LEADER en el modelo, y nextLeader tambien)
// Solo debemos asegurarnos que el GameState se sincronice.
if (game.startGame()) {
io.to(roomId).emit('game_state', game.state);
}
}
});
// 2.4 VOTAR LÍDER
socket.on('vote_leader', ({ roomId, approve }) => {
const game = games[roomId];
if (game) {
game.voteLeader(socket.id, approve);
io.to(roomId).emit('game_state', game.state);
}
});
// 3. PROPONER EQUIPO
socket.on('propose_team', ({ roomId, teamIds }) => {
const game = games[roomId];
if (game && game.proposeTeam(teamIds)) {
io.to(roomId).emit('game_state', game.state);
}
});
// 4. VOTAR EQUIPO
socket.on('vote_team', ({ roomId, approve }) => {
const game = games[roomId];
if (game) {
game.voteForTeam(socket.id, approve);
io.to(roomId).emit('game_state', game.state);
}
});
// 5. VOTAR MISIÓN
socket.on('vote_mission', ({ roomId, success }) => {
const game = games[roomId];
if (game) {
game.voteMission(success);
io.to(roomId).emit('game_state', game.state);
}
});
// 5.1 FINALIZAR REVELACIÓN DE CARTAS
socket.on('finish_reveal', ({ roomId }) => {
const game = games[roomId];
if (game && game.hostId === socket.id && game.state.phase === 'mission_reveal') {
game.finishReveal();
io.to(roomId).emit('game_state', game.state);
}
});
// 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') {
game.finishMissionResult();
io.to(roomId).emit('game_state', game.state);
}
});
// 6. ASESINATO FINAL
socket.on('assassin_kill', ({ roomId, targetId }) => {
const game = games[roomId];
if (game) {
game.assassinKill(targetId);
io.to(roomId).emit('game_state', game.state);
}
});
// 7. DESCONEXIÓN
socket.on('disconnect', () => {
// Buscar en qué partida estaba y sacarlo (opcional, por ahora solo notificamos)
console.log('Desconectado:', socket.id);
// TODO: Eliminar de la partida si está en LOBBY para liberar hueco
});
});
const getRoomsList = () => {
return Object.values(games).map(g => ({
id: g.state.roomId,
name: g.roomName,
hostId: g.hostId,
currentPlayers: g.state.players.length,
maxPlayers: g.maxPlayers,
isPrivate: !!g.password,
status: g.state.phase === 'lobby' ? 'waiting' : 'playing'
}));
};
app.get('/', (req, res) => {
res.send('Servidor de La Resistencia funcionando 🚀');
});
server.listen(port, () => {
console.log(`[server]: Servidor corriendo en http://localhost:${port}`);
});

311
server/src/models/Game.ts Normal file
View File

@@ -0,0 +1,311 @@
import {
GameState,
Player,
GamePhase,
Role,
Faction,
GAME_CONFIG
} from '../../../shared/types';
export class Game {
public state: GameState;
public roomName: string;
public hostId: string;
public maxPlayers: number;
public password?: string;
constructor(
roomId: string,
roomName: string,
hostId: string,
maxPlayers: number,
password?: string
) {
this.roomName = roomName;
this.hostId = hostId;
this.maxPlayers = maxPlayers;
this.password = password;
this.state = {
roomId,
phase: GamePhase.LOBBY,
players: [],
currentRound: 1,
failedVotesCount: 0,
questResults: [null, null, null, null, null],
currentLeaderId: '',
leaderVotes: {},
proposedTeam: [],
teamVotes: {},
missionVotes: [],
missionHistory: [],
revealedVotes: [],
history: [],
hostId: hostId
};
}
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`;
const player: Player = {
id,
name,
avatar: avatarStr,
isLeader: false
};
this.state.players.push(player);
this.log(`${name} se ha unido a la partida.`);
return player;
}
removePlayer(id: string) {
this.state.players = this.state.players.filter(p => p.id !== id);
}
startGame(): boolean {
const count = this.state.players.length;
if (count < 5 || count > 10) return false;
const config = GAME_CONFIG[count as keyof typeof GAME_CONFIG];
// 1. Asignar Roles
this.assignRoles(config.good, config.evil);
// 2. Asignar Líder inicial aleatorio
const leaderIndex = Math.floor(Math.random() * count);
this.state.players[leaderIndex].isLeader = true;
this.state.currentLeaderId = this.state.players[leaderIndex].id;
// 3. Iniciar juego - Fase Votación de Líder
this.state.phase = GamePhase.VOTE_LEADER;
this.state.leaderVotes = {}; // Reset votes
this.state.currentRound = 1;
this.log('¡La partida ha comenzado! Ahora deben votar para confirmar al Líder.');
return true;
}
// ... assignRoles se mantiene igual ...
private assignRoles(goodCount: number, evilCount: number) {
// Roles obligatorios
const roles: Role[] = [Role.MERLIN, Role.ASSASSIN];
// Rellenar resto de malos
for (let i = 0; i < evilCount - 1; i++) roles.push(Role.MINION);
// Rellenar resto de buenos
for (let i = 0; i < goodCount - 1; i++) roles.push(Role.LOYAL_SERVANT);
// Barajar roles
const shuffledRoles = roles.sort(() => Math.random() - 0.5);
// Asignar a jugadores
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;
} else {
player.faction = Faction.SPIES;
}
});
}
// --- LOGICA DE VOTACIÓN DE LÍDER ---
voteLeader(playerId: string, approve: boolean | null) {
this.state.leaderVotes[playerId] = approve;
// Comprobar si todos han votado
if (Object.keys(this.state.leaderVotes).length === this.state.players.length) {
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.
this.log(`Votación de Líder: ${approves} A favor - ${rejects} En contra.`);
if (approves > rejects) {
// 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.');
} 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
}
}
proposeTeam(playerIds: string[]): boolean {
// Validar tamaño del equipo según la ronda
const config = GAME_CONFIG[this.state.players.length as keyof typeof GAME_CONFIG];
const requiredSize = config.quests[this.state.currentRound - 1];
if (playerIds.length !== requiredSize) return false;
this.state.proposedTeam = playerIds;
this.state.phase = GamePhase.VOTING_TEAM;
this.state.teamVotes = {}; // Resetear votos
this.log(`El líder ha propuesto un equipo de ${playerIds.length} personas.`);
return true;
}
voteForTeam(playerId: string, approve: boolean) {
this.state.teamVotes[playerId] = approve;
// Comprobar si todos han votado
if (Object.keys(this.state.teamVotes).length === this.state.players.length) {
this.resolveTeamVote();
}
}
private resolveTeamVote() {
const votes = Object.values(this.state.teamVotes);
const approves = votes.filter(v => v).length;
const rejects = votes.length - approves;
this.log(`Votación completada: ${approves} A favor - ${rejects} En contra.`);
if (approves > rejects) {
// Equipo Aprobado
this.state.phase = GamePhase.MISSION;
this.state.missionVotes = [];
this.state.failedVotesCount = 0;
this.log('El equipo ha sido aprobado. ¡Comienza la misión!');
} else {
// Equipo Rechazado
this.state.failedVotesCount++;
this.state.proposedTeam = [];
if (this.state.failedVotesCount >= 5) {
this.endGame(Faction.SPIES, 'Se han rechazado 5 equipos consecutivos.');
} else {
this.nextLeader(); // Pasa a VOTE_LEADER
this.log('El equipo fue rechazado. El liderazgo pasa al siguiente jugador.');
}
}
}
voteMission(success: boolean) {
this.state.missionVotes.push(success);
// Comprobar si todos los miembros del equipo han votado
if (this.state.missionVotes.length === this.state.proposedTeam.length) {
this.resolveMission();
}
}
private resolveMission() {
const fails = this.state.missionVotes.filter(v => !v).length;
const successes = this.state.missionVotes.filter(v => v).length;
const playerCount = this.state.players.length;
const round = this.state.currentRound;
// Regla especial: 4ta misión con 7+ jugadores necesita 2 fallos
let failsRequired = 1;
if (playerCount >= 7 && round === 4) {
failsRequired = 2;
}
const isSuccess = fails < failsRequired;
// 1. BARAJAR los votos para que no se sepa quién votó qué
const shuffledVotes = [...this.state.missionVotes].sort(() => Math.random() - 0.5);
// 2. Guardar en el histórico
const missionRecord = {
round,
team: [...this.state.proposedTeam],
votes: shuffledVotes,
successes,
fails,
isSuccess,
leaderId: this.state.currentLeaderId
};
this.state.missionHistory.push(missionRecord);
// 3. Actualizar resultado de la quest
this.state.questResults[round - 1] = isSuccess;
// 4. Pasar a fase MISSION_REVEAL (mostrar cartas una a una)
this.state.phase = GamePhase.MISSION_REVEAL;
this.state.revealedVotes = shuffledVotes; // Las cartas a revelar
this.log(`Misión ${round} completada. Revelando votos...`);
}
// Método para avanzar desde MISSION_REVEAL a MISSION_RESULT
finishReveal() {
this.state.phase = GamePhase.MISSION_RESULT;
}
// Método para avanzar desde MISSION_RESULT y continuar el juego
finishMissionResult() {
const round = this.state.currentRound;
const isSuccess = this.state.questResults[round - 1];
this.log(`Misión ${round}: ${isSuccess ? 'ÉXITO' : 'FRACASO'}`);
// Comprobar condiciones de victoria
const successes = this.state.questResults.filter(r => r === true).length;
const failures = this.state.questResults.filter(r => r === false).length;
if (failures >= 3) {
this.endGame(Faction.SPIES, 'Tres misiones han fracasado.');
} else if (successes >= 3) {
this.state.phase = GamePhase.ASSASSIN_PHASE;
this.log('¡La Resistencia ha triunfado! Pero el Asesino tiene una última oportunidad...');
} else {
// Siguiente ronda
this.state.currentRound++;
this.nextLeader(); // Esto pone phase = VOTE_LEADER
this.state.proposedTeam = [];
this.state.missionVotes = [];
this.state.revealedVotes = [];
}
}
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)!');
} else {
this.endGame(Faction.RESISTANCE, 'El Asesino ha fallado. ¡La Resistencia gana!');
}
}
private nextLeader() {
const currentIdx = this.state.players.findIndex(p => p.id === this.state.currentLeaderId);
const nextIdx = (currentIdx + 1) % this.state.players.length;
this.state.players[currentIdx].isLeader = false;
this.state.players[nextIdx].isLeader = true;
this.state.currentLeaderId = this.state.players[nextIdx].id;
// Fase de confirmar al nuevo líder
this.state.phase = GamePhase.VOTE_LEADER;
this.state.leaderVotes = {};
}
private endGame(winner: Faction, reason: string) {
this.state.winner = winner;
this.state.phase = GamePhase.GAME_OVER;
this.log(`FIN DEL JUEGO. Victoria para ${winner}. Razón: ${reason}`);
}
private log(message: string) {
this.state.history.push(message);
// Mantener solo los últimos 50 mensajes
if (this.state.history.length > 50) this.state.history.shift();
}
}

15
server/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": false,
"skipLibCheck": true
},
"include": [
"src/**/*",
"../shared/**/*"
]
}

127
shared/types.ts Normal file
View File

@@ -0,0 +1,127 @@
export enum Role {
// Bando del Bien (Resistencia Francesa)
MERLIN = 'merlin', // Marlenne
PERCIVAL = 'percival',
LOYAL_SERVANT = 'loyal_servant', // Soldado Resistencia
// Bando del Mal (Ocupación Alemana)
MORDRED = 'mordred',
ASSASSIN = 'assassin',
MORGANA = 'morgana',
OBERON = 'oberon',
MINION = 'minion', // Soldado Alemán
}
export enum Faction {
RESISTANCE = 'resistance',
SPIES = 'spies',
}
export enum GamePhase {
LOBBY = 'lobby',
INTRO = 'intro',
REVEAL_ROLE = 'reveal_role',
ROLL_CALL = 'roll_call',
VOTE_LEADER = 'vote_leader', // Votar si se acepta al líder
TEAM_BUILDING = 'team_building', // Líder propone equipo
VOTING_TEAM = 'voting_team', // Todos votan si aprueban el equipo
MISSION = 'mission', // Los elegidos votan éxito/fracaso
MISSION_REVEAL = 'mission_reveal', // Mostrar cartas una a una
MISSION_RESULT = 'mission_result', // Pantalla de resumen
ASSASSIN_PHASE = 'assassin_phase', // Si gana el bien, el asesino intenta matar a Merlín
GAME_OVER = 'game_over',
}
export interface Player {
id: string;
name: string;
avatar: string; // Avatar persistente
role?: Role;
faction?: Faction;
isLeader: boolean;
}
export interface MissionResult {
successes: number;
fails: number;
isSuccess: boolean;
}
// Registro de una misión completada
export interface MissionRecord {
round: number;
team: string[]; // IDs de los participantes
votes: boolean[]; // Votos (barajados)
successes: number;
fails: number;
isSuccess: boolean;
leaderId: string;
}
export interface GameState {
roomId: string;
phase: GamePhase;
players: Player[];
hostId: string;
currentRound: number;
failedVotesCount: number;
questResults: (boolean | null)[];
currentLeaderId: string;
// Votación de Líder
leaderVotes: Record<string, boolean | null>;
// Selección de Equipo (Misión)
proposedTeam: string[];
teamVotes: Record<string, boolean>; // Votos de aprobación del equipo (idJugador -> aprueba/rechaza)
missionVotes: boolean[]; // Votos anónimos de la misión (éxito/fracaso)
// Histórico de misiones
missionHistory: MissionRecord[];
// Para la animación de revelación de cartas
revealedVotes: boolean[]; // Votos que se van mostrando uno a uno
winner?: Faction;
history: string[]; // Log de acciones para mostrar en pantalla
}
// Configuración de jugadores por partida (según tus reglas)
export const GAME_CONFIG = {
5: { good: 3, evil: 2, quests: [2, 3, 2, 3, 3] },
6: { good: 4, evil: 2, quests: [2, 3, 4, 3, 4] },
7: { good: 4, evil: 3, quests: [2, 3, 3, 4, 4] }, // Nota: 4ta misión requiere 2 fallos
8: { good: 5, evil: 3, quests: [3, 4, 4, 5, 5] },
9: { good: 6, evil: 3, quests: [3, 4, 4, 5, 5] },
10: { good: 6, evil: 4, quests: [3, 4, 4, 5, 5] },
};
// --- NUEVOS TIPOS PARA EL LOBBY ---
export interface GameRoom {
id: string; // ID interno único (uuid)
name: string; // Nombre de misión (ej: Operación Overlord)
hostId: string; // ID del creador
currentPlayers: number;
maxPlayers: number;
isPrivate: boolean; // Si tiene contraseña
status: 'waiting' | 'playing' | 'finished';
}
export interface CreateGamePayload {
hostName: string;
maxPlayers: number;
password?: string;
}
export interface JoinGamePayload {
roomId: string; // Puede ser el ID numérico interno o lo que usemos
playerName: string;
password?: string;
}
export interface LobbyLists {
rooms: GameRoom[];
}