commit 8d423ac19d369c9a8f84a21b2795766a905aa5dc Author: Resistencia Dev Date: Fri Dec 5 22:07:20 2025 +0100 Estado actual con errores de sintaxis en GameBoard.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd187d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +dist +.env +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be24441 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +dist/ +*.log +.env +.DS_Store +postgres_data/ diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..0fca3dd --- /dev/null +++ b/client/Dockerfile @@ -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"] diff --git a/client/next-env.d.ts b/client/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/client/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/client/next.config.js b/client/next.config.js new file mode 100644 index 0000000..767719f --- /dev/null +++ b/client/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..cdd5ac5 --- /dev/null +++ b/client/package.json @@ -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" + } +} \ No newline at end of file diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..fef1b22 --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/client/public/assets/audio/Intro.ogg b/client/public/assets/audio/Intro.ogg new file mode 100644 index 0000000..4bbbb65 Binary files /dev/null and b/client/public/assets/audio/Intro.ogg differ diff --git a/client/public/assets/audio/Rondas.ogg b/client/public/assets/audio/Rondas.ogg new file mode 100644 index 0000000..5323d0a Binary files /dev/null and b/client/public/assets/audio/Rondas.ogg differ diff --git a/client/public/assets/images/characters/USA_OSS.png b/client/public/assets/images/characters/USA_OSS.png new file mode 100644 index 0000000..09f8344 Binary files /dev/null and b/client/public/assets/images/characters/USA_OSS.png differ diff --git a/client/public/assets/images/characters/avatar_1.png b/client/public/assets/images/characters/avatar_1.png new file mode 100644 index 0000000..13ad88c Binary files /dev/null and b/client/public/assets/images/characters/avatar_1.png differ diff --git a/client/public/assets/images/characters/avatar_2.png b/client/public/assets/images/characters/avatar_2.png new file mode 100644 index 0000000..0c6b66a Binary files /dev/null and b/client/public/assets/images/characters/avatar_2.png differ diff --git a/client/public/assets/images/characters/avatar_3.png b/client/public/assets/images/characters/avatar_3.png new file mode 100644 index 0000000..4b3b19b Binary files /dev/null and b/client/public/assets/images/characters/avatar_3.png differ diff --git a/client/public/assets/images/characters/card_back.png b/client/public/assets/images/characters/card_back.png new file mode 100644 index 0000000..93c0587 Binary files /dev/null and b/client/public/assets/images/characters/card_back.png differ diff --git a/client/public/assets/images/characters/evil_assassin.png b/client/public/assets/images/characters/evil_assassin.png new file mode 100644 index 0000000..f5a72a5 Binary files /dev/null and b/client/public/assets/images/characters/evil_assassin.png differ diff --git a/client/public/assets/images/characters/evil_minion_1.png b/client/public/assets/images/characters/evil_minion_1.png new file mode 100644 index 0000000..1a606f9 Binary files /dev/null and b/client/public/assets/images/characters/evil_minion_1.png differ diff --git a/client/public/assets/images/characters/evil_minion_2.png b/client/public/assets/images/characters/evil_minion_2.png new file mode 100644 index 0000000..6a6ba0f Binary files /dev/null and b/client/public/assets/images/characters/evil_minion_2.png differ diff --git a/client/public/assets/images/characters/evil_minion_3.png b/client/public/assets/images/characters/evil_minion_3.png new file mode 100644 index 0000000..d09fb73 Binary files /dev/null and b/client/public/assets/images/characters/evil_minion_3.png differ diff --git a/client/public/assets/images/characters/evil_mordred.png b/client/public/assets/images/characters/evil_mordred.png new file mode 100644 index 0000000..70bf6a3 Binary files /dev/null and b/client/public/assets/images/characters/evil_mordred.png differ diff --git a/client/public/assets/images/characters/evil_morgana.png b/client/public/assets/images/characters/evil_morgana.png new file mode 100644 index 0000000..c03ae2f Binary files /dev/null and b/client/public/assets/images/characters/evil_morgana.png differ diff --git a/client/public/assets/images/characters/evil_oberon.png b/client/public/assets/images/characters/evil_oberon.png new file mode 100644 index 0000000..da46778 Binary files /dev/null and b/client/public/assets/images/characters/evil_oberon.png differ diff --git a/client/public/assets/images/characters/good_merlin.png b/client/public/assets/images/characters/good_merlin.png new file mode 100644 index 0000000..bbd9863 Binary files /dev/null and b/client/public/assets/images/characters/good_merlin.png differ diff --git a/client/public/assets/images/characters/good_percival.png b/client/public/assets/images/characters/good_percival.png new file mode 100644 index 0000000..9781436 Binary files /dev/null and b/client/public/assets/images/characters/good_percival.png differ diff --git a/client/public/assets/images/characters/good_soldier_1.png b/client/public/assets/images/characters/good_soldier_1.png new file mode 100644 index 0000000..b691137 Binary files /dev/null and b/client/public/assets/images/characters/good_soldier_1.png differ diff --git a/client/public/assets/images/characters/good_soldier_2.png b/client/public/assets/images/characters/good_soldier_2.png new file mode 100644 index 0000000..de4163a Binary files /dev/null and b/client/public/assets/images/characters/good_soldier_2.png differ diff --git a/client/public/assets/images/characters/good_soldier_3.png b/client/public/assets/images/characters/good_soldier_3.png new file mode 100644 index 0000000..04b1e27 Binary files /dev/null and b/client/public/assets/images/characters/good_soldier_3.png differ diff --git a/client/public/assets/images/characters/good_soldier_4.png b/client/public/assets/images/characters/good_soldier_4.png new file mode 100644 index 0000000..2743397 Binary files /dev/null and b/client/public/assets/images/characters/good_soldier_4.png differ diff --git a/client/public/assets/images/characters/good_soldier_5.png b/client/public/assets/images/characters/good_soldier_5.png new file mode 100644 index 0000000..1e10f29 Binary files /dev/null and b/client/public/assets/images/characters/good_soldier_5.png differ diff --git a/client/public/assets/images/characters/rebel001.jpg b/client/public/assets/images/characters/rebel001.jpg new file mode 100644 index 0000000..5714930 Binary files /dev/null and b/client/public/assets/images/characters/rebel001.jpg differ diff --git a/client/public/assets/images/characters/rebel002.jpg b/client/public/assets/images/characters/rebel002.jpg new file mode 100644 index 0000000..8235de1 Binary files /dev/null and b/client/public/assets/images/characters/rebel002.jpg differ diff --git a/client/public/assets/images/characters/rebel003.jpg b/client/public/assets/images/characters/rebel003.jpg new file mode 100644 index 0000000..5ca87d3 Binary files /dev/null and b/client/public/assets/images/characters/rebel003.jpg differ diff --git a/client/public/assets/images/characters/rebel004.jpg b/client/public/assets/images/characters/rebel004.jpg new file mode 100644 index 0000000..a731a07 Binary files /dev/null and b/client/public/assets/images/characters/rebel004.jpg differ diff --git a/client/public/assets/images/characters/rebel005.jpg b/client/public/assets/images/characters/rebel005.jpg new file mode 100644 index 0000000..48e3d79 Binary files /dev/null and b/client/public/assets/images/characters/rebel005.jpg differ diff --git a/client/public/assets/images/characters/rebel006.jpg b/client/public/assets/images/characters/rebel006.jpg new file mode 100644 index 0000000..38e7606 Binary files /dev/null and b/client/public/assets/images/characters/rebel006.jpg differ diff --git a/client/public/assets/images/characters/rebel007.jpg b/client/public/assets/images/characters/rebel007.jpg new file mode 100644 index 0000000..4124d1d Binary files /dev/null and b/client/public/assets/images/characters/rebel007.jpg differ diff --git a/client/public/assets/images/characters/rebel008.jpg b/client/public/assets/images/characters/rebel008.jpg new file mode 100644 index 0000000..f98d73d Binary files /dev/null and b/client/public/assets/images/characters/rebel008.jpg differ diff --git a/client/public/assets/images/characters/rebel009.jpg b/client/public/assets/images/characters/rebel009.jpg new file mode 100644 index 0000000..10a93af Binary files /dev/null and b/client/public/assets/images/characters/rebel009.jpg differ diff --git a/client/public/assets/images/characters/rebel010.jpg b/client/public/assets/images/characters/rebel010.jpg new file mode 100644 index 0000000..1a012bd Binary files /dev/null and b/client/public/assets/images/characters/rebel010.jpg differ diff --git a/client/public/assets/images/tokens/Votacion_Back.png b/client/public/assets/images/tokens/Votacion_Back.png new file mode 100644 index 0000000..2dd15aa Binary files /dev/null and b/client/public/assets/images/tokens/Votacion_Back.png differ diff --git a/client/public/assets/images/tokens/accept_leader.png b/client/public/assets/images/tokens/accept_leader.png new file mode 100644 index 0000000..e0f9c8b Binary files /dev/null and b/client/public/assets/images/tokens/accept_leader.png differ diff --git a/client/public/assets/images/tokens/deny_leader.png b/client/public/assets/images/tokens/deny_leader.png new file mode 100644 index 0000000..a38afcc Binary files /dev/null and b/client/public/assets/images/tokens/deny_leader.png differ diff --git a/client/public/assets/images/tokens/marker_round.png b/client/public/assets/images/tokens/marker_round.png new file mode 100644 index 0000000..7ab3cf0 Binary files /dev/null and b/client/public/assets/images/tokens/marker_round.png differ diff --git a/client/public/assets/images/tokens/marker_score_blue.jpg b/client/public/assets/images/tokens/marker_score_blue.jpg new file mode 100644 index 0000000..0ac4401 Binary files /dev/null and b/client/public/assets/images/tokens/marker_score_blue.jpg differ diff --git a/client/public/assets/images/tokens/marker_score_blue.png b/client/public/assets/images/tokens/marker_score_blue.png new file mode 100644 index 0000000..ba0f469 Binary files /dev/null and b/client/public/assets/images/tokens/marker_score_blue.png differ diff --git a/client/public/assets/images/tokens/marker_score_red.png b/client/public/assets/images/tokens/marker_score_red.png new file mode 100644 index 0000000..e889ed6 Binary files /dev/null and b/client/public/assets/images/tokens/marker_score_red.png differ diff --git a/client/public/assets/images/tokens/mission_fail.png b/client/public/assets/images/tokens/mission_fail.png new file mode 100644 index 0000000..a5678f9 Binary files /dev/null and b/client/public/assets/images/tokens/mission_fail.png differ diff --git a/client/public/assets/images/tokens/mission_success.png b/client/public/assets/images/tokens/mission_success.png new file mode 100644 index 0000000..57e976e Binary files /dev/null and b/client/public/assets/images/tokens/mission_success.png differ diff --git a/client/public/assets/images/tokens/token_allies2.jpg b/client/public/assets/images/tokens/token_allies2.jpg new file mode 100644 index 0000000..f81f689 Binary files /dev/null and b/client/public/assets/images/tokens/token_allies2.jpg differ diff --git a/client/public/assets/images/tokens/token_allies2.png b/client/public/assets/images/tokens/token_allies2.png new file mode 100644 index 0000000..8f35abe Binary files /dev/null and b/client/public/assets/images/tokens/token_allies2.png differ diff --git a/client/public/assets/images/tokens/token_leader.png b/client/public/assets/images/tokens/token_leader.png new file mode 100644 index 0000000..34e5fb9 Binary files /dev/null and b/client/public/assets/images/tokens/token_leader.png differ diff --git a/client/public/assets/images/tokens/token_team.png b/client/public/assets/images/tokens/token_team.png new file mode 100644 index 0000000..de9adea Binary files /dev/null and b/client/public/assets/images/tokens/token_team.png differ diff --git a/client/public/assets/images/tokens/traseraTiros2.png b/client/public/assets/images/tokens/traseraTiros2.png new file mode 100644 index 0000000..700997d Binary files /dev/null and b/client/public/assets/images/tokens/traseraTiros2.png differ diff --git a/client/public/assets/images/tokens/vote_approve.png b/client/public/assets/images/tokens/vote_approve.png new file mode 100644 index 0000000..34c8b66 Binary files /dev/null and b/client/public/assets/images/tokens/vote_approve.png differ diff --git a/client/public/assets/images/tokens/vote_reject.png b/client/public/assets/images/tokens/vote_reject.png new file mode 100644 index 0000000..08a2460 Binary files /dev/null and b/client/public/assets/images/tokens/vote_reject.png differ diff --git a/client/public/assets/images/ui/bg_game.png b/client/public/assets/images/ui/bg_game.png new file mode 100644 index 0000000..f77fa05 Binary files /dev/null and b/client/public/assets/images/ui/bg_game.png differ diff --git a/client/public/assets/images/ui/bg_intro.png b/client/public/assets/images/ui/bg_intro.png new file mode 100644 index 0000000..b71758a Binary files /dev/null and b/client/public/assets/images/ui/bg_intro.png differ diff --git a/client/public/assets/images/ui/bg_lobby.png b/client/public/assets/images/ui/bg_lobby.png new file mode 100644 index 0000000..bd09f41 Binary files /dev/null and b/client/public/assets/images/ui/bg_lobby.png differ diff --git a/client/public/assets/images/ui/bg_roll_call.png b/client/public/assets/images/ui/bg_roll_call.png new file mode 100644 index 0000000..f9b4d8c Binary files /dev/null and b/client/public/assets/images/ui/bg_roll_call.png differ diff --git a/client/public/assets/images/ui/board_map.jpg b/client/public/assets/images/ui/board_map.jpg new file mode 100644 index 0000000..3128c71 Binary files /dev/null and b/client/public/assets/images/ui/board_map.jpg differ diff --git a/client/public/assets/images/ui/board_track.png b/client/public/assets/images/ui/board_track.png new file mode 100644 index 0000000..407d12c Binary files /dev/null and b/client/public/assets/images/ui/board_track.png differ diff --git a/client/public/assets/images/ui/logo.png b/client/public/assets/images/ui/logo.png new file mode 100644 index 0000000..fd18e00 Binary files /dev/null and b/client/public/assets/images/ui/logo.png differ diff --git a/client/src/app/globals.css b/client/src/app/globals.css new file mode 100644 index 0000000..fd81e88 --- /dev/null +++ b/client/src/app/globals.css @@ -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)); +} diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx new file mode 100644 index 0000000..9db5f48 --- /dev/null +++ b/client/src/app/layout.tsx @@ -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 ( + + {children} + + ) +} diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx new file mode 100644 index 0000000..e6258af --- /dev/null +++ b/client/src/app/page.tsx @@ -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('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(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 ( +
+
+ War Room +
+
+ +
+
+

Sala de Espera

+

Operación en curso. Esperando activación...

+
+ +
+ {gameState.players.map(player => ( +
+
+ + {player.name} + + {player.id === gameState.hostId && ( + HOST + )} +
+ ))} + {/* Rellenar huecos vacíos visualmente si quisieramos */} +
+ +
+ {isHost ? ( + <> +

+ Jugadores: {gameState.players.length} + {gameState.players.length < 5 && (Mínimo 5 requeridos)} +

+ + + ) : ( +
+

Esperando al Comandante...

+

La misión comenzará cuando el líder dé la orden.

+
+ )} +
+
+
+ ); + } + + return ( + + ); + } + + return ( +
+ + {/* FONDO COMÚN LOBBY/LOGIN */} +
+ Lobby Background +
+
+ + {/* HEADER / LOGO */} +
+
+ Logo +

+ La Resistencia +

+
+ {view === 'lobby' && ( +
+ AGENTE: + {fullPlayerName} +
+ )} +
+ +
+ + + {/* --- PANTALLA DE LOGIN --- */} + {view === 'login' && ( + +

Identificación

+ +
+
+ + 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" + /> +
+ {/* APELLIDO ELIMINADO - SE GENERA AUTOMÁTICAMENTE */} + + +
+
+ )} + + {/* --- PANTALLA DE LOBBY (LISTA DE SALAS) --- */} + {view === 'lobby' && ( + +
+
+

MISIONES ACTIVAS

+

Selecciona una operación o inicia una nueva.

+
+ +
+ +
+ {roomsList.length === 0 ? ( +
+ No hay misiones activas en este momento. +
+ ) : ( + roomsList.map((room) => ( + +
+ {room.isPrivate ? ( + 🔒 + ) : ( + 🔓 + )} +
+ +

+ {room.name} +

+ +
+ + HOST: + + {room.hostId.substring(0, 6)}... +
+ +
+
+ {room.currentPlayers} + / {room.maxPlayers} +
+ + +
+ + {/* Barra de progreso visual */} +
+
+
+ + )) + )} +
+
+ )} + +
+ + {/* --- MODALES --- */} + + {/* Modal Crear */} + {showCreateModal && ( +
+ +

Configurar Operación

+
+
+ + +
+
+ + 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" + /> +
+
+ + +
+
+
+
+ )} + + {/* Modal Password */} + {passwordPromptRoomId && ( +
+ +

+ 🔒 Acceso Restringido +

+

Esta operación es clasificada. Introduce la clave de acceso.

+ + 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..." + /> + +
+ + +
+
+
+ )} + +
+ {isConnected ? ● CONEXIÓN SEGURA : ● BUSCANDO SEÑAL...} +
+
+ ); +} diff --git a/client/src/components/GameBoard.tsx b/client/src/components/GameBoard.tsx new file mode 100644 index 0000000..ad8c854 --- /dev/null +++ b/client/src/components/GameBoard.tsx @@ -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([]); + + // 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 ( +
+
+ Battlefield +
+
+ +

+ Guerra Total +

+ + {/* Audio Auto-Play */} +
+ ); + } + + // 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 ( +
+ {/* FONDO (Mismo que Roll Call) */} +
+ Resistance HQ +
+
+ +
+

+ Tu Identidad Secreta +

+ +

+ Desliza hacia arriba para revelar +

+ +
+ {/* Carta Revelada (Fondo) */} +
+ Role +
+ {role?.replace('_', ' ')} +
+
+ + {/* Reverso de Carta (Draggable) */} + { + // 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" + > + Card Back + +
+ + +
+
+ ); + } + + // FASE ROLL CALL + if (gameState.phase === 'roll_call' as any) { + return ( +
+
+ Resistance HQ +
+
+ +
+

+ Pasando Lista... +

+ + {isHost && ( +
+
+ ); + } + + return ( +
+
+ Game Background +
+
+ +
+ + {/* --- MAPA TÁCTICO (TABLERO) --- */} +
+ Tactical Map + + {/* TOKENS SOBRE EL MAPA */} + {missionCoords.map((coord, idx) => { + const result = gameState.questResults[idx]; + const isCurrent = gameState.currentRound === idx + 1; + + return ( +
+ {/* Marcador de Ronda Actual */} + {isCurrent && ( + + Current Round + + )} + + {/* Resultado de Misión (Éxito/Fracaso) */} + {result === true && ( + + Success + + )} + {result === false && ( + + Fail + + )} +
+ ); + })} + + {/* TRACK DE VOTOS FALLIDOS (Pequeño indicador en la esquina inferior izquierda del mapa) */} +
+
Votos Rechazados
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+
+ + {/* --- ÁREA DE JUEGO (CARTAS Y ACCIONES) --- */} +
+ + + {/* FASE: VOTACIÓN DE LÍDER */} + {gameState.phase === 'vote_leader' as any && ( + +
+

+ Confirmar Líder +

+
+ ¿Aceptas a {gameState.players.find(p => p.id === gameState.currentLeaderId)?.name} como Líder? +
+ + {/* Timer */} + {!gameState.leaderVotes?.[currentPlayerId] && ( + actions.voteLeader(null)} /> + )} +
+ + {gameState.leaderVotes?.[currentPlayerId] === undefined ? ( +
+ + +
+ ) : ( +
+ VOTO REGISTRADO. ESPERANDO AL RESTO... +
+ )} +
+ )} + + {/* FASE: CONSTRUCCIÓN DE EQUIPO */} + {gameState.phase === GamePhase.TEAM_BUILDING && ( + +

+ {isLeader ? 'TU TURNO: ELIGE EQUIPO' : `ESPERANDO AL LÍDER...`} +

+

+ Se necesitan {currentQuestSize} agentes para esta misión. +

+ + {isLeader && ( + + )} +
+ )} + + {/* FASE: VOTACIÓN DE EQUIPO */} + {gameState.phase === GamePhase.VOTING_TEAM && ( + +
+

PROPUESTA DE MISIÓN

+
+ {gameState.proposedTeam.map(id => { + const p = gameState.players.find(pl => pl.id === id); + return ( +
+ {p?.name} +
+ ); + })} +
+
+ + {!currentPlayer?.hasVoted ? ( +
+ + +
+ ) : ( +
+ VOTO REGISTRADO. ESPERANDO AL RESTO... +
+ )} +
+ )} + + {/* FASE: MISIÓN */} + {gameState.phase === GamePhase.MISSION && ( + + {gameState.proposedTeam.includes(currentPlayerId) ? ( +
+

¡ESTÁS EN LA MISIÓN!

+
+ + + {/* Solo los malos pueden sabotear */} + {currentPlayer?.faction === 'spies' && ( + + )} +
+
+ ) : ( +
+ La misión está en curso...
+ Rezando por el éxito. +
+ )} +
+ )} + + {/* FASE: REVELACIÓN DE CARTAS */} + {gameState.phase === 'mission_reveal' as any && ( + isHost && actions.finishMissionReveal()} + /> + )} + + {/* FASE: RESULTADO DE MISIÓN */} + {gameState.phase === 'mission_result' as any && ( + isHost && actions.finishMissionResult()} + /> + )} + +
+
+ + {/* JUGADORES (TIENDA DE CAMPAÑA) */} +
+
+ {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 ( +
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 */} +
+ {player.name} + + {/* Icono de Líder */} + {player.isLeader && ( +
+ L +
+ )} +
+ + {/* Nombre */} + + {player.name} + + ); + })} +
+
+ + {/* HISTÓRICO DE MISIONES (Esquina superior derecha) */} + {gameState.missionHistory.length > 0 && ( +
+
Historial
+
+ {gameState.missionHistory.map((mission, idx) => ( +
+ {mission.round} +
+ ))} +
+
+ )} +
+
+ ); +} + + +// 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 ( +
+ {timeLeft} +
+ ); +} + +// 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 ( + +

+ Revelando Votos... +

+ +
+ {votes.slice(0, revealedCount).map((vote, idx) => ( + + {vote + + ))} +
+ +
+ {revealedCount} / {votes.length} cartas reveladas +
+
+ ); +} + +// 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 ( + + {/* Título */} +
+

+ {isSuccess ? '✓ MISIÓN EXITOSA' : '✗ MISIÓN FALLIDA'} +

+

Misión #{round}

+
+ + {/* Estadísticas */} +
+
+
{successes}
+
Éxitos
+
+
+
{fails}
+
Sabotajes
+
+
+ + {/* Equipo */} +
+

Equipo de Misión:

+
+ {team.map((playerId: string) => { + const player = gameState.players.find((p: any) => p.id === playerId); + return ( +
+ {player?.name || 'Desconocido'} +
+ ); + })} +
+
+ + {/* Mensaje */} +
+ Continuando en breve... +
+
+ ); +} diff --git a/client/src/hooks/useSocket.ts b/client/src/hooks/useSocket.ts new file mode 100644 index 0000000..e991ba4 --- /dev/null +++ b/client/src/hooks/useSocket.ts @@ -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(null); + const [gameState, setGameState] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [roomsList, setRoomsList] = useState([]); + + 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 }) + } + }; +}; diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts new file mode 100644 index 0000000..275b3a4 --- /dev/null +++ b/client/tailwind.config.ts @@ -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 diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..7badb1f --- /dev/null +++ b/client/tsconfig.json @@ -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" + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be2e6fc --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..14a5a9b --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..822ea1c --- /dev/null +++ b/server/package.json @@ -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" + } +} \ No newline at end of file diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..4bde9ca --- /dev/null +++ b/server/src/index.ts @@ -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 = {}; + +// --- 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}`); +}); diff --git a/server/src/models/Game.ts b/server/src/models/Game.ts new file mode 100644 index 0000000..6c19a3d --- /dev/null +++ b/server/src/models/Game.ts @@ -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(); + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..f55c412 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": false, + "skipLibCheck": true + }, + "include": [ + "src/**/*", + "../shared/**/*" + ] +} \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..afd0db1 --- /dev/null +++ b/shared/types.ts @@ -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; + + // Selección de Equipo (Misión) + proposedTeam: string[]; + teamVotes: Record; // 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[]; +}