Compare commits

..

2 Commits

Author SHA1 Message Date
6248037b47 docs: añade documentación completa del proyecto
- README.md: descripción general, stack, instalación rápida
- docs/API.md: referencia completa de API REST y WebSocket
- docs/ARCHITECTURE.md: arquitectura del sistema con diagramas
- docs/INSTALLATION.md: guía detallada de instalación
- backend/.env.example: plantilla de configuración

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 23:50:34 +00:00
ab201e113a feat: 5 categorías rotativas por partida + pool de 200 preguntas + mejoras UI
Cambios principales:
- Tablero ahora muestra 5 categorías aleatorias (de 8 disponibles)
- Pool de 200 preguntas (8 cats × 5 diffs × 5 opciones)
- Preguntas rotan aleatoriamente entre partidas
- Diseño mejorado estilo Jeopardy con efectos visuales
- Socket singleton para conexión persistente
- Nuevos sonidos: game_start, player_join, question_reveal, hover, countdown
- Control de volumen vertical
- Barra de progreso del timer en modal de preguntas
- Animaciones mejoradas con Framer Motion

Backend:
- question_service: selección aleatoria de 5 categorías
- room_manager: fix retorno de create_room
- game_events: carga board desde DB, await en enter_room

Frontend:
- Game.tsx: tablero dinámico, efectos hover, mejor scoreboard
- useSocket: singleton service, eventos con sonidos
- SoundControl: slider vertical
- soundStore: 5 nuevos efectos de sonido

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 23:44:55 +00:00
13 changed files with 2355 additions and 264 deletions

344
README.md Normal file
View File

@@ -0,0 +1,344 @@
# Trivy - Trivia Multiplayer en Tiempo Real
<p align="center">
<img src="docs/logo.png" alt="Trivy Logo" width="200">
</p>
**Trivy** es un juego de trivia multijugador en tiempo real inspirado en Jeopardy. Dos equipos compiten respondiendo preguntas de diferentes categorías, con mecánicas de robo y validación de respuestas por IA.
## Características
- **Multijugador en tiempo real** - Hasta 8 jugadores (2 equipos × 4 jugadores)
- **8 categorías temáticas** - Nintendo, Xbox, PlayStation, Anime, Música, Películas, Libros, Historia
- **5 categorías por partida** - Rotación aleatoria entre partidas
- **Pool de 200 preguntas** - 5 opciones por cada categoría/dificultad
- **Validación de respuestas por IA** - Claude API para respuestas flexibles
- **Sistema de robo** - Oportunidad de robar puntos cuando el equipo contrario falla
- **Múltiples temas visuales** - DRRR, Retro Arcade, Minimal, Gaming RGB, Anime 90s
- **Efectos de sonido** - Sonidos personalizados por tema
- **Chat de equipo** - Comunicación privada entre compañeros
- **Reacciones emoji** - Expresiones en tiempo real
- **Panel de administración** - Gestión de preguntas, categorías y monitoreo
## Stack Tecnológico
### Backend
- **FastAPI** - Framework web async de alto rendimiento
- **Python-SocketIO** - WebSockets para comunicación en tiempo real
- **PostgreSQL** - Base de datos principal
- **Redis** - Estado de salas y sesiones en tiempo real
- **SQLAlchemy** - ORM async
- **Anthropic Claude API** - Validación inteligente de respuestas
### Frontend
- **React 18** - UI declarativa
- **TypeScript** - Tipado estático
- **Vite** - Build tool rápido
- **Zustand** - Estado global ligero
- **Framer Motion** - Animaciones fluidas
- **Tailwind CSS** - Estilos utilitarios
- **Socket.IO Client** - Comunicación WebSocket
- **Howler.js** - Gestión de audio
## Requisitos
- Python 3.11+
- Node.js 18+
- PostgreSQL 15+
- Redis 7+
- API Key de Anthropic (Claude)
## Instalación
### 1. Clonar repositorio
```bash
git clone https://gitea.local/frank/Trivy.git
cd Trivy
```
### 2. Configurar Backend
```bash
cd backend
# Crear entorno virtual
python -m venv venv
source venv/bin/activate # Linux/Mac
# o: venv\Scripts\activate # Windows
# Instalar dependencias
pip install -r requirements.txt
# Configurar variables de entorno
cp .env.example .env
# Editar .env con tus credenciales
```
### 3. Configurar Frontend
```bash
cd frontend
# Instalar dependencias
npm install
# Configurar variables de entorno
cp .env.example .env
# Editar .env con la URL del backend
```
### 4. Base de datos
```bash
# Con Docker
docker-compose up -d db redis
# O configurar PostgreSQL y Redis manualmente
# Crear base de datos: trivy
```
### 5. Migraciones
```bash
cd backend
alembic upgrade head
```
### 6. Datos iniciales
```bash
# Crear usuario admin
python -c "
from app.models.base import get_async_session
from app.models.admin import Admin
import asyncio
async def create_admin():
async with get_async_session()() as db:
admin = Admin(username='admin')
admin.set_password('admin123')
db.add(admin)
await db.commit()
asyncio.run(create_admin())
"
# Crear categorías y preguntas iniciales
python scripts/seed_data.py
```
## Ejecución
### Desarrollo
```bash
# Terminal 1 - Backend
cd backend
source venv/bin/activate
uvicorn app.main:socket_app --host 0.0.0.0 --port 8000 --reload
# Terminal 2 - Frontend
cd frontend
npm run dev -- --host
```
### Producción
```bash
# Backend con Gunicorn
gunicorn app.main:socket_app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000
# Frontend build
cd frontend
npm run build
# Servir dist/ con nginx o similar
```
## Configuración
### Variables de entorno - Backend (.env)
```env
# Base de datos
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/trivy
# Redis
REDIS_URL=redis://localhost:6379
# Anthropic API
ANTHROPIC_API_KEY=sk-ant-...
# Configuración del juego
STEAL_PENALTY_MULTIPLIER=0.5
STEAL_TIME_MULTIPLIER=0.5
ROOM_TTL_HOURS=3
```
### Variables de entorno - Frontend (.env)
```env
VITE_API_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
```
## Arquitectura
```
Trivy/
├── backend/
│ ├── app/
│ │ ├── api/ # Endpoints REST
│ │ │ ├── admin.py # Panel de administración
│ │ │ ├── auth.py # Autenticación
│ │ │ └── game.py # Datos del juego
│ │ ├── models/ # Modelos SQLAlchemy
│ │ ├── schemas/ # Schemas Pydantic
│ │ ├── services/ # Lógica de negocio
│ │ │ ├── room_manager.py # Gestión de salas (Redis)
│ │ │ ├── game_manager.py # Lógica del juego
│ │ │ ├── ai_validator.py # Validación con Claude
│ │ │ └── question_service.py # Carga de preguntas
│ │ ├── sockets/ # Eventos WebSocket
│ │ │ └── game_events.py
│ │ ├── config.py # Configuración
│ │ └── main.py # Aplicación ASGI
│ ├── alembic/ # Migraciones
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── components/ # Componentes React
│ │ │ ├── chat/ # Chat y reacciones
│ │ │ ├── game/ # Componentes del juego
│ │ │ ├── lobby/ # Sala de espera
│ │ │ └── ui/ # Componentes UI genéricos
│ │ ├── hooks/ # Custom hooks
│ │ │ ├── useSocket.ts
│ │ │ └── useSound.ts
│ │ ├── pages/ # Páginas/rutas
│ │ ├── services/ # Servicios (socket)
│ │ ├── stores/ # Estado Zustand
│ │ ├── themes/ # Temas visuales
│ │ └── types/ # Tipos TypeScript
│ ├── public/
│ │ └── sounds/ # Archivos de audio
│ └── package.json
└── docs/ # Documentación
```
## Flujo del Juego
```
1. CREAR SALA
Host → create_room → Código de 6 caracteres
2. UNIRSE
Jugadores → join_room(código) → Asignación a equipo A/B
3. INICIAR
Host → start_game → Backend carga 5 categorías aleatorias
4. TURNO
Jugador actual → Selecciona pregunta del tablero
5. RESPONDER
30 segundos para escribir respuesta
Claude valida si es correcta
6. ROBO (si falla)
Equipo contrario puede intentar robar
Si falla el robo, pierde puntos
7. SIGUIENTE TURNO
Rotación de jugadores y equipos
8. FIN
Cuando todas las preguntas están contestadas
Equipo con más puntos gana
```
## API REST
### Autenticación
- `POST /api/auth/login` - Login admin
- `POST /api/auth/logout` - Logout
### Juego
- `GET /api/game/categories` - Lista de categorías
- `GET /api/game/today-questions` - Preguntas del día
### Admin
- `GET /api/admin/questions` - Listar preguntas
- `POST /api/admin/questions` - Crear pregunta
- `PUT /api/admin/questions/{id}` - Actualizar pregunta
- `DELETE /api/admin/questions/{id}` - Eliminar pregunta
- `GET /api/admin/categories` - Listar categorías
- `GET /api/admin/rooms/active` - Salas activas
## Eventos WebSocket
### Cliente → Servidor
- `create_room` - Crear nueva sala
- `join_room` - Unirse a sala existente
- `start_game` - Iniciar partida (solo host)
- `select_question` - Seleccionar pregunta
- `submit_answer` - Enviar respuesta
- `steal_decision` - Decidir si robar
- `chat_message` - Mensaje de chat
- `team_message` - Mensaje de equipo
- `send_reaction` - Enviar emoji
### Servidor → Cliente
- `room_created` - Sala creada
- `player_joined` - Jugador se unió
- `game_started` - Juego iniciado
- `question_selected` - Pregunta seleccionada
- `answer_result` - Resultado de respuesta
- `steal_prompt` - Oportunidad de robo
- `game_finished` - Juego terminado
- `error` - Error
## Temas Disponibles
| Tema | Descripción |
|------|-------------|
| DRRR | Inspirado en Durarara!! - Oscuro con acentos amarillos |
| Retro Arcade | Estética arcade 80s - Neón sobre negro |
| Minimal | Diseño limpio y moderno - Blanco con acentos |
| Gaming RGB | Gradientes RGB animados - Estilo gamer |
| Anime 90s | Colores pasteles - Nostalgia anime |
## Categorías
1. **Nintendo** 🍄 - Mario, Zelda, Pokémon, etc.
2. **Xbox** 🎮 - Halo, Forza, Game Pass, etc.
3. **PlayStation** 🎯 - God of War, Uncharted, etc.
4. **Anime** ⛩️ - Naruto, One Piece, Dragon Ball, etc.
5. **Música** 🎵 - Artistas, géneros, historia musical
6. **Películas** 🎬 - Cine clásico y moderno
7. **Libros** 📚 - Literatura universal
8. **Historia** 🏛️ - Historia y cultura general
## Contribuir
1. Fork el repositorio
2. Crear rama feature (`git checkout -b feature/nueva-funcionalidad`)
3. Commit cambios (`git commit -m 'feat: descripción'`)
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
5. Crear Pull Request
## Licencia
MIT License - ver [LICENSE](LICENSE)
## Créditos
Desarrollado por Frank con asistencia de Claude (Anthropic)
---
<p align="center">
<strong>¡Que gane el mejor equipo!</strong> 🏆
</p>

45
backend/.env.example Normal file
View File

@@ -0,0 +1,45 @@
# ===========================================
# Trivy Backend - Configuration
# ===========================================
# Database
DATABASE_URL=postgresql+asyncpg://trivy:trivy@localhost:5432/trivy
# Redis
REDIS_URL=redis://localhost:6379
# Anthropic API (Required for answer validation)
ANTHROPIC_API_KEY=sk-ant-api03-YOUR_API_KEY_HERE
# ===========================================
# Game Settings
# ===========================================
# Penalty multiplier when failing a steal (0.5 = lose 50% of question points)
STEAL_PENALTY_MULTIPLIER=0.5
# Time multiplier for steal attempts (0.5 = half the original time)
STEAL_TIME_MULTIPLIER=0.5
# How long rooms stay in Redis (hours)
ROOM_TTL_HOURS=3
# ===========================================
# Security
# ===========================================
# CORS origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Secret key for sessions (generate with: openssl rand -hex 32)
SECRET_KEY=your-secret-key-here
# ===========================================
# Optional
# ===========================================
# Log level (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# Enable SQL echo (for debugging)
SQL_ECHO=false

View File

@@ -1,11 +1,15 @@
from typing import Optional, List, Dict from typing import Optional, List, Dict
from datetime import date from datetime import date
from sqlalchemy import select, and_ import random
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.question import Question from app.models.question import Question
from app.models.category import Category from app.models.category import Category
# Number of categories per game
CATEGORIES_PER_GAME = 5
class QuestionService: class QuestionService:
async def get_daily_questions( async def get_daily_questions(
@@ -64,16 +68,49 @@ class QuestionService:
target_date: Optional[date] = None target_date: Optional[date] = None
) -> Dict[str, List[dict]]: ) -> Dict[str, List[dict]]:
""" """
Genera el tablero 8×5 para el juego. Genera el tablero 5×5 para el juego.
Si no hay suficientes preguntas, retorna lo disponible. Selecciona 5 categorías aleatorias y 1 pregunta por dificultad.
Returns: Returns:
Dict con category_id como string (para JSON) -> lista de preguntas Dict con category_id como string (para JSON) -> lista de preguntas
""" """
board = await self.get_daily_questions(db, target_date) full_board = await self.get_daily_questions(db, target_date)
# Convertir keys a string para JSON if not full_board:
return {str(k): v for k, v in board.items()} return {}
# Get available category IDs that have questions
available_categories = list(full_board.keys())
# Select random categories (up to CATEGORIES_PER_GAME)
num_categories = min(CATEGORIES_PER_GAME, len(available_categories))
selected_categories = random.sample(available_categories, num_categories)
# Build the game board with selected categories
game_board: Dict[str, List[dict]] = {}
for cat_id in selected_categories:
questions_by_difficulty: Dict[int, List[dict]] = {}
# Group questions by difficulty
for q in full_board[cat_id]:
diff = q["difficulty"]
if diff not in questions_by_difficulty:
questions_by_difficulty[diff] = []
questions_by_difficulty[diff].append(q)
# Select one random question per difficulty
selected_questions = []
for difficulty in range(1, 6): # 1-5
if difficulty in questions_by_difficulty:
questions = questions_by_difficulty[difficulty]
selected_q = random.choice(questions)
selected_questions.append(selected_q)
if selected_questions:
game_board[str(cat_id)] = selected_questions
return game_board
async def get_question_by_id( async def get_question_by_id(
self, self,

View File

@@ -59,9 +59,9 @@ class RoomManager:
) )
# Add player to room # Add player to room
await self.add_player(room_code, player_name, "A", socket_id) room = await self.add_player(room_code, player_name, "A", socket_id)
return room_state return room
async def get_room(self, room_code: str) -> Optional[dict]: async def get_room(self, room_code: str) -> Optional[dict]:
"""Get room state by code.""" """Get room state by code."""

View File

@@ -50,7 +50,7 @@ def register_socket_events(sio: socketio.AsyncServer):
await room_manager.init_player_stats(room["code"], player_name) await room_manager.init_player_stats(room["code"], player_name)
# Join socket room # Join socket room
sio.enter_room(sid, room["code"]) await sio.enter_room(sid, room["code"])
await sio.emit("room_created", {"room": room}, to=sid) await sio.emit("room_created", {"room": room}, to=sid)
@@ -75,7 +75,7 @@ def register_socket_events(sio: socketio.AsyncServer):
await room_manager.init_player_stats(room_code, player_name) await room_manager.init_player_stats(room_code, player_name)
# Join socket room # Join socket room
sio.enter_room(sid, room_code) await sio.enter_room(sid, room_code)
# Notify all players # Notify all players
await sio.emit("player_joined", {"room": room}, room=room_code) await sio.emit("player_joined", {"room": room}, room=room_code)
@@ -147,13 +147,18 @@ def register_socket_events(sio: socketio.AsyncServer):
) )
return return
# Get board from data or generate # Load board from database and start game
board = data.get("board", {}) async with await get_db_session() as db:
updated_room = await game_manager.start_game_with_db(db, room_code)
updated_room = await game_manager.start_game(room_code, board)
if updated_room: if updated_room:
await sio.emit("game_started", {"room": updated_room}, room=room_code) await sio.emit("game_started", {"room": updated_room}, room=room_code)
else:
await sio.emit(
"error",
{"message": "No hay preguntas disponibles para hoy. Contacta al administrador."},
to=sid
)
@sio.event @sio.event
async def select_question(sid, data): async def select_question(sid, data):

492
docs/API.md Normal file
View File

@@ -0,0 +1,492 @@
# API Reference - Trivy
## Base URL
```
http://localhost:8000/api
```
## Autenticación
El panel de administración usa autenticación básica HTTP.
```bash
# Ejemplo con curl
curl -u admin:admin123 http://localhost:8000/api/admin/questions
```
---
## Endpoints REST
### Game
#### GET /game/categories
Obtiene todas las categorías disponibles.
**Response:**
```json
[
{
"id": 1,
"name": "Nintendo",
"icon": "🍄",
"color": "#E60012"
},
{
"id": 2,
"name": "Xbox",
"icon": "🎮",
"color": "#107C10"
}
]
```
#### GET /game/today-questions
Obtiene las preguntas activas para hoy (sin respuestas).
**Response:**
```json
{
"1": [
{
"id": 1,
"category_id": 1,
"question_text": "¿En qué año se lanzó la NES?",
"difficulty": 1,
"points": 100,
"time_seconds": 15
}
]
}
```
---
### Admin - Preguntas
#### GET /admin/questions
Lista todas las preguntas.
**Query Parameters:**
- `status` (optional): `pending`, `approved`, `used`
- `category_id` (optional): ID de categoría
**Response:**
```json
[
{
"id": 1,
"category_id": 1,
"question_text": "¿En qué año se lanzó la NES?",
"correct_answer": "1983",
"alt_answers": ["1984"],
"difficulty": 1,
"points": 100,
"time_seconds": 15,
"date_active": "2024-01-26",
"status": "approved",
"fun_fact": "La NES salvó la industria del videojuego",
"created_at": "2024-01-25T10:00:00Z"
}
]
```
#### POST /admin/questions
Crea una nueva pregunta.
**Request Body:**
```json
{
"category_id": 1,
"question_text": "¿Cuál es el nombre del fontanero de Nintendo?",
"correct_answer": "Mario",
"alt_answers": ["Super Mario"],
"difficulty": 1,
"points": 100,
"time_seconds": 15,
"date_active": "2024-01-26",
"status": "approved",
"fun_fact": "Mario apareció por primera vez en Donkey Kong"
}
```
**Response:** `201 Created`
#### PUT /admin/questions/{id}
Actualiza una pregunta existente.
#### DELETE /admin/questions/{id}
Elimina una pregunta.
---
### Admin - Categorías
#### GET /admin/categories
Lista todas las categorías.
#### POST /admin/categories
Crea una nueva categoría.
**Request Body:**
```json
{
"name": "Ciencia",
"icon": "🔬",
"color": "#00BCD4"
}
```
---
### Admin - Salas
#### GET /admin/rooms/active
Obtiene las salas activas en Redis.
**Response:**
```json
[
{
"code": "ABC123",
"status": "playing",
"host": "Player1",
"teams": {
"A": [{"name": "Player1", "team": "A"}],
"B": [{"name": "Player2", "team": "B"}]
},
"scores": {"A": 500, "B": 300},
"current_team": "A"
}
]
```
---
## WebSocket Events
### Conexión
```javascript
import { io } from 'socket.io-client';
const socket = io('http://localhost:8000', {
transports: ['websocket', 'polling']
});
```
---
### Eventos Cliente → Servidor
#### create_room
Crea una nueva sala de juego.
```javascript
socket.emit('create_room', {
player_name: 'MiNombre'
});
```
#### join_room
Unirse a una sala existente.
```javascript
socket.emit('join_room', {
room_code: 'ABC123',
player_name: 'MiNombre',
team: 'A' // o 'B'
});
```
#### change_team
Cambiar de equipo en el lobby.
```javascript
socket.emit('change_team', {
team: 'B'
});
```
#### start_game
Iniciar el juego (solo host).
```javascript
socket.emit('start_game', {});
```
#### select_question
Seleccionar una pregunta del tablero.
```javascript
socket.emit('select_question', {
question_id: 1,
category_id: 1
});
```
#### submit_answer
Enviar respuesta a la pregunta actual.
```javascript
socket.emit('submit_answer', {
answer: 'Mi respuesta',
question: { /* objeto pregunta */ },
is_steal: false
});
```
#### steal_decision
Decidir si intentar robar.
```javascript
socket.emit('steal_decision', {
attempt: true, // o false para pasar
question_id: 1,
answer: 'Mi respuesta' // solo si attempt=true
});
```
#### chat_message
Enviar mensaje al chat general.
```javascript
socket.emit('chat_message', {
message: 'Hola a todos!'
});
```
#### team_message
Enviar mensaje al chat de equipo.
```javascript
socket.emit('team_message', {
room_code: 'ABC123',
team: 'A',
player_name: 'MiNombre',
message: 'Mensaje privado del equipo'
});
```
#### send_reaction
Enviar reacción emoji.
```javascript
socket.emit('send_reaction', {
emoji: '🎉',
room_code: 'ABC123',
player_name: 'MiNombre'
});
```
---
### Eventos Servidor → Cliente
#### room_created
Confirmación de sala creada.
```javascript
socket.on('room_created', (data) => {
console.log(data.room.code); // 'ABC123'
});
```
**Payload:**
```json
{
"room": {
"code": "ABC123",
"status": "waiting",
"host": "Player1",
"teams": {"A": [...], "B": []},
"scores": {"A": 0, "B": 0}
}
}
```
#### player_joined
Un jugador se unió a la sala.
```javascript
socket.on('player_joined', (data) => {
console.log('Nuevo jugador:', data.room.teams);
});
```
#### player_left
Un jugador abandonó la sala.
```javascript
socket.on('player_left', (data) => {
console.log('Jugador salió');
});
```
#### game_started
El juego ha comenzado.
```javascript
socket.on('game_started', (data) => {
// data.room.board contiene el tablero con las preguntas
console.log('Categorías:', Object.keys(data.room.board));
});
```
**Payload:**
```json
{
"room": {
"status": "playing",
"board": {
"1": [
{
"id": 1,
"question_text": "...",
"difficulty": 1,
"points": 100,
"answered": false
}
]
},
"current_team": "A",
"current_player_index": {"A": 0, "B": 0}
}
}
```
#### question_selected
Se seleccionó una pregunta.
```javascript
socket.on('question_selected', (data) => {
console.log('Pregunta:', data.question_id);
});
```
#### answer_result
Resultado de una respuesta.
```javascript
socket.on('answer_result', (data) => {
if (data.valid) {
console.log('¡Correcto! +', data.points_earned);
} else {
console.log('Incorrecto:', data.reason);
}
});
```
**Payload:**
```json
{
"valid": true,
"reason": "Respuesta correcta",
"points_earned": 100,
"was_steal": false,
"room": { /* estado actualizado */ }
}
```
#### steal_prompt
Oportunidad de robo disponible.
```javascript
socket.on('steal_prompt', (data) => {
console.log('¡Puedes robar!');
});
```
#### game_finished
El juego ha terminado.
```javascript
socket.on('game_finished', (data) => {
console.log('Ganador:', data.winner);
console.log('Puntuación final:', data.room.scores);
});
```
#### new_reaction
Nueva reacción de un jugador.
```javascript
socket.on('new_reaction', (data) => {
console.log(data.player_name, 'reaccionó con', data.emoji);
});
```
#### team_message
Mensaje de chat de equipo.
```javascript
socket.on('team_message', (data) => {
console.log(`[Equipo] ${data.player_name}: ${data.message}`);
});
```
#### error
Error del servidor.
```javascript
socket.on('error', (data) => {
console.error('Error:', data.message);
});
```
---
## Códigos de Error
| Código | Mensaje | Descripción |
|--------|---------|-------------|
| `room_not_found` | Room not found | La sala no existe |
| `room_full` | Room is full | La sala está llena |
| `not_host` | Only the host can start | Solo el host puede iniciar |
| `need_players` | Both teams need players | Ambos equipos necesitan jugadores |
| `not_your_turn` | Not your turn | No es tu turno |
| `question_answered` | Question already answered | Pregunta ya contestada |
---
## Rate Limiting
- API REST: 100 requests/minuto por IP
- WebSocket: Sin límite específico (controlado por lógica de juego)
## Timeouts
- Preguntas: 15-35 segundos según dificultad
- Robo: 50% del tiempo original
- Sala inactiva: 3 horas (configurable)

374
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,374 @@
# Arquitectura de Trivy
## Visión General
Trivy utiliza una arquitectura de microservicios con comunicación en tiempo real mediante WebSockets.
```
┌─────────────────────────────────────────────────────────────────────┐
│ CLIENTES │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Browser │ │ Browser │ │ Browser │ │ Browser │ │
│ │ React App│ │ React App│ │ React App│ │ React App│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └─────────────┴──────┬──────┴─────────────┘ │
│ │ │
│ WebSocket/HTTP │
└────────────────────────────┼─────────────────────────────────────────┘
┌────────────────────────────┼─────────────────────────────────────────┐
│ BACKEND │
│ │ │
│ ┌─────────────────────────▼─────────────────────────────┐ │
│ │ FastAPI + Socket.IO │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ REST API │ │ WebSocket │ │ ASGI │ │ │
│ │ │ Endpoints │ │ Events │ │ Middleware │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └─────────────┘ │ │
│ └─────────┼────────────────┼────────────────────────────┘ │
│ │ │ │
│ ┌─────────▼────────────────▼────────────────────────────┐ │
│ │ SERVICES │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Room │ │ Game │ │ AI │ │ │
│ │ │ Manager │ │ Manager │ │ Validator │ │ │
│ │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │
│ └────────┼───────────────┼───────────────┼──────────────┘ │
│ │ │ │ │
│ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ Redis │ │ PostgreSQL│ │ Claude │ │
│ │ (State) │ │ (Data) │ │ API │ │
│ └───────────┘ └───────────┘ └───────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
## Componentes
### Frontend (React + TypeScript)
```
frontend/src/
├── components/ # Componentes reutilizables
│ ├── chat/ # EmojiReactions, ReactionOverlay, TeamChat
│ ├── game/ # Componentes del tablero
│ ├── lobby/ # Sala de espera
│ └── ui/ # SoundControl, botones, inputs
├── hooks/ # Custom hooks
│ ├── useSocket.ts # Gestión de WebSocket
│ └── useSound.ts # Gestión de audio
├── pages/ # Rutas principales
│ ├── Home.tsx # Crear/unir sala
│ ├── Lobby.tsx # Sala de espera
│ ├── Game.tsx # Tablero de juego
│ ├── Results.tsx # Resultados finales
│ └── admin/ # Panel de administración
├── services/
│ └── socket.ts # Singleton de Socket.IO
├── stores/ # Estado global (Zustand)
│ ├── gameStore.ts # Estado del juego
│ ├── themeStore.ts # Tema visual
│ └── soundStore.ts # Configuración de audio
├── themes/ # Definiciones de temas
└── types/ # Tipos TypeScript
```
### Backend (FastAPI + Python)
```
backend/app/
├── api/ # Endpoints REST
│ ├── admin.py # CRUD de preguntas/categorías
│ ├── auth.py # Autenticación admin
│ └── game.py # Datos públicos del juego
├── models/ # Modelos SQLAlchemy
│ ├── question.py # Preguntas
│ ├── category.py # Categorías
│ ├── admin.py # Administradores
│ └── game_session.py # Sesiones de juego
├── schemas/ # Schemas Pydantic
├── services/ # Lógica de negocio
│ ├── room_manager.py # Gestión de salas (Redis)
│ ├── game_manager.py # Lógica del juego
│ ├── question_service.py # Carga de preguntas
│ └── ai_validator.py # Validación con Claude
├── sockets/ # Eventos WebSocket
│ └── game_events.py # Todos los eventos del juego
├── config.py # Configuración
└── main.py # Punto de entrada ASGI
```
## Flujo de Datos
### 1. Creación de Sala
```
Cliente Backend Redis
│ │ │
│──── create_room ────────>│ │
│ │ │
│ │──── SETEX room:CODE ────>│
│ │ │
│ │<─── OK ─────────────────│
│ │ │
│<─── room_created ────────│ │
│ │ │
```
### 2. Unirse a Sala
```
Cliente Backend Redis
│ │ │
│──── join_room ──────────>│ │
│ │ │
│ │──── GET room:CODE ──────>│
│ │<─── room_data ──────────│
│ │ │
│ │──── SETEX (updated) ────>│
│ │ │
│<─── player_joined ───────│ │
│ │ │
│ │──── emit to room ───────>│ (otros clientes)
```
### 3. Inicio del Juego
```
Cliente Backend PostgreSQL
│ │ │
│──── start_game ─────────>│ │
│ │ │
│ │──── SELECT questions ───>│
│ │ (5 random cats) │
│ │<─── questions ──────────│
│ │ │
│ │──── SELECT 1 per diff ──>│
│ │<─── board data ─────────│
│ │ │
│<─── game_started ────────│ │
│ (with board) │ │
```
### 4. Responder Pregunta
```
Cliente Backend Claude API
│ │ │
│──── submit_answer ──────>│ │
│ │ │
│ │──── validate_answer ────>│
│ │ (question, answer) │
│ │ │
│ │<─── {valid, reason} ────│
│ │ │
│<─── answer_result ───────│ │
│ │ │
```
## Estado del Juego
### Redis (Estado en Tiempo Real)
```json
{
"room:ABC123": {
"code": "ABC123",
"status": "playing",
"host": "Player1",
"teams": {
"A": [{"name": "Player1", "team": "A", "socket_id": "..."}],
"B": [{"name": "Player2", "team": "B", "socket_id": "..."}]
},
"current_team": "A",
"current_player_index": {"A": 0, "B": 0},
"current_question": null,
"can_steal": false,
"scores": {"A": 500, "B": 300},
"board": {
"1": [/* preguntas categoría 1 */],
"3": [/* preguntas categoría 3 */]
}
},
"player:socket_id": {
"room": "ABC123",
"name": "Player1",
"team": "A"
}
}
```
### PostgreSQL (Datos Persistentes)
```sql
-- Categorías
categories (id, name, icon, color)
-- Preguntas (pool de 200)
questions (
id, category_id, question_text, correct_answer,
alt_answers[], difficulty, points, time_seconds,
date_active, status, fun_fact
)
-- Sesiones de juego (historial)
game_sessions (
id, room_code, status,
team_a_score, team_b_score,
questions_used[], created_at, finished_at
)
-- Eventos de juego (analytics)
game_events (
id, session_id, event_type, player_name,
question_id, data, created_at
)
-- Administradores
admins (id, username, password_hash)
```
## Validación de Respuestas con IA
El sistema usa Claude para validación flexible de respuestas:
```python
# ai_validator.py
async def validate_answer(
question: str,
correct_answer: str,
alt_answers: list,
player_answer: str
) -> dict:
prompt = f"""
Pregunta: {question}
Respuesta correcta: {correct_answer}
Respuestas alternativas: {alt_answers}
Respuesta del jugador: {player_answer}
¿La respuesta del jugador es correcta?
Considera sinónimos, abreviaciones, errores menores de ortografía.
Responde en JSON: {{"valid": bool, "reason": "explicación"}}
"""
response = await anthropic.messages.create(
model="claude-3-haiku-20240307",
messages=[{"role": "user", "content": prompt}]
)
return json.loads(response.content[0].text)
```
## Selección de Preguntas
Cada partida selecciona aleatoriamente:
1. **5 categorías** de las 8 disponibles
2. **1 pregunta por dificultad** de las 5 opciones disponibles
```python
# question_service.py
async def get_board_for_game(db, target_date):
# 1. Obtener todas las preguntas del día
full_board = await get_daily_questions(db, target_date)
# 2. Seleccionar 5 categorías aleatorias
selected_cats = random.sample(list(full_board.keys()), 5)
# 3. Para cada categoría, seleccionar 1 pregunta por dificultad
game_board = {}
for cat_id in selected_cats:
questions_by_diff = group_by_difficulty(full_board[cat_id])
selected = []
for diff in range(1, 6):
if diff in questions_by_diff:
selected.append(random.choice(questions_by_diff[diff]))
game_board[cat_id] = selected
return game_board
```
## Temas y Sonidos
### Estructura de Temas
```typescript
// themes/drrr.ts
export const drrrTheme: ThemeConfig = {
name: 'drrr',
displayName: 'DRRR Style',
colors: {
primary: '#FFD93D',
secondary: '#6BCB77',
accent: '#FF6B6B',
bg: '#1a1a2e',
text: '#EAEAEA',
textMuted: '#888888'
},
fonts: {
heading: '"Press Start 2P", monospace',
body: 'Inter, sans-serif'
}
}
```
### Estructura de Sonidos
```
public/sounds/
├── drrr/
│ ├── correct.mp3
│ ├── incorrect.mp3
│ ├── select.mp3
│ └── ...
├── retro/
├── minimal/
├── rgb/
└── anime/
```
Cada tema tiene sus propios archivos de audio, con fallback a sonidos generados por Web Audio API si no existen.
## Escalabilidad
### Horizontal Scaling
```
┌─────────────┐
│ NGINX │
│ (LB) │
└──────┬──────┘
┌─────────────────┼─────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Backend │ │ Backend │ │ Backend │
│ Node 1 │ │ Node 2 │ │ Node 3 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└────────────────┼────────────────┘
┌───────────┴───────────┐
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ Redis │ │ PostgreSQL│
│ Cluster │ │ Primary │
└───────────┘ └───────────┘
```
### Consideraciones
1. **Socket.IO con Redis Adapter** - Para sincronizar eventos entre múltiples instancias
2. **Sticky Sessions** - Para mantener conexiones WebSocket
3. **PostgreSQL Read Replicas** - Para queries de lectura
4. **Redis Cluster** - Para alta disponibilidad del estado
## Seguridad
1. **CORS** - Configurado para dominios permitidos
2. **Rate Limiting** - En endpoints REST
3. **Input Validation** - Pydantic schemas
4. **SQL Injection** - Prevenido por SQLAlchemy ORM
5. **XSS** - React escapa contenido por defecto
6. **WebSocket Auth** - Validación de sala/jugador en cada evento

469
docs/INSTALLATION.md Normal file
View File

@@ -0,0 +1,469 @@
# Guía de Instalación - Trivy
## Requisitos Previos
### Software Requerido
| Software | Versión Mínima | Notas |
|----------|----------------|-------|
| Python | 3.11+ | Con pip |
| Node.js | 18+ | Con npm |
| PostgreSQL | 15+ | O Docker |
| Redis | 7+ | O Docker |
| Git | 2.0+ | Para clonar |
### API Keys
- **Anthropic Claude API** - Obtener en https://console.anthropic.com
## Opción 1: Instalación con Docker (Recomendado)
### 1. Clonar repositorio
```bash
git clone https://gitea.local/frank/Trivy.git
cd Trivy
```
### 2. Configurar variables de entorno
```bash
# Backend
cp backend/.env.example backend/.env
# Editar backend/.env:
DATABASE_URL=postgresql+asyncpg://trivy:trivy@db:5432/trivy
REDIS_URL=redis://redis:6379
ANTHROPIC_API_KEY=sk-ant-api03-YOUR_KEY_HERE
# Frontend
cp frontend/.env.example frontend/.env
# Editar frontend/.env:
VITE_API_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
```
### 3. Iniciar con Docker Compose
```bash
docker-compose up -d
```
Esto iniciará:
- PostgreSQL en puerto 5432
- Redis en puerto 6379
- Backend en puerto 8000
- Frontend en puerto 3000
### 4. Ejecutar migraciones
```bash
docker-compose exec backend alembic upgrade head
```
### 5. Crear datos iniciales
```bash
docker-compose exec backend python scripts/seed_data.py
```
### 6. Acceder
- Frontend: http://localhost:3000
- Admin: http://localhost:3000/admin (admin/admin123)
- API: http://localhost:8000/docs
---
## Opción 2: Instalación Manual
### 1. Clonar repositorio
```bash
git clone https://gitea.local/frank/Trivy.git
cd Trivy
```
### 2. Configurar PostgreSQL
```bash
# Crear usuario y base de datos
sudo -u postgres psql
CREATE USER trivy WITH PASSWORD 'trivy';
CREATE DATABASE trivy OWNER trivy;
GRANT ALL PRIVILEGES ON DATABASE trivy TO trivy;
\q
```
### 3. Configurar Redis
```bash
# Ubuntu/Debian
sudo apt install redis-server
sudo systemctl start redis
sudo systemctl enable redis
# Verificar
redis-cli ping
# Respuesta: PONG
```
### 4. Configurar Backend
```bash
cd backend
# Crear entorno virtual
python3 -m venv venv
source venv/bin/activate
# Instalar dependencias
pip install -r requirements.txt
# Configurar variables de entorno
cp .env.example .env
```
Editar `backend/.env`:
```env
# Base de datos
DATABASE_URL=postgresql+asyncpg://trivy:trivy@localhost:5432/trivy
# Redis
REDIS_URL=redis://localhost:6379
# Anthropic API
ANTHROPIC_API_KEY=sk-ant-api03-YOUR_KEY_HERE
# Configuración del juego
STEAL_PENALTY_MULTIPLIER=0.5
STEAL_TIME_MULTIPLIER=0.5
ROOM_TTL_HOURS=3
# CORS (separar con comas)
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
```
### 5. Ejecutar migraciones
```bash
cd backend
source venv/bin/activate
alembic upgrade head
```
### 6. Crear usuario admin
```bash
python -c "
import asyncio
from app.models.base import get_async_session
from app.models.admin import Admin
async def create_admin():
AsyncSession = get_async_session()
async with AsyncSession() as db:
admin = Admin(username='admin')
admin.set_password('admin123')
db.add(admin)
await db.commit()
print('Admin creado: admin/admin123')
asyncio.run(create_admin())
"
```
### 7. Crear categorías
```bash
python -c "
import asyncio
from app.models.base import get_async_session
from app.models.category import Category
categories = [
('Nintendo', '🍄', '#E60012'),
('Xbox', '🎮', '#107C10'),
('PlayStation', '🎯', '#003791'),
('Anime', '⛩️', '#FF6B9D'),
('Música', '🎵', '#1DB954'),
('Películas', '🎬', '#F5C518'),
('Libros', '📚', '#8B4513'),
('Historia', '🏛️', '#6B5B95'),
]
async def create_categories():
AsyncSession = get_async_session()
async with AsyncSession() as db:
for name, icon, color in categories:
cat = Category(name=name, icon=icon, color=color)
db.add(cat)
await db.commit()
print(f'{len(categories)} categorías creadas')
asyncio.run(create_categories())
"
```
### 8. Crear preguntas (ejecutar script completo)
```bash
python scripts/seed_questions.py
```
### 9. Configurar Frontend
```bash
cd frontend
# Instalar dependencias
npm install
# Configurar variables de entorno
cp .env.example .env
```
Editar `frontend/.env`:
```env
VITE_API_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
```
### 10. Iniciar servicios
**Terminal 1 - Backend:**
```bash
cd backend
source venv/bin/activate
uvicorn app.main:socket_app --host 0.0.0.0 --port 8000 --reload
```
**Terminal 2 - Frontend:**
```bash
cd frontend
npm run dev -- --host
```
### 11. Acceder
- Frontend: http://localhost:3000
- Admin: http://localhost:3000/admin
- API Docs: http://localhost:8000/docs
---
## Configuración para Red Local
Para permitir acceso desde otros dispositivos en la red:
### 1. Obtener IP local
```bash
ip addr show | grep "inet " | grep -v 127.0.0.1
# Ejemplo: 192.168.1.100
```
### 2. Actualizar configuración
**backend/.env:**
```env
CORS_ORIGINS=http://localhost:3000,http://192.168.1.100:3000
```
**frontend/.env:**
```env
VITE_API_URL=http://192.168.1.100:8000
VITE_WS_URL=http://192.168.1.100:8000
```
### 3. Iniciar con host 0.0.0.0
```bash
# Backend
uvicorn app.main:socket_app --host 0.0.0.0 --port 8000
# Frontend
npm run dev -- --host
```
### 4. Acceder desde otros dispositivos
- http://192.168.1.100:3000
---
## Producción
### Backend con Gunicorn
```bash
pip install gunicorn
gunicorn app.main:socket_app \
-w 4 \
-k uvicorn.workers.UvicornWorker \
-b 0.0.0.0:8000 \
--access-logfile - \
--error-logfile -
```
### Frontend Build
```bash
cd frontend
npm run build
# Servir con cualquier servidor estático
# Ejemplo con serve:
npm install -g serve
serve -s dist -l 3000
```
### Nginx como Reverse Proxy
```nginx
# /etc/nginx/sites-available/trivy
server {
listen 80;
server_name trivy.example.com;
# Frontend
location / {
root /var/www/trivy/frontend/dist;
try_files $uri $uri/ /index.html;
}
# API
location /api {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket
location /socket.io {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
### Systemd Service
```ini
# /etc/systemd/system/trivy-backend.service
[Unit]
Description=Trivy Backend
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/trivy/backend
Environment=PATH=/var/www/trivy/backend/venv/bin
ExecStart=/var/www/trivy/backend/venv/bin/gunicorn app.main:socket_app -w 4 -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable trivy-backend
sudo systemctl start trivy-backend
```
---
## Solución de Problemas
### Error: "Could not connect to Redis"
```bash
# Verificar Redis
redis-cli ping
# Si no responde, iniciar Redis
sudo systemctl start redis
```
### Error: "Database connection failed"
```bash
# Verificar PostgreSQL
sudo systemctl status postgresql
# Verificar credenciales
psql -U trivy -d trivy -h localhost
```
### Error: "CORS blocked"
Verificar que la URL del frontend esté en `CORS_ORIGINS` del backend.
### WebSocket no conecta
1. Verificar que `VITE_WS_URL` use `http://` (no `ws://`)
2. Verificar que el backend use `socket_app` (no `app`)
3. Verificar firewall/puertos
### Preguntas no cargan
```bash
# Verificar preguntas en BD
cd backend
source venv/bin/activate
python -c "
import asyncio
from sqlalchemy import select
from app.models.base import get_async_session
from app.models.question import Question
from datetime import date
async def check():
AsyncSession = get_async_session()
async with AsyncSession() as db:
result = await db.execute(
select(Question).where(Question.date_active == date.today())
)
qs = result.scalars().all()
print(f'Preguntas para hoy: {len(qs)}')
asyncio.run(check())
"
```
### Sonidos no funcionan
Los sonidos requieren archivos MP3 en `/public/sounds/{tema}/`. Si no existen, el sistema usa fallbacks generados por Web Audio API (tonos simples).
---
## Actualizaciones
```bash
cd Trivy
git pull origin main
# Backend
cd backend
source venv/bin/activate
pip install -r requirements.txt
alembic upgrade head
# Frontend
cd frontend
npm install
npm run build
# Reiniciar servicios
sudo systemctl restart trivy-backend
```

View File

@@ -7,15 +7,12 @@ import { useThemeStyles } from '../../themes/ThemeProvider'
interface SoundControlProps { interface SoundControlProps {
/** Compact mode shows just the icon, expanded shows slider */ /** Compact mode shows just the icon, expanded shows slider */
compact?: boolean compact?: boolean
/** Position for the popup menu when in compact mode */
popupPosition?: 'top' | 'bottom' | 'left' | 'right'
/** Custom class name */ /** Custom class name */
className?: string className?: string
} }
export default function SoundControl({ export default function SoundControl({
compact = false, compact = false,
popupPosition = 'top',
className = '', className = '',
}: SoundControlProps) { }: SoundControlProps) {
const { volume, muted, setVolume, toggleMute } = useSoundStore() const { volume, muted, setVolume, toggleMute } = useSoundStore()
@@ -84,19 +81,6 @@ export default function SoundControl({
) )
} }
const getPopupStyles = () => {
switch (popupPosition) {
case 'top':
return 'bottom-full mb-2 left-1/2 -translate-x-1/2'
case 'bottom':
return 'top-full mt-2 left-1/2 -translate-x-1/2'
case 'left':
return 'right-full mr-2 top-1/2 -translate-y-1/2'
case 'right':
return 'left-full ml-2 top-1/2 -translate-y-1/2'
}
}
if (!compact) { if (!compact) {
return ( return (
<div <div
@@ -158,32 +142,37 @@ export default function SoundControl({
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
className={`absolute ${getPopupStyles()} p-3 rounded-lg shadow-lg z-50`} className="absolute top-full mt-2 right-0 p-3 rounded-lg shadow-lg z-50"
style={{ style={{
backgroundColor: config.colors.bg, backgroundColor: config.colors.bg,
border: `1px solid ${config.colors.primary}`, border: `1px solid ${config.colors.primary}`,
}} }}
> >
<div className="flex flex-col items-center gap-2 min-w-[120px]"> <div className="flex flex-col items-center gap-2">
<button <button
onClick={handleToggleMute} onClick={handleToggleMute}
className="p-2 rounded-lg transition-colors hover:opacity-80" className="p-1 rounded-lg transition-colors hover:opacity-80"
style={{ color: config.colors.primary }} style={{ color: config.colors.primary }}
> >
{getVolumeIcon()} {getVolumeIcon()}
</button> </button>
<input {/* Vertical slider container */}
type="range" <div className="relative h-24 w-6 flex items-center justify-center">
min="0" <input
max="1" type="range"
step="0.05" min="0"
value={muted ? 0 : volume} max="1"
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))} step="0.05"
className="w-full h-2 rounded-lg appearance-none cursor-pointer" value={muted ? 0 : volume}
style={{ onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
background: `linear-gradient(to right, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`, className="h-20 w-2 rounded-lg appearance-none cursor-pointer"
}} style={{
/> writingMode: 'vertical-lr',
direction: 'rtl',
background: `linear-gradient(to top, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`,
}}
/>
</div>
<span <span
className="text-xs" className="text-xs"
style={{ color: config.colors.textMuted }} style={{ color: config.colors.textMuted }}

View File

@@ -1,14 +1,12 @@
import { useEffect, useRef, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import { io, Socket } from 'socket.io-client'
import { useGameStore } from '../stores/gameStore' import { useGameStore } from '../stores/gameStore'
import { soundPlayer } from './useSound' import { soundPlayer } from './useSound'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import { useSoundStore } from '../stores/soundStore' import { useSoundStore } from '../stores/soundStore'
import { socketService } from '../services/socket'
import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types' import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
import type { Reaction } from '../stores/gameStore' import type { Reaction } from '../stores/gameStore'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
// Team message type // Team message type
export interface TeamMessage { export interface TeamMessage {
player_name: string player_name: string
@@ -18,8 +16,7 @@ export interface TeamMessage {
} }
export function useSocket() { export function useSocket() {
const socketRef = useRef<Socket | null>(null) const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setGameResult, addReaction, addTeamMessage } =
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage } =
useGameStore() useGameStore()
// Initialize sound player with current theme // Initialize sound player with current theme
@@ -27,23 +24,16 @@ export function useSocket() {
soundPlayer.loadTheme(currentTheme) soundPlayer.loadTheme(currentTheme)
useEffect(() => { useEffect(() => {
// Create socket connection // Get singleton socket connection
socketRef.current = io(SOCKET_URL, { const socket = socketService.connect()
transports: ['websocket', 'polling'],
autoConnect: true,
})
const socket = socketRef.current // Only set up listeners once globally
if (socketService.isInitialized) {
// Connection events return // No cleanup - socket persists
socket.on('connect', () => { }
console.log('Connected to server') socketService.setInitialized()
})
socket.on('disconnect', () => {
console.log('Disconnected from server')
})
// Error handler
socket.on('error', (data: { message: string }) => { socket.on('error', (data: { message: string }) => {
console.error('Socket error:', data.message) console.error('Socket error:', data.message)
}) })
@@ -55,6 +45,9 @@ export function useSocket() {
socket.on('player_joined', (data: { room: GameRoom }) => { socket.on('player_joined', (data: { room: GameRoom }) => {
setRoom(data.room) setRoom(data.room)
// Play sound when a player joins
const volume = useSoundStore.getState().volume
soundPlayer.play('player_join', volume)
}) })
socket.on('player_left', (data: { room: GameRoom }) => { socket.on('player_left', (data: { room: GameRoom }) => {
@@ -68,11 +61,22 @@ export function useSocket() {
// Game events // Game events
socket.on('game_started', (data: { room: GameRoom }) => { socket.on('game_started', (data: { room: GameRoom }) => {
setRoom(data.room) setRoom(data.room)
// Play game start sound
const volume = useSoundStore.getState().volume
soundPlayer.play('game_start', volume)
}) })
socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => { socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => {
setRoom(data.room) setRoom(data.room)
// Fetch full question details // Find the question in the board and set it as current
const questionId = data.question_id
for (const categoryQuestions of Object.values(data.room.board || {})) {
const question = (categoryQuestions as Array<{ id: number }>).find(q => q.id === questionId)
if (question) {
setCurrentQuestion(question as unknown as import('../types').Question)
break
}
}
}) })
socket.on('answer_result', (data: AnswerResult) => { socket.on('answer_result', (data: AnswerResult) => {
@@ -82,12 +86,17 @@ export function useSocket() {
const volume = useSoundStore.getState().volume const volume = useSoundStore.getState().volume
if (data.valid) { if (data.valid) {
soundPlayer.play('correct', volume) soundPlayer.play('correct', volume)
// Clear current question after correct answer
setCurrentQuestion(null)
} else { } else {
soundPlayer.play('incorrect', volume) soundPlayer.play('incorrect', volume)
} }
if (!data.valid && !data.was_steal && data.room.can_steal) { if (!data.valid && !data.was_steal && data.room.can_steal) {
setShowStealPrompt(true) setShowStealPrompt(true)
} else if (data.was_steal) {
// Clear question after steal attempt (success or fail)
setCurrentQuestion(null)
} }
}) })
@@ -103,6 +112,7 @@ export function useSocket() {
socket.on('steal_passed', (data: { room: GameRoom }) => { socket.on('steal_passed', (data: { room: GameRoom }) => {
setRoom(data.room) setRoom(data.room)
setShowStealPrompt(false) setShowStealPrompt(false)
setCurrentQuestion(null)
}) })
socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => { socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => {
@@ -111,6 +121,7 @@ export function useSocket() {
setShowStealPrompt(true) setShowStealPrompt(true)
} else { } else {
setShowStealPrompt(false) setShowStealPrompt(false)
setCurrentQuestion(null)
} }
}) })
@@ -175,18 +186,16 @@ export function useSocket() {
}) })
}) })
return () => { // No cleanup - socket connection persists across components
socket.disconnect() }, [])
}
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage])
// Socket methods // Socket methods - use singleton service
const createRoom = useCallback((playerName: string) => { const createRoom = useCallback((playerName: string) => {
socketRef.current?.emit('create_room', { player_name: playerName }) socketService.emit('create_room', { player_name: playerName })
}, []) }, [])
const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => { const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => {
socketRef.current?.emit('join_room', { socketService.emit('join_room', {
room_code: roomCode, room_code: roomCode,
player_name: playerName, player_name: playerName,
team, team,
@@ -194,15 +203,15 @@ export function useSocket() {
}, []) }, [])
const changeTeam = useCallback((team: 'A' | 'B') => { const changeTeam = useCallback((team: 'A' | 'B') => {
socketRef.current?.emit('change_team', { team }) socketService.emit('change_team', { team })
}, []) }, [])
const startGame = useCallback((board: Record<string, unknown>) => { const startGame = useCallback((board: Record<string, unknown>) => {
socketRef.current?.emit('start_game', { board }) socketService.emit('start_game', { board })
}, []) }, [])
const selectQuestion = useCallback((questionId: number, categoryId: number) => { const selectQuestion = useCallback((questionId: number, categoryId: number) => {
socketRef.current?.emit('select_question', { socketService.emit('select_question', {
question_id: questionId, question_id: questionId,
category_id: categoryId, category_id: categoryId,
}) })
@@ -210,7 +219,7 @@ export function useSocket() {
const submitAnswer = useCallback( const submitAnswer = useCallback(
(answer: string, question: Record<string, unknown>, isSteal: boolean = false) => { (answer: string, question: Record<string, unknown>, isSteal: boolean = false) => {
socketRef.current?.emit('submit_answer', { socketService.emit('submit_answer', {
answer, answer,
question, question,
is_steal: isSteal, is_steal: isSteal,
@@ -220,7 +229,7 @@ export function useSocket() {
) )
const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => { const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => {
socketRef.current?.emit('steal_decision', { socketService.emit('steal_decision', {
attempt, attempt,
question_id: questionId, question_id: questionId,
answer, answer,
@@ -228,15 +237,15 @@ export function useSocket() {
}, []) }, [])
const sendChatMessage = useCallback((message: string) => { const sendChatMessage = useCallback((message: string) => {
socketRef.current?.emit('chat_message', { message }) socketService.emit('chat_message', { message })
}, []) }, [])
const sendEmojiReaction = useCallback((emoji: string) => { const sendEmojiReaction = useCallback((emoji: string) => {
socketRef.current?.emit('emoji_reaction', { emoji }) socketService.emit('emoji_reaction', { emoji })
}, []) }, [])
const sendReaction = useCallback((emoji: string, roomCode?: string, playerName?: string) => { const sendReaction = useCallback((emoji: string, roomCode?: string, playerName?: string) => {
socketRef.current?.emit('send_reaction', { socketService.emit('send_reaction', {
emoji, emoji,
room_code: roomCode, room_code: roomCode,
player_name: playerName, player_name: playerName,
@@ -245,7 +254,7 @@ export function useSocket() {
const sendTeamMessage = useCallback( const sendTeamMessage = useCallback(
(message: string, roomCode: string, team: 'A' | 'B', playerName: string) => { (message: string, roomCode: string, team: 'A' | 'B', playerName: string) => {
socketRef.current?.emit('team_message', { socketService.emit('team_message', {
room_code: roomCode, room_code: roomCode,
team, team,
player_name: playerName, player_name: playerName,
@@ -256,11 +265,11 @@ export function useSocket() {
) )
const notifyTimerExpired = useCallback(() => { const notifyTimerExpired = useCallback(() => {
socketRef.current?.emit('timer_expired', {}) socketService.emit('timer_expired', {})
}, []) }, [])
return { return {
socket: socketRef.current, socket: socketService.connect(),
createRoom, createRoom,
joinRoom, joinRoom,
changeTeam, changeTeam,

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useSocket } from '../hooks/useSocket' import { useSocket } from '../hooks/useSocket'
@@ -11,16 +11,17 @@ import TeamChat from '../components/chat/TeamChat'
import SoundControl from '../components/ui/SoundControl' import SoundControl from '../components/ui/SoundControl'
import type { Question } from '../types' import type { Question } from '../types'
const categories = [ // All available categories with their styling
{ id: 1, name: 'Nintendo', icon: '🍄', color: '#E60012' }, const allCategories: Record<number, { name: string; icon: string; color: string }> = {
{ id: 2, name: 'Xbox', icon: '🎮', color: '#107C10' }, 1: { name: 'Nintendo', icon: '🍄', color: '#E60012' },
{ id: 3, name: 'PlayStation', icon: '🎯', color: '#003791' }, 2: { name: 'Xbox', icon: '🎮', color: '#107C10' },
{ id: 4, name: 'Anime', icon: '⛩️', color: '#FF6B9D' }, 3: { name: 'PlayStation', icon: '🎯', color: '#003791' },
{ id: 5, name: 'Música', icon: '🎵', color: '#1DB954' }, 4: { name: 'Anime', icon: '⛩️', color: '#FF6B9D' },
{ id: 6, name: 'Películas', icon: '🎬', color: '#F5C518' }, 5: { name: 'Música', icon: '🎵', color: '#1DB954' },
{ id: 7, name: 'Libros', icon: '📚', color: '#8B4513' }, 6: { name: 'Películas', icon: '🎬', color: '#F5C518' },
{ id: 8, name: 'Historia-Cultura', icon: '🏛️', color: '#6B5B95' }, 7: { name: 'Libros', icon: '📚', color: '#8B4513' },
] 8: { name: 'Historia', icon: '🏛️', color: '#6B5B95' },
}
export default function Game() { export default function Game() {
useParams<{ roomCode: string }>() useParams<{ roomCode: string }>()
@@ -32,7 +33,7 @@ export default function Game() {
const [answer, setAnswer] = useState('') const [answer, setAnswer] = useState('')
const [timeLeft, setTimeLeft] = useState(0) const [timeLeft, setTimeLeft] = useState(0)
const [showingQuestion, setShowingQuestion] = useState(false) const [hoveredCell, setHoveredCell] = useState<string | null>(null)
// Redirect if game finished // Redirect if game finished
useEffect(() => { useEffect(() => {
@@ -41,9 +42,16 @@ export default function Game() {
} }
}, [room?.status, room?.code, navigate]) }, [room?.status, room?.code, navigate])
// Play sound when question is revealed
useEffect(() => {
if (currentQuestion) {
play('question_reveal')
}
}, [currentQuestion, play])
// Timer logic with sound effects // Timer logic with sound effects
useEffect(() => { useEffect(() => {
if (!currentQuestion || !showingQuestion) return if (!currentQuestion) return
setTimeLeft(currentQuestion.time_seconds) setTimeLeft(currentQuestion.time_seconds)
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -52,11 +60,11 @@ export default function Game() {
clearInterval(interval) clearInterval(interval)
return 0 return 0
} }
// Play urgent sound when time is running low (5 seconds or less) if (prev <= 4 && prev > 1) {
if (prev <= 6 && prev > 1) { play('countdown')
} else if (prev <= 6 && prev > 4) {
play('timer_urgent') play('timer_urgent')
} else if (prev > 6) { } else if (prev > 6) {
// Play tick sound for normal countdown
play('timer_tick') play('timer_tick')
} }
return prev - 1 return prev - 1
@@ -64,12 +72,25 @@ export default function Game() {
}, 1000) }, 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [currentQuestion, showingQuestion, play]) }, [currentQuestion, play])
// Hover sound handler
const handleCellHover = useCallback((cellId: string, canSelect: boolean) => {
if (canSelect && hoveredCell !== cellId) {
setHoveredCell(cellId)
play('hover')
}
}, [hoveredCell, play])
if (!room) { if (!room) {
return ( return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}> <div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>Cargando...</p> <motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-8 h-8 border-4 rounded-full"
style={{ borderColor: config.colors.primary, borderTopColor: 'transparent' }}
/>
</div> </div>
) )
} }
@@ -85,164 +106,379 @@ export default function Game() {
if (!amICurrentPlayer || question.answered) return if (!amICurrentPlayer || question.answered) return
play('select') play('select')
selectQuestion(question.id, categoryId) selectQuestion(question.id, categoryId)
setShowingQuestion(true)
} }
const handleSubmitAnswer = () => { const handleSubmitAnswer = () => {
if (!currentQuestion || !answer.trim()) return if (!currentQuestion || !answer.trim()) return
submitAnswer(answer, currentQuestion as unknown as Record<string, unknown>, room.can_steal) submitAnswer(answer, currentQuestion as unknown as Record<string, unknown>, room.can_steal)
setAnswer('') setAnswer('')
setShowingQuestion(false)
} }
const handleStealDecision = (attempt: boolean) => { const handleStealDecision = (attempt: boolean) => {
if (!currentQuestion) return if (!currentQuestion) return
if (attempt) { if (!attempt) {
setShowingQuestion(true)
} else {
stealDecision(false, currentQuestion.id) stealDecision(false, currentQuestion.id)
} }
setShowStealPrompt(false) setShowStealPrompt(false)
} }
// Handler for sending team messages
const handleSendTeamMessage = (message: string) => { const handleSendTeamMessage = (message: string) => {
if (room && playerName && myTeam) { if (room && playerName && myTeam) {
sendTeamMessage(message, room.code, myTeam, playerName) sendTeamMessage(message, room.code, myTeam, playerName)
} }
} }
// Determine if the game is active (playing status)
const isGameActive = room.status === 'playing' const isGameActive = room.status === 'playing'
const timerProgress = currentQuestion ? (timeLeft / currentQuestion.time_seconds) * 100 : 100
// Get active categories from the board (dynamic based on what backend sends)
const activeCategories = useMemo(() => {
if (!room.board) return []
return Object.keys(room.board).map(id => ({
id: parseInt(id),
...allCategories[parseInt(id)] || { name: `Cat ${id}`, icon: '❓', color: '#666' }
}))
}, [room.board])
const numCategories = activeCategories.length || 5
return ( return (
<div className="min-h-screen p-4" style={styles.bgPrimary}> <div className="min-h-screen p-2 md:p-4 overflow-hidden" style={styles.bgPrimary}>
<div className="max-w-6xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Scoreboard */} {/* Header with Room Code */}
<div className="flex justify-between items-center mb-6"> <div className="text-center mb-4">
<div <motion.h1
className="text-center px-6 py-2 rounded-lg" initial={{ y: -20, opacity: 0 }}
style={{ animate={{ y: 0, opacity: 1 }}
backgroundColor: config.colors.primary + '20', className="text-2xl md:text-3xl font-bold tracking-wider"
border: `2px solid ${config.colors.primary}`, style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
}}
> >
<div className="text-sm" style={styles.textSecondary}>Equipo A</div> TRIVY
<div className="text-3xl font-bold" style={{ color: config.colors.primary }}> </motion.h1>
{room.scores.A} <div className="text-xs opacity-60" style={{ color: config.colors.textMuted }}>
</div> Sala: {room.code}
</div>
<div className="text-center">
<div className="text-sm" style={styles.textSecondary}>
Turno de {room.current_team === 'A' ? 'Equipo A' : 'Equipo B'}
</div>
{amICurrentPlayer && (
<div className="text-lg font-bold" style={{ color: config.colors.accent }}>
¡Tu turno!
</div>
)}
</div>
<div
className="text-center px-6 py-2 rounded-lg"
style={{
backgroundColor: config.colors.secondary + '20',
border: `2px solid ${config.colors.secondary}`,
}}
>
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
<div className="text-3xl font-bold" style={{ color: config.colors.secondary }}>
{room.scores.B}
</div>
</div> </div>
</div> </div>
{/* Game Board */} {/* Scoreboard */}
<div className="grid grid-cols-8 gap-2 mb-6"> <div className="flex justify-between items-stretch gap-4 mb-4">
{/* Category Headers */} {/* Team A Score */}
{categories.map((cat) => ( <motion.div
<div initial={{ x: -50, opacity: 0 }}
key={cat.id} animate={{ x: 0, opacity: 1 }}
className="text-center p-2 rounded-t-lg" className={`flex-1 text-center p-3 md:p-4 rounded-xl relative overflow-hidden ${
style={{ backgroundColor: cat.color }} room.current_team === 'A' ? 'ring-2 ring-offset-2' : ''
> }`}
<div className="text-2xl">{cat.icon}</div> style={{
<div className="text-xs text-white font-bold truncate">{cat.name}</div> background: `linear-gradient(135deg, ${config.colors.primary}30 0%, ${config.colors.primary}10 100%)`,
border: `3px solid ${config.colors.primary}`,
boxShadow: room.current_team === 'A' ? `0 0 20px ${config.colors.primary}50` : 'none',
'--tw-ring-color': config.colors.primary,
'--tw-ring-offset-color': config.colors.bg,
} as React.CSSProperties}
>
{room.current_team === 'A' && (
<motion.div
className="absolute inset-0"
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
style={{ background: `radial-gradient(circle, ${config.colors.primary}20 0%, transparent 70%)` }}
/>
)}
<div className="relative z-10">
<div className="text-xs md:text-sm font-medium opacity-80" style={{ color: config.colors.primary }}>
EQUIPO A
</div>
<motion.div
key={room.scores.A}
initial={{ scale: 1.3 }}
animate={{ scale: 1 }}
className="text-3xl md:text-5xl font-black"
style={{ color: config.colors.primary, textShadow: `0 0 10px ${config.colors.primary}50` }}
>
{room.scores.A}
</motion.div>
<div className="text-xs opacity-60 mt-1" style={{ color: config.colors.textMuted }}>
{room.teams.A.map(p => p.name).join(', ')}
</div>
</div> </div>
))} </motion.div>
{/* Turn Indicator */}
<div className="flex flex-col items-center justify-center px-2">
<motion.div
animate={amICurrentPlayer ? { scale: [1, 1.1, 1] } : {}}
transition={{ duration: 0.5, repeat: amICurrentPlayer ? Infinity : 0 }}
className="text-center"
>
<div className="text-xs opacity-60 mb-1" style={{ color: config.colors.textMuted }}>TURNO</div>
<div
className="text-lg md:text-xl font-bold px-3 py-1 rounded-full"
style={{
backgroundColor: room.current_team === 'A' ? config.colors.primary : config.colors.secondary,
color: config.colors.bg
}}
>
{room.current_team}
</div>
{amICurrentPlayer && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs mt-1 font-bold"
style={{ color: config.colors.accent }}
>
¡TU TURNO!
</motion.div>
)}
</motion.div>
</div>
{/* Team B Score */}
<motion.div
initial={{ x: 50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
className={`flex-1 text-center p-3 md:p-4 rounded-xl relative overflow-hidden ${
room.current_team === 'B' ? 'ring-2 ring-offset-2' : ''
}`}
style={{
background: `linear-gradient(135deg, ${config.colors.secondary}30 0%, ${config.colors.secondary}10 100%)`,
border: `3px solid ${config.colors.secondary}`,
boxShadow: room.current_team === 'B' ? `0 0 20px ${config.colors.secondary}50` : 'none',
'--tw-ring-color': config.colors.secondary,
'--tw-ring-offset-color': config.colors.bg,
} as React.CSSProperties}
>
{room.current_team === 'B' && (
<motion.div
className="absolute inset-0"
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
style={{ background: `radial-gradient(circle, ${config.colors.secondary}20 0%, transparent 70%)` }}
/>
)}
<div className="relative z-10">
<div className="text-xs md:text-sm font-medium opacity-80" style={{ color: config.colors.secondary }}>
EQUIPO B
</div>
<motion.div
key={room.scores.B}
initial={{ scale: 1.3 }}
animate={{ scale: 1 }}
className="text-3xl md:text-5xl font-black"
style={{ color: config.colors.secondary, textShadow: `0 0 10px ${config.colors.secondary}50` }}
>
{room.scores.B}
</motion.div>
<div className="text-xs opacity-60 mt-1" style={{ color: config.colors.textMuted }}>
{room.teams.B.map(p => p.name).join(', ')}
</div>
</div>
</motion.div>
</div>
{/* Game Board - Jeopardy Style */}
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="rounded-xl overflow-hidden mb-16"
style={{
border: `3px solid ${config.colors.primary}40`,
boxShadow: `0 10px 40px ${config.colors.primary}20`
}}
>
{/* Category Headers */}
<div
className="grid"
style={{ gridTemplateColumns: `repeat(${numCategories}, minmax(0, 1fr))` }}
>
{activeCategories.map((cat, index) => (
<motion.div
key={cat.id}
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: index * 0.05 }}
className="text-center p-2 md:p-4 relative overflow-hidden"
style={{
background: `linear-gradient(180deg, ${cat.color} 0%, ${cat.color}CC 100%)`,
borderRight: index < numCategories - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none'
}}
>
<div className="text-2xl md:text-4xl mb-1 drop-shadow-lg">{cat.icon}</div>
<div className="text-xs md:text-sm text-white font-bold uppercase tracking-wide truncate drop-shadow">
{cat.name}
</div>
</motion.div>
))}
</div>
{/* Questions Grid */} {/* Questions Grid */}
{[1, 2, 3, 4, 5].map((difficulty) => {[1, 2, 3, 4, 5].map((difficulty, rowIndex) => (
categories.map((cat) => { <div
const questions = room.board[String(cat.id)] || [] key={difficulty}
const question = questions.find(q => q.difficulty === difficulty) className="grid"
const isAnswered = question?.answered style={{ gridTemplateColumns: `repeat(${numCategories}, minmax(0, 1fr))` }}
>
{activeCategories.map((cat, colIndex) => {
const questions = room.board[String(cat.id)] || []
const question = questions.find(q => q.difficulty === difficulty)
const isAnswered = question?.answered
const cellId = `${cat.id}-${difficulty}`
const canSelect = !isAnswered && amICurrentPlayer && question
return ( return (
<motion.button <motion.button
key={`${cat.id}-${difficulty}`} key={cellId}
whileHover={!isAnswered && amICurrentPlayer ? { scale: 1.05 } : {}} initial={{ scale: 0, opacity: 0 }}
whileTap={!isAnswered && amICurrentPlayer ? { scale: 0.95 } : {}} animate={{ scale: 1, opacity: 1 }}
onClick={() => question && handleSelectQuestion(question, cat.id)} transition={{ delay: (rowIndex * 8 + colIndex) * 0.02 + 0.3 }}
disabled={isAnswered || !amICurrentPlayer} whileHover={canSelect ? {
className={`p-4 rounded transition-all ${ scale: 1.08,
isAnswered ? 'opacity-30' : amICurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed opacity-70' zIndex: 10,
}`} boxShadow: `0 0 25px ${cat.color}80`
style={{ } : {}}
backgroundColor: isAnswered ? config.colors.bg : cat.color + '40', whileTap={canSelect ? { scale: 0.95 } : {}}
border: `2px solid ${cat.color}`, onClick={() => question && handleSelectQuestion(question, cat.id)}
}} onMouseEnter={() => handleCellHover(cellId, !!canSelect)}
> onMouseLeave={() => setHoveredCell(null)}
<span className="text-xl font-bold" style={{ color: config.colors.text }}> disabled={isAnswered || !amICurrentPlayer}
{difficulty * 100} className={`relative aspect-[4/3] md:aspect-square flex items-center justify-center transition-all duration-200 ${
</span> isAnswered
</motion.button> ? 'cursor-default'
) : canSelect
}) ? 'cursor-pointer'
)} : 'cursor-not-allowed'
</div> }`}
style={{
background: isAnswered
? `linear-gradient(135deg, ${config.colors.bg} 0%, ${config.colors.bg}90 100%)`
: `linear-gradient(135deg, ${cat.color}50 0%, ${cat.color}30 100%)`,
borderRight: colIndex < numCategories - 1 ? '1px solid rgba(255,255,255,0.1)' : 'none',
borderBottom: '1px solid rgba(255,255,255,0.1)',
opacity: isAnswered ? 0.3 : (!amICurrentPlayer ? 0.6 : 1),
}}
>
{/* Glow effect on hover */}
{canSelect && hoveredCell === cellId && (
<motion.div
layoutId="cellHighlight"
className="absolute inset-0 pointer-events-none"
style={{
background: `radial-gradient(circle, ${cat.color}40 0%, transparent 70%)`,
}}
/>
)}
{/* Points display */}
<span
className={`text-lg md:text-2xl lg:text-3xl font-black relative z-10 transition-all ${
isAnswered ? 'line-through opacity-40' : ''
}`}
style={{
color: isAnswered ? config.colors.textMuted : '#FFD700',
textShadow: isAnswered ? 'none' : '2px 2px 4px rgba(0,0,0,0.5), 0 0 10px rgba(255,215,0,0.3)'
}}
>
${difficulty * 100}
</span>
{/* Answered checkmark */}
{isAnswered && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute inset-0 flex items-center justify-center"
>
<span className="text-2xl opacity-40"></span>
</motion.div>
)}
</motion.button>
)
})}
</div>
))}
</motion.div>
{/* Question Modal */} {/* Question Modal */}
<AnimatePresence> <AnimatePresence>
{showingQuestion && currentQuestion && ( {currentQuestion && (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50" className="fixed inset-0 bg-black/90 flex items-center justify-center p-4 z-50"
> >
<motion.div <motion.div
initial={{ scale: 0.9, y: 20 }} initial={{ scale: 0.8, y: 50, rotateX: -15 }}
animate={{ scale: 1, y: 0 }} animate={{ scale: 1, y: 0, rotateX: 0 }}
exit={{ scale: 0.9, y: 20 }} exit={{ scale: 0.8, y: 50, opacity: 0 }}
className="w-full max-w-lg p-6 rounded-lg" transition={{ type: "spring", damping: 20 }}
className="w-full max-w-2xl p-6 md:p-8 rounded-2xl relative overflow-hidden"
style={{ style={{
backgroundColor: config.colors.bg, background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
border: `3px solid ${config.colors.primary}`, border: `4px solid ${config.colors.primary}`,
boxShadow: `0 0 60px ${config.colors.primary}40, inset 0 0 60px ${config.colors.primary}10`
}} }}
> >
{/* Timer */} {/* Timer Bar */}
<div className="flex justify-between items-center mb-4"> <div className="absolute top-0 left-0 right-0 h-2 bg-black/30 overflow-hidden">
<span className="text-sm" style={styles.textSecondary}> <motion.div
{currentQuestion.points} puntos initial={{ width: '100%' }}
</span> animate={{ width: `${timerProgress}%` }}
<div transition={{ duration: 0.5 }}
className={`text-2xl font-bold ${timeLeft <= 5 ? 'text-red-500 animate-pulse' : ''}`} className="h-full"
style={{ color: timeLeft > 5 ? config.colors.primary : undefined }} style={{
backgroundColor: timerProgress > 30 ? config.colors.primary : timerProgress > 15 ? '#FFA500' : '#FF4444',
boxShadow: `0 0 10px ${timerProgress > 30 ? config.colors.primary : '#FF4444'}`
}}
/>
</div>
{/* Points & Timer */}
<div className="flex justify-between items-center mb-6 mt-2">
<motion.div
className="px-4 py-2 rounded-full font-bold"
style={{
backgroundColor: '#FFD700',
color: '#000',
boxShadow: '0 0 15px rgba(255,215,0,0.5)'
}}
> >
{timeLeft}s ${currentQuestion.points}
</div> </motion.div>
<motion.div
animate={timeLeft <= 5 ? { scale: [1, 1.1, 1] } : {}}
transition={{ duration: 0.3, repeat: timeLeft <= 5 ? Infinity : 0 }}
className={`text-4xl md:text-5xl font-black ${timeLeft <= 5 ? 'text-red-500' : ''}`}
style={{
color: timeLeft > 5 ? config.colors.primary : undefined,
textShadow: timeLeft <= 5 ? '0 0 20px #FF0000' : `0 0 20px ${config.colors.primary}50`
}}
>
{timeLeft}
</motion.div>
</div> </div>
{/* Question */} {/* Question */}
<p className="text-xl mb-6 text-center" style={{ color: config.colors.text }}> <motion.p
{currentQuestion.question_text || 'Pregunta de ejemplo: ¿En qué año se lanzó la NES?'} initial={{ opacity: 0, y: 20 }}
</p> animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-xl md:text-2xl mb-8 text-center leading-relaxed"
style={{ color: config.colors.text }}
>
{currentQuestion.question_text}
</motion.p>
{/* Answer Input */} {/* Answer Input */}
{amICurrentPlayer && ( {amICurrentPlayer && (
<div className="space-y-4"> <motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="space-y-4"
>
<input <input
type="text" type="text"
value={answer} value={answer}
@@ -250,30 +486,49 @@ export default function Game() {
onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()} onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()}
placeholder="Escribe tu respuesta..." placeholder="Escribe tu respuesta..."
autoFocus autoFocus
className="w-full px-4 py-3 rounded-lg bg-transparent outline-none text-lg" className="w-full px-6 py-4 rounded-xl bg-black/30 outline-none text-xl text-center transition-all focus:ring-2"
style={{ style={{
border: `2px solid ${config.colors.primary}`, border: `3px solid ${config.colors.primary}50`,
color: config.colors.text, color: config.colors.text,
}} '--tw-ring-color': config.colors.primary,
} as React.CSSProperties}
/> />
<button <motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleSubmitAnswer} onClick={handleSubmitAnswer}
disabled={!answer.trim()} disabled={!answer.trim()}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105 disabled:opacity-50" className="w-full py-4 rounded-xl font-bold text-xl transition-all disabled:opacity-30"
style={{ style={{
backgroundColor: config.colors.primary, background: `linear-gradient(135deg, ${config.colors.primary} 0%, ${config.colors.accent} 100%)`,
color: config.colors.bg, color: config.colors.bg,
boxShadow: answer.trim() ? `0 5px 30px ${config.colors.primary}50` : 'none'
}} }}
> >
Responder RESPONDER
</button> </motion.button>
</div> </motion.div>
)} )}
{!amICurrentPlayer && ( {!amICurrentPlayer && (
<p className="text-center" style={styles.textSecondary}> <motion.div
Esperando respuesta de {currentPlayer?.name}... initial={{ opacity: 0 }}
</p> animate={{ opacity: 1 }}
className="text-center py-4"
>
<div className="inline-flex items-center gap-2 px-6 py-3 rounded-full"
style={{ backgroundColor: config.colors.primary + '20' }}>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-4 h-4 border-2 rounded-full"
style={{ borderColor: config.colors.primary, borderTopColor: 'transparent' }}
/>
<span style={{ color: config.colors.textMuted }}>
Esperando respuesta de <strong style={{ color: config.colors.primary }}>{currentPlayer?.name}</strong>
</span>
</div>
</motion.div>
)} )}
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -287,68 +542,82 @@ export default function Game() {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50" className="fixed inset-0 bg-black/90 flex items-center justify-center p-4 z-50"
> >
<motion.div <motion.div
initial={{ scale: 0.9 }} initial={{ scale: 0.5, rotate: -5 }}
animate={{ scale: 1 }} animate={{ scale: 1, rotate: 0 }}
className="p-6 rounded-lg text-center" exit={{ scale: 0.5, opacity: 0 }}
transition={{ type: "spring", damping: 15 }}
className="p-8 rounded-2xl text-center max-w-md"
style={{ style={{
backgroundColor: config.colors.bg, background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
border: `3px solid ${config.colors.accent}`, border: `4px solid ${config.colors.accent}`,
boxShadow: `0 0 60px ${config.colors.accent}60`
}} }}
> >
<h3 className="text-2xl font-bold mb-4" style={{ color: config.colors.accent }}> <motion.div
¡Oportunidad de Robo! animate={{ rotate: [0, -10, 10, 0] }}
transition={{ duration: 0.5, repeat: Infinity }}
className="text-6xl mb-4"
>
🎯
</motion.div>
<h3 className="text-3xl font-black mb-4" style={{ color: config.colors.accent }}>
¡OPORTUNIDAD DE ROBO!
</h3> </h3>
<p className="mb-6" style={styles.textSecondary}> <p className="mb-2" style={{ color: config.colors.textMuted }}>
El equipo contrario falló. ¿Quieres intentar robar los puntos? El equipo contrario falló
<br /> </p>
<span className="text-red-500">Advertencia: Si fallas, perderás puntos</span> <p className="mb-6 text-red-400 text-sm">
Si fallas, perderás puntos
</p> </p>
<div className="flex gap-4 justify-center"> <div className="flex gap-4 justify-center">
<button <motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleStealDecision(true)} onClick={() => handleStealDecision(true)}
className="px-6 py-3 rounded-lg font-bold" className="px-8 py-4 rounded-xl font-bold text-lg"
style={{ style={{
backgroundColor: config.colors.accent, background: `linear-gradient(135deg, ${config.colors.accent} 0%, #FF6B6B 100%)`,
color: config.colors.bg, color: '#FFF',
boxShadow: `0 5px 30px ${config.colors.accent}50`
}} }}
> >
¡Robar! ¡ROBAR!
</button> </motion.button>
<button <motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleStealDecision(false)} onClick={() => handleStealDecision(false)}
className="px-6 py-3 rounded-lg font-bold" className="px-8 py-4 rounded-xl font-bold text-lg"
style={{ style={{
backgroundColor: 'transparent', backgroundColor: 'transparent',
color: config.colors.text, color: config.colors.text,
border: `2px solid ${config.colors.text}`, border: `2px solid ${config.colors.text}50`,
}} }}
> >
Pasar Pasar
</button> </motion.button>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* Emoji Reactions Bar - Fixed at bottom */} {/* Emoji Reactions Bar */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30"> <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
<EmojiReactions /> <EmojiReactions />
</div> </div>
{/* Sound Control - Fixed at top right */} {/* Sound Control */}
<div className="fixed top-4 right-4 z-30"> <div className="fixed top-4 right-4 z-30">
<SoundControl compact popupPosition="bottom" /> <SoundControl compact />
</div> </div>
</div> </div>
{/* Reaction Overlay - Full screen overlay for floating reactions */}
<ReactionOverlay /> <ReactionOverlay />
{/* Team Chat - Only visible during the game */}
{isGameActive && ( {isGameActive && (
<TeamChat <TeamChat
roomCode={room.code} roomCode={room.code}

View File

@@ -4,15 +4,16 @@ const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
class SocketService { class SocketService {
private socket: Socket | null = null private socket: Socket | null = null
private listeners: Map<string, Set<(data: unknown) => void>> = new Map() private initialized = false
connect(): Socket { connect(): Socket {
if (!this.socket) { if (!this.socket) {
console.log('Creating new socket connection to:', SOCKET_URL)
this.socket = io(SOCKET_URL, { this.socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
autoConnect: true, autoConnect: true,
reconnection: true, reconnection: true,
reconnectionAttempts: 5, reconnectionAttempts: 10,
reconnectionDelay: 1000, reconnectionDelay: 1000,
}) })
@@ -24,9 +25,12 @@ class SocketService {
console.log('Socket disconnected:', reason) console.log('Socket disconnected:', reason)
}) })
this.socket.on('error', (error) => { this.socket.on('connect_error', (error) => {
console.error('Socket error:', error) console.error('Socket connection error:', error)
}) })
} else if (!this.socket.connected) {
console.log('Reconnecting socket...')
this.socket.connect()
} }
return this.socket return this.socket
@@ -36,25 +40,35 @@ class SocketService {
if (this.socket) { if (this.socket) {
this.socket.disconnect() this.socket.disconnect()
this.socket = null this.socket = null
this.initialized = false
} }
} }
on(event: string, callback: (data: unknown) => void): void { on(event: string, callback: (...args: unknown[]) => void): void {
if (!this.listeners.has(event)) { const socket = this.connect()
this.listeners.set(event, new Set()) // Remove existing listener to prevent duplicates
} socket.off(event, callback)
this.listeners.get(event)!.add(callback) socket.on(event, callback)
this.socket?.on(event, callback)
} }
off(event: string, callback: (data: unknown) => void): void { off(event: string, callback?: (...args: unknown[]) => void): void {
this.listeners.get(event)?.delete(callback) if (callback) {
this.socket?.off(event, callback) this.socket?.off(event, callback)
} else {
this.socket?.off(event)
}
} }
emit(event: string, data?: unknown): void { emit(event: string, data?: unknown): void {
this.socket?.emit(event, data) const socket = this.connect()
if (socket.connected) {
socket.emit(event, data)
} else {
// Wait for connection and then emit
socket.once('connect', () => {
socket.emit(event, data)
})
}
} }
get connected(): boolean { get connected(): boolean {
@@ -64,6 +78,15 @@ class SocketService {
get id(): string | undefined { get id(): string | undefined {
return this.socket?.id return this.socket?.id
} }
// Check if listeners are initialized
get isInitialized(): boolean {
return this.initialized
}
setInitialized(): void {
this.initialized = true
}
} }
export const socketService = new SocketService() export const socketService = new SocketService()

View File

@@ -11,6 +11,11 @@ export type SoundEffect =
| 'victory' | 'victory'
| 'defeat' | 'defeat'
| 'select' | 'select'
| 'game_start'
| 'player_join'
| 'question_reveal'
| 'hover'
| 'countdown'
interface SoundState { interface SoundState {
volume: number volume: number
@@ -56,6 +61,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/drrr/victory.mp3', victory: '/sounds/drrr/victory.mp3',
defeat: '/sounds/drrr/defeat.mp3', defeat: '/sounds/drrr/defeat.mp3',
select: '/sounds/drrr/select.mp3', select: '/sounds/drrr/select.mp3',
game_start: '/sounds/drrr/game_start.mp3',
player_join: '/sounds/drrr/player_join.mp3',
question_reveal: '/sounds/drrr/question_reveal.mp3',
hover: '/sounds/drrr/hover.mp3',
countdown: '/sounds/drrr/countdown.mp3',
}, },
retro: { retro: {
correct: '/sounds/retro/correct.mp3', correct: '/sounds/retro/correct.mp3',
@@ -66,6 +76,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/retro/victory.mp3', victory: '/sounds/retro/victory.mp3',
defeat: '/sounds/retro/defeat.mp3', defeat: '/sounds/retro/defeat.mp3',
select: '/sounds/retro/select.mp3', select: '/sounds/retro/select.mp3',
game_start: '/sounds/retro/game_start.mp3',
player_join: '/sounds/retro/player_join.mp3',
question_reveal: '/sounds/retro/question_reveal.mp3',
hover: '/sounds/retro/hover.mp3',
countdown: '/sounds/retro/countdown.mp3',
}, },
minimal: { minimal: {
correct: '/sounds/minimal/correct.mp3', correct: '/sounds/minimal/correct.mp3',
@@ -76,6 +91,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/minimal/victory.mp3', victory: '/sounds/minimal/victory.mp3',
defeat: '/sounds/minimal/defeat.mp3', defeat: '/sounds/minimal/defeat.mp3',
select: '/sounds/minimal/select.mp3', select: '/sounds/minimal/select.mp3',
game_start: '/sounds/minimal/game_start.mp3',
player_join: '/sounds/minimal/player_join.mp3',
question_reveal: '/sounds/minimal/question_reveal.mp3',
hover: '/sounds/minimal/hover.mp3',
countdown: '/sounds/minimal/countdown.mp3',
}, },
rgb: { rgb: {
correct: '/sounds/rgb/correct.mp3', correct: '/sounds/rgb/correct.mp3',
@@ -86,6 +106,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/rgb/victory.mp3', victory: '/sounds/rgb/victory.mp3',
defeat: '/sounds/rgb/defeat.mp3', defeat: '/sounds/rgb/defeat.mp3',
select: '/sounds/rgb/select.mp3', select: '/sounds/rgb/select.mp3',
game_start: '/sounds/rgb/game_start.mp3',
player_join: '/sounds/rgb/player_join.mp3',
question_reveal: '/sounds/rgb/question_reveal.mp3',
hover: '/sounds/rgb/hover.mp3',
countdown: '/sounds/rgb/countdown.mp3',
}, },
anime: { anime: {
correct: '/sounds/anime/correct.mp3', correct: '/sounds/anime/correct.mp3',
@@ -96,6 +121,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/anime/victory.mp3', victory: '/sounds/anime/victory.mp3',
defeat: '/sounds/anime/defeat.mp3', defeat: '/sounds/anime/defeat.mp3',
select: '/sounds/anime/select.mp3', select: '/sounds/anime/select.mp3',
game_start: '/sounds/anime/game_start.mp3',
player_join: '/sounds/anime/player_join.mp3',
question_reveal: '/sounds/anime/question_reveal.mp3',
hover: '/sounds/anime/hover.mp3',
countdown: '/sounds/anime/countdown.mp3',
}, },
} }
@@ -110,4 +140,9 @@ export const fallbackSoundConfigs: Record<SoundEffect, { frequency: number; dura
victory: { frequency: 523, duration: 0.5, type: 'sine' }, victory: { frequency: 523, duration: 0.5, type: 'sine' },
defeat: { frequency: 196, duration: 0.5, type: 'sine' }, defeat: { frequency: 196, duration: 0.5, type: 'sine' },
select: { frequency: 600, duration: 0.08, type: 'sine' }, select: { frequency: 600, duration: 0.08, type: 'sine' },
game_start: { frequency: 440, duration: 0.4, type: 'sine' },
player_join: { frequency: 520, duration: 0.12, type: 'sine' },
question_reveal: { frequency: 700, duration: 0.2, type: 'triangle' },
hover: { frequency: 400, duration: 0.03, type: 'sine' },
countdown: { frequency: 600, duration: 0.15, type: 'square' },
} }