Compare commits
2 Commits
e5a2b016a0
...
6248037b47
| Author | SHA1 | Date | |
|---|---|---|---|
| 6248037b47 | |||
| ab201e113a |
344
README.md
Normal file
344
README.md
Normal 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
45
backend/.env.example
Normal 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
|
||||
@@ -1,11 +1,15 @@
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import date
|
||||
from sqlalchemy import select, and_
|
||||
import random
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.question import Question
|
||||
from app.models.category import Category
|
||||
|
||||
# Number of categories per game
|
||||
CATEGORIES_PER_GAME = 5
|
||||
|
||||
|
||||
class QuestionService:
|
||||
async def get_daily_questions(
|
||||
@@ -64,16 +68,49 @@ class QuestionService:
|
||||
target_date: Optional[date] = None
|
||||
) -> Dict[str, List[dict]]:
|
||||
"""
|
||||
Genera el tablero 8×5 para el juego.
|
||||
Si no hay suficientes preguntas, retorna lo disponible.
|
||||
Genera el tablero 5×5 para el juego.
|
||||
Selecciona 5 categorías aleatorias y 1 pregunta por dificultad.
|
||||
|
||||
Returns:
|
||||
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
|
||||
return {str(k): v for k, v in board.items()}
|
||||
if not full_board:
|
||||
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(
|
||||
self,
|
||||
|
||||
@@ -59,9 +59,9 @@ class RoomManager:
|
||||
)
|
||||
|
||||
# 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]:
|
||||
"""Get room state by code."""
|
||||
|
||||
@@ -50,7 +50,7 @@ def register_socket_events(sio: socketio.AsyncServer):
|
||||
await room_manager.init_player_stats(room["code"], player_name)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -75,7 +75,7 @@ def register_socket_events(sio: socketio.AsyncServer):
|
||||
await room_manager.init_player_stats(room_code, player_name)
|
||||
|
||||
# Join socket room
|
||||
sio.enter_room(sid, room_code)
|
||||
await sio.enter_room(sid, room_code)
|
||||
|
||||
# Notify all players
|
||||
await sio.emit("player_joined", {"room": room}, room=room_code)
|
||||
@@ -147,13 +147,18 @@ def register_socket_events(sio: socketio.AsyncServer):
|
||||
)
|
||||
return
|
||||
|
||||
# Get board from data or generate
|
||||
board = data.get("board", {})
|
||||
|
||||
updated_room = await game_manager.start_game(room_code, board)
|
||||
# Load board from database and start game
|
||||
async with await get_db_session() as db:
|
||||
updated_room = await game_manager.start_game_with_db(db, room_code)
|
||||
|
||||
if updated_room:
|
||||
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
|
||||
async def select_question(sid, data):
|
||||
|
||||
492
docs/API.md
Normal file
492
docs/API.md
Normal 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
374
docs/ARCHITECTURE.md
Normal 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
469
docs/INSTALLATION.md
Normal 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
|
||||
```
|
||||
@@ -7,15 +7,12 @@ import { useThemeStyles } from '../../themes/ThemeProvider'
|
||||
interface SoundControlProps {
|
||||
/** Compact mode shows just the icon, expanded shows slider */
|
||||
compact?: boolean
|
||||
/** Position for the popup menu when in compact mode */
|
||||
popupPosition?: 'top' | 'bottom' | 'left' | 'right'
|
||||
/** Custom class name */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SoundControl({
|
||||
compact = false,
|
||||
popupPosition = 'top',
|
||||
className = '',
|
||||
}: SoundControlProps) {
|
||||
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) {
|
||||
return (
|
||||
<div
|
||||
@@ -158,20 +142,22 @@ export default function SoundControl({
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
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={{
|
||||
backgroundColor: config.colors.bg,
|
||||
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
|
||||
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 }}
|
||||
>
|
||||
{getVolumeIcon()}
|
||||
</button>
|
||||
{/* Vertical slider container */}
|
||||
<div className="relative h-24 w-6 flex items-center justify-center">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -179,11 +165,14 @@ export default function SoundControl({
|
||||
step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
||||
className="h-20 w-2 rounded-lg appearance-none cursor-pointer"
|
||||
style={{
|
||||
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%)`,
|
||||
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
|
||||
className="text-xs"
|
||||
style={{ color: config.colors.textMuted }}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useGameStore } from '../stores/gameStore'
|
||||
import { soundPlayer } from './useSound'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import { useSoundStore } from '../stores/soundStore'
|
||||
import { socketService } from '../services/socket'
|
||||
import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
|
||||
import type { Reaction } from '../stores/gameStore'
|
||||
|
||||
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
|
||||
|
||||
// Team message type
|
||||
export interface TeamMessage {
|
||||
player_name: string
|
||||
@@ -18,8 +16,7 @@ export interface TeamMessage {
|
||||
}
|
||||
|
||||
export function useSocket() {
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage } =
|
||||
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setGameResult, addReaction, addTeamMessage } =
|
||||
useGameStore()
|
||||
|
||||
// Initialize sound player with current theme
|
||||
@@ -27,23 +24,16 @@ export function useSocket() {
|
||||
soundPlayer.loadTheme(currentTheme)
|
||||
|
||||
useEffect(() => {
|
||||
// Create socket connection
|
||||
socketRef.current = io(SOCKET_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
autoConnect: true,
|
||||
})
|
||||
// Get singleton socket connection
|
||||
const socket = socketService.connect()
|
||||
|
||||
const socket = socketRef.current
|
||||
|
||||
// Connection events
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to server')
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server')
|
||||
})
|
||||
// Only set up listeners once globally
|
||||
if (socketService.isInitialized) {
|
||||
return // No cleanup - socket persists
|
||||
}
|
||||
socketService.setInitialized()
|
||||
|
||||
// Error handler
|
||||
socket.on('error', (data: { message: string }) => {
|
||||
console.error('Socket error:', data.message)
|
||||
})
|
||||
@@ -55,6 +45,9 @@ export function useSocket() {
|
||||
|
||||
socket.on('player_joined', (data: { room: GameRoom }) => {
|
||||
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 }) => {
|
||||
@@ -68,11 +61,22 @@ export function useSocket() {
|
||||
// Game events
|
||||
socket.on('game_started', (data: { room: GameRoom }) => {
|
||||
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 }) => {
|
||||
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) => {
|
||||
@@ -82,12 +86,17 @@ export function useSocket() {
|
||||
const volume = useSoundStore.getState().volume
|
||||
if (data.valid) {
|
||||
soundPlayer.play('correct', volume)
|
||||
// Clear current question after correct answer
|
||||
setCurrentQuestion(null)
|
||||
} else {
|
||||
soundPlayer.play('incorrect', volume)
|
||||
}
|
||||
|
||||
if (!data.valid && !data.was_steal && data.room.can_steal) {
|
||||
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 }) => {
|
||||
setRoom(data.room)
|
||||
setShowStealPrompt(false)
|
||||
setCurrentQuestion(null)
|
||||
})
|
||||
|
||||
socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => {
|
||||
@@ -111,6 +121,7 @@ export function useSocket() {
|
||||
setShowStealPrompt(true)
|
||||
} else {
|
||||
setShowStealPrompt(false)
|
||||
setCurrentQuestion(null)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -175,18 +186,16 @@ export function useSocket() {
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage])
|
||||
// No cleanup - socket connection persists across components
|
||||
}, [])
|
||||
|
||||
// Socket methods
|
||||
// Socket methods - use singleton service
|
||||
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') => {
|
||||
socketRef.current?.emit('join_room', {
|
||||
socketService.emit('join_room', {
|
||||
room_code: roomCode,
|
||||
player_name: playerName,
|
||||
team,
|
||||
@@ -194,15 +203,15 @@ export function useSocket() {
|
||||
}, [])
|
||||
|
||||
const changeTeam = useCallback((team: 'A' | 'B') => {
|
||||
socketRef.current?.emit('change_team', { team })
|
||||
socketService.emit('change_team', { team })
|
||||
}, [])
|
||||
|
||||
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) => {
|
||||
socketRef.current?.emit('select_question', {
|
||||
socketService.emit('select_question', {
|
||||
question_id: questionId,
|
||||
category_id: categoryId,
|
||||
})
|
||||
@@ -210,7 +219,7 @@ export function useSocket() {
|
||||
|
||||
const submitAnswer = useCallback(
|
||||
(answer: string, question: Record<string, unknown>, isSteal: boolean = false) => {
|
||||
socketRef.current?.emit('submit_answer', {
|
||||
socketService.emit('submit_answer', {
|
||||
answer,
|
||||
question,
|
||||
is_steal: isSteal,
|
||||
@@ -220,7 +229,7 @@ export function useSocket() {
|
||||
)
|
||||
|
||||
const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => {
|
||||
socketRef.current?.emit('steal_decision', {
|
||||
socketService.emit('steal_decision', {
|
||||
attempt,
|
||||
question_id: questionId,
|
||||
answer,
|
||||
@@ -228,15 +237,15 @@ export function useSocket() {
|
||||
}, [])
|
||||
|
||||
const sendChatMessage = useCallback((message: string) => {
|
||||
socketRef.current?.emit('chat_message', { message })
|
||||
socketService.emit('chat_message', { message })
|
||||
}, [])
|
||||
|
||||
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) => {
|
||||
socketRef.current?.emit('send_reaction', {
|
||||
socketService.emit('send_reaction', {
|
||||
emoji,
|
||||
room_code: roomCode,
|
||||
player_name: playerName,
|
||||
@@ -245,7 +254,7 @@ export function useSocket() {
|
||||
|
||||
const sendTeamMessage = useCallback(
|
||||
(message: string, roomCode: string, team: 'A' | 'B', playerName: string) => {
|
||||
socketRef.current?.emit('team_message', {
|
||||
socketService.emit('team_message', {
|
||||
room_code: roomCode,
|
||||
team,
|
||||
player_name: playerName,
|
||||
@@ -256,11 +265,11 @@ export function useSocket() {
|
||||
)
|
||||
|
||||
const notifyTimerExpired = useCallback(() => {
|
||||
socketRef.current?.emit('timer_expired', {})
|
||||
socketService.emit('timer_expired', {})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
socket: socketRef.current,
|
||||
socket: socketService.connect(),
|
||||
createRoom,
|
||||
joinRoom,
|
||||
changeTeam,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useSocket } from '../hooks/useSocket'
|
||||
@@ -11,16 +11,17 @@ import TeamChat from '../components/chat/TeamChat'
|
||||
import SoundControl from '../components/ui/SoundControl'
|
||||
import type { Question } from '../types'
|
||||
|
||||
const categories = [
|
||||
{ id: 1, name: 'Nintendo', icon: '🍄', color: '#E60012' },
|
||||
{ id: 2, name: 'Xbox', icon: '🎮', color: '#107C10' },
|
||||
{ id: 3, name: 'PlayStation', icon: '🎯', color: '#003791' },
|
||||
{ id: 4, name: 'Anime', icon: '⛩️', color: '#FF6B9D' },
|
||||
{ id: 5, name: 'Música', icon: '🎵', color: '#1DB954' },
|
||||
{ id: 6, name: 'Películas', icon: '🎬', color: '#F5C518' },
|
||||
{ id: 7, name: 'Libros', icon: '📚', color: '#8B4513' },
|
||||
{ id: 8, name: 'Historia-Cultura', icon: '🏛️', color: '#6B5B95' },
|
||||
]
|
||||
// All available categories with their styling
|
||||
const allCategories: Record<number, { name: string; icon: string; color: string }> = {
|
||||
1: { name: 'Nintendo', icon: '🍄', color: '#E60012' },
|
||||
2: { name: 'Xbox', icon: '🎮', color: '#107C10' },
|
||||
3: { name: 'PlayStation', icon: '🎯', color: '#003791' },
|
||||
4: { name: 'Anime', icon: '⛩️', color: '#FF6B9D' },
|
||||
5: { name: 'Música', icon: '🎵', color: '#1DB954' },
|
||||
6: { name: 'Películas', icon: '🎬', color: '#F5C518' },
|
||||
7: { name: 'Libros', icon: '📚', color: '#8B4513' },
|
||||
8: { name: 'Historia', icon: '🏛️', color: '#6B5B95' },
|
||||
}
|
||||
|
||||
export default function Game() {
|
||||
useParams<{ roomCode: string }>()
|
||||
@@ -32,7 +33,7 @@ export default function Game() {
|
||||
|
||||
const [answer, setAnswer] = useState('')
|
||||
const [timeLeft, setTimeLeft] = useState(0)
|
||||
const [showingQuestion, setShowingQuestion] = useState(false)
|
||||
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
|
||||
|
||||
// Redirect if game finished
|
||||
useEffect(() => {
|
||||
@@ -41,9 +42,16 @@ export default function Game() {
|
||||
}
|
||||
}, [room?.status, room?.code, navigate])
|
||||
|
||||
// Play sound when question is revealed
|
||||
useEffect(() => {
|
||||
if (currentQuestion) {
|
||||
play('question_reveal')
|
||||
}
|
||||
}, [currentQuestion, play])
|
||||
|
||||
// Timer logic with sound effects
|
||||
useEffect(() => {
|
||||
if (!currentQuestion || !showingQuestion) return
|
||||
if (!currentQuestion) return
|
||||
|
||||
setTimeLeft(currentQuestion.time_seconds)
|
||||
const interval = setInterval(() => {
|
||||
@@ -52,11 +60,11 @@ export default function Game() {
|
||||
clearInterval(interval)
|
||||
return 0
|
||||
}
|
||||
// Play urgent sound when time is running low (5 seconds or less)
|
||||
if (prev <= 6 && prev > 1) {
|
||||
if (prev <= 4 && prev > 1) {
|
||||
play('countdown')
|
||||
} else if (prev <= 6 && prev > 4) {
|
||||
play('timer_urgent')
|
||||
} else if (prev > 6) {
|
||||
// Play tick sound for normal countdown
|
||||
play('timer_tick')
|
||||
}
|
||||
return prev - 1
|
||||
@@ -64,12 +72,25 @@ export default function Game() {
|
||||
}, 1000)
|
||||
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -85,164 +106,379 @@ export default function Game() {
|
||||
if (!amICurrentPlayer || question.answered) return
|
||||
play('select')
|
||||
selectQuestion(question.id, categoryId)
|
||||
setShowingQuestion(true)
|
||||
}
|
||||
|
||||
const handleSubmitAnswer = () => {
|
||||
if (!currentQuestion || !answer.trim()) return
|
||||
submitAnswer(answer, currentQuestion as unknown as Record<string, unknown>, room.can_steal)
|
||||
setAnswer('')
|
||||
setShowingQuestion(false)
|
||||
}
|
||||
|
||||
const handleStealDecision = (attempt: boolean) => {
|
||||
if (!currentQuestion) return
|
||||
if (attempt) {
|
||||
setShowingQuestion(true)
|
||||
} else {
|
||||
if (!attempt) {
|
||||
stealDecision(false, currentQuestion.id)
|
||||
}
|
||||
setShowStealPrompt(false)
|
||||
}
|
||||
|
||||
// Handler for sending team messages
|
||||
const handleSendTeamMessage = (message: string) => {
|
||||
if (room && playerName && myTeam) {
|
||||
sendTeamMessage(message, room.code, myTeam, playerName)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if the game is active (playing status)
|
||||
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 (
|
||||
<div className="min-h-screen p-4" style={styles.bgPrimary}>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Scoreboard */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div
|
||||
className="text-center px-6 py-2 rounded-lg"
|
||||
style={{
|
||||
backgroundColor: config.colors.primary + '20',
|
||||
border: `2px solid ${config.colors.primary}`,
|
||||
}}
|
||||
<div className="min-h-screen p-2 md:p-4 overflow-hidden" style={styles.bgPrimary}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header with Room Code */}
|
||||
<div className="text-center mb-4">
|
||||
<motion.h1
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="text-2xl md:text-3xl font-bold tracking-wider"
|
||||
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
|
||||
>
|
||||
<div className="text-sm" style={styles.textSecondary}>Equipo A</div>
|
||||
<div className="text-3xl font-bold" style={{ color: config.colors.primary }}>
|
||||
{room.scores.A}
|
||||
TRIVY
|
||||
</motion.h1>
|
||||
<div className="text-xs opacity-60" style={{ color: config.colors.textMuted }}>
|
||||
Sala: {room.code}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-sm" style={styles.textSecondary}>
|
||||
Turno de {room.current_team === 'A' ? 'Equipo A' : 'Equipo B'}
|
||||
{/* Scoreboard */}
|
||||
<div className="flex justify-between items-stretch gap-4 mb-4">
|
||||
{/* Team A 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 === 'A' ? 'ring-2 ring-offset-2' : ''
|
||||
}`}
|
||||
style={{
|
||||
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>
|
||||
</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 && (
|
||||
<div className="text-lg font-bold" style={{ color: config.colors.accent }}>
|
||||
¡Tu turno!
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div
|
||||
className="text-center px-6 py-2 rounded-lg"
|
||||
{/* 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={{
|
||||
backgroundColor: config.colors.secondary + '20',
|
||||
border: `2px solid ${config.colors.secondary}`,
|
||||
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`
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Game Board */}
|
||||
<div className="grid grid-cols-8 gap-2 mb-6">
|
||||
{/* Category Headers */}
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="text-center p-2 rounded-t-lg"
|
||||
style={{ backgroundColor: cat.color }}
|
||||
className="grid"
|
||||
style={{ gridTemplateColumns: `repeat(${numCategories}, minmax(0, 1fr))` }}
|
||||
>
|
||||
<div className="text-2xl">{cat.icon}</div>
|
||||
<div className="text-xs text-white font-bold truncate">{cat.name}</div>
|
||||
{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 */}
|
||||
{[1, 2, 3, 4, 5].map((difficulty) =>
|
||||
categories.map((cat) => {
|
||||
{[1, 2, 3, 4, 5].map((difficulty, rowIndex) => (
|
||||
<div
|
||||
key={difficulty}
|
||||
className="grid"
|
||||
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 (
|
||||
<motion.button
|
||||
key={`${cat.id}-${difficulty}`}
|
||||
whileHover={!isAnswered && amICurrentPlayer ? { scale: 1.05 } : {}}
|
||||
whileTap={!isAnswered && amICurrentPlayer ? { scale: 0.95 } : {}}
|
||||
key={cellId}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: (rowIndex * 8 + colIndex) * 0.02 + 0.3 }}
|
||||
whileHover={canSelect ? {
|
||||
scale: 1.08,
|
||||
zIndex: 10,
|
||||
boxShadow: `0 0 25px ${cat.color}80`
|
||||
} : {}}
|
||||
whileTap={canSelect ? { scale: 0.95 } : {}}
|
||||
onClick={() => question && handleSelectQuestion(question, cat.id)}
|
||||
onMouseEnter={() => handleCellHover(cellId, !!canSelect)}
|
||||
onMouseLeave={() => setHoveredCell(null)}
|
||||
disabled={isAnswered || !amICurrentPlayer}
|
||||
className={`p-4 rounded transition-all ${
|
||||
isAnswered ? 'opacity-30' : amICurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
|
||||
className={`relative aspect-[4/3] md:aspect-square flex items-center justify-center transition-all duration-200 ${
|
||||
isAnswered
|
||||
? 'cursor-default'
|
||||
: canSelect
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isAnswered ? config.colors.bg : cat.color + '40',
|
||||
border: `2px solid ${cat.color}`,
|
||||
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),
|
||||
}}
|
||||
>
|
||||
<span className="text-xl font-bold" style={{ color: config.colors.text }}>
|
||||
{difficulty * 100}
|
||||
{/* 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 */}
|
||||
<AnimatePresence>
|
||||
{showingQuestion && currentQuestion && (
|
||||
{currentQuestion && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="w-full max-w-lg p-6 rounded-lg"
|
||||
initial={{ scale: 0.8, y: 50, rotateX: -15 }}
|
||||
animate={{ scale: 1, y: 0, rotateX: 0 }}
|
||||
exit={{ scale: 0.8, y: 50, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
className="w-full max-w-2xl p-6 md:p-8 rounded-2xl relative overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: config.colors.bg,
|
||||
border: `3px solid ${config.colors.primary}`,
|
||||
background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
|
||||
border: `4px solid ${config.colors.primary}`,
|
||||
boxShadow: `0 0 60px ${config.colors.primary}40, inset 0 0 60px ${config.colors.primary}10`
|
||||
}}
|
||||
>
|
||||
{/* Timer */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-sm" style={styles.textSecondary}>
|
||||
{currentQuestion.points} puntos
|
||||
</span>
|
||||
<div
|
||||
className={`text-2xl font-bold ${timeLeft <= 5 ? 'text-red-500 animate-pulse' : ''}`}
|
||||
style={{ color: timeLeft > 5 ? config.colors.primary : undefined }}
|
||||
>
|
||||
{timeLeft}s
|
||||
{/* Timer Bar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-2 bg-black/30 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: `${timerProgress}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="h-full"
|
||||
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)'
|
||||
}}
|
||||
>
|
||||
${currentQuestion.points}
|
||||
</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>
|
||||
|
||||
{/* Question */}
|
||||
<p className="text-xl mb-6 text-center" style={{ color: config.colors.text }}>
|
||||
{currentQuestion.question_text || 'Pregunta de ejemplo: ¿En qué año se lanzó la NES?'}
|
||||
</p>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
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 */}
|
||||
{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
|
||||
type="text"
|
||||
value={answer}
|
||||
@@ -250,30 +486,49 @@ export default function Game() {
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()}
|
||||
placeholder="Escribe tu respuesta..."
|
||||
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={{
|
||||
border: `2px solid ${config.colors.primary}`,
|
||||
border: `3px solid ${config.colors.primary}50`,
|
||||
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}
|
||||
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={{
|
||||
backgroundColor: config.colors.primary,
|
||||
background: `linear-gradient(135deg, ${config.colors.primary} 0%, ${config.colors.accent} 100%)`,
|
||||
color: config.colors.bg,
|
||||
boxShadow: answer.trim() ? `0 5px 30px ${config.colors.primary}50` : 'none'
|
||||
}}
|
||||
>
|
||||
Responder
|
||||
</button>
|
||||
</div>
|
||||
RESPONDER
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!amICurrentPlayer && (
|
||||
<p className="text-center" style={styles.textSecondary}>
|
||||
Esperando respuesta de {currentPlayer?.name}...
|
||||
</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
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>
|
||||
@@ -287,68 +542,82 @@ export default function Game() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="p-6 rounded-lg text-center"
|
||||
initial={{ scale: 0.5, rotate: -5 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
exit={{ scale: 0.5, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 15 }}
|
||||
className="p-8 rounded-2xl text-center max-w-md"
|
||||
style={{
|
||||
backgroundColor: config.colors.bg,
|
||||
border: `3px solid ${config.colors.accent}`,
|
||||
background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
|
||||
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 }}>
|
||||
¡Oportunidad de Robo!
|
||||
<motion.div
|
||||
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>
|
||||
<p className="mb-6" style={styles.textSecondary}>
|
||||
El equipo contrario falló. ¿Quieres intentar robar los puntos?
|
||||
<br />
|
||||
<span className="text-red-500">Advertencia: Si fallas, perderás puntos</span>
|
||||
<p className="mb-2" style={{ color: config.colors.textMuted }}>
|
||||
El equipo contrario falló
|
||||
</p>
|
||||
<p className="mb-6 text-red-400 text-sm">
|
||||
⚠️ Si fallas, perderás puntos
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => handleStealDecision(true)}
|
||||
className="px-6 py-3 rounded-lg font-bold"
|
||||
className="px-8 py-4 rounded-xl font-bold text-lg"
|
||||
style={{
|
||||
backgroundColor: config.colors.accent,
|
||||
color: config.colors.bg,
|
||||
background: `linear-gradient(135deg, ${config.colors.accent} 0%, #FF6B6B 100%)`,
|
||||
color: '#FFF',
|
||||
boxShadow: `0 5px 30px ${config.colors.accent}50`
|
||||
}}
|
||||
>
|
||||
¡Robar!
|
||||
</button>
|
||||
<button
|
||||
¡ROBAR!
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => handleStealDecision(false)}
|
||||
className="px-6 py-3 rounded-lg font-bold"
|
||||
className="px-8 py-4 rounded-xl font-bold text-lg"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: config.colors.text,
|
||||
border: `2px solid ${config.colors.text}`,
|
||||
border: `2px solid ${config.colors.text}50`,
|
||||
}}
|
||||
>
|
||||
Pasar
|
||||
</button>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Emoji Reactions Bar - Fixed at bottom */}
|
||||
{/* Emoji Reactions Bar */}
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
|
||||
<EmojiReactions />
|
||||
</div>
|
||||
|
||||
{/* Sound Control - Fixed at top right */}
|
||||
{/* Sound Control */}
|
||||
<div className="fixed top-4 right-4 z-30">
|
||||
<SoundControl compact popupPosition="bottom" />
|
||||
<SoundControl compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reaction Overlay - Full screen overlay for floating reactions */}
|
||||
<ReactionOverlay />
|
||||
|
||||
{/* Team Chat - Only visible during the game */}
|
||||
{isGameActive && (
|
||||
<TeamChat
|
||||
roomCode={room.code}
|
||||
|
||||
@@ -4,15 +4,16 @@ const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
|
||||
|
||||
class SocketService {
|
||||
private socket: Socket | null = null
|
||||
private listeners: Map<string, Set<(data: unknown) => void>> = new Map()
|
||||
private initialized = false
|
||||
|
||||
connect(): Socket {
|
||||
if (!this.socket) {
|
||||
console.log('Creating new socket connection to:', SOCKET_URL)
|
||||
this.socket = io(SOCKET_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionAttempts: 10,
|
||||
reconnectionDelay: 1000,
|
||||
})
|
||||
|
||||
@@ -24,9 +25,12 @@ class SocketService {
|
||||
console.log('Socket disconnected:', reason)
|
||||
})
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
console.error('Socket error:', error)
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('Socket connection error:', error)
|
||||
})
|
||||
} else if (!this.socket.connected) {
|
||||
console.log('Reconnecting socket...')
|
||||
this.socket.connect()
|
||||
}
|
||||
|
||||
return this.socket
|
||||
@@ -36,25 +40,35 @@ class SocketService {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect()
|
||||
this.socket = null
|
||||
this.initialized = false
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, callback: (data: unknown) => void): void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set())
|
||||
}
|
||||
this.listeners.get(event)!.add(callback)
|
||||
|
||||
this.socket?.on(event, callback)
|
||||
on(event: string, callback: (...args: unknown[]) => void): void {
|
||||
const socket = this.connect()
|
||||
// Remove existing listener to prevent duplicates
|
||||
socket.off(event, callback)
|
||||
socket.on(event, callback)
|
||||
}
|
||||
|
||||
off(event: string, callback: (data: unknown) => void): void {
|
||||
this.listeners.get(event)?.delete(callback)
|
||||
off(event: string, callback?: (...args: unknown[]) => void): void {
|
||||
if (callback) {
|
||||
this.socket?.off(event, callback)
|
||||
} else {
|
||||
this.socket?.off(event)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -64,6 +78,15 @@ class SocketService {
|
||||
get id(): string | undefined {
|
||||
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()
|
||||
|
||||
@@ -11,6 +11,11 @@ export type SoundEffect =
|
||||
| 'victory'
|
||||
| 'defeat'
|
||||
| 'select'
|
||||
| 'game_start'
|
||||
| 'player_join'
|
||||
| 'question_reveal'
|
||||
| 'hover'
|
||||
| 'countdown'
|
||||
|
||||
interface SoundState {
|
||||
volume: number
|
||||
@@ -56,6 +61,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
||||
victory: '/sounds/drrr/victory.mp3',
|
||||
defeat: '/sounds/drrr/defeat.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: {
|
||||
correct: '/sounds/retro/correct.mp3',
|
||||
@@ -66,6 +76,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
||||
victory: '/sounds/retro/victory.mp3',
|
||||
defeat: '/sounds/retro/defeat.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: {
|
||||
correct: '/sounds/minimal/correct.mp3',
|
||||
@@ -76,6 +91,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
||||
victory: '/sounds/minimal/victory.mp3',
|
||||
defeat: '/sounds/minimal/defeat.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: {
|
||||
correct: '/sounds/rgb/correct.mp3',
|
||||
@@ -86,6 +106,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
||||
victory: '/sounds/rgb/victory.mp3',
|
||||
defeat: '/sounds/rgb/defeat.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: {
|
||||
correct: '/sounds/anime/correct.mp3',
|
||||
@@ -96,6 +121,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
||||
victory: '/sounds/anime/victory.mp3',
|
||||
defeat: '/sounds/anime/defeat.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' },
|
||||
defeat: { frequency: 196, duration: 0.5, 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' },
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user