feat: Initial project structure for WebTriviasMulti
- Backend: FastAPI + Python-SocketIO + SQLAlchemy - Models for categories, questions, game sessions, events - AI services for answer validation and question generation (Claude) - Room management with Redis - Game logic with stealing mechanics - Admin API for question management - Frontend: React + Vite + TypeScript + Tailwind - 5 visual themes (DRRR, Retro, Minimal, RGB, Anime 90s) - Real-time game with Socket.IO - Achievement system - Replay functionality - Sound effects per theme - Docker Compose for deployment - Design documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Backend
|
||||||
|
DATABASE_URL=postgresql://trivia:trivia@db:5432/trivia
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-your-api-key
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
VITE_API_URL=http://localhost:8000
|
||||||
|
VITE_WS_URL=ws://localhost:8000
|
||||||
|
|
||||||
|
# Cloudflare Tunnel
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token
|
||||||
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
ENV/
|
||||||
|
.eggs/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Alembic
|
||||||
|
alembic/versions/*.pyc
|
||||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# WebTriviasMulti Backend
|
||||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# API routers
|
||||||
296
backend/app/api/admin.py
Normal file
296
backend/app/api/admin.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.models.base import get_db
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.question import Question
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.schemas.admin import AdminCreate, Token, TokenData
|
||||||
|
from app.schemas.question import (
|
||||||
|
QuestionCreate, QuestionUpdate, QuestionResponse,
|
||||||
|
AIGenerateRequest
|
||||||
|
)
|
||||||
|
from app.services.ai_generator import ai_generator
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/login")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.jwt_expire_minutes)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_admin(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> Admin:
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
|
||||||
|
)
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
result = await db.execute(select(Admin).where(Admin.username == username))
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
if admin is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Admin).where(Admin.username == form_data.username)
|
||||||
|
)
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not admin or not verify_password(form_data.password, admin.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": admin.username})
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=Token)
|
||||||
|
async def register_admin(
|
||||||
|
admin_data: AdminCreate,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
# Check if admin exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(Admin).where(Admin.username == admin_data.username)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Username already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create admin
|
||||||
|
admin = Admin(
|
||||||
|
username=admin_data.username,
|
||||||
|
password_hash=get_password_hash(admin_data.password)
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": admin.username})
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
# Question Management
|
||||||
|
|
||||||
|
@router.get("/questions", response_model=List[QuestionResponse])
|
||||||
|
async def get_questions(
|
||||||
|
category_id: int = None,
|
||||||
|
status: str = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: Admin = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
query = select(Question)
|
||||||
|
if category_id:
|
||||||
|
query = query.where(Question.category_id == category_id)
|
||||||
|
if status:
|
||||||
|
query = query.where(Question.status == status)
|
||||||
|
|
||||||
|
result = await db.execute(query.order_by(Question.created_at.desc()))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/questions", response_model=QuestionResponse)
|
||||||
|
async def create_question(
|
||||||
|
question_data: QuestionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: Admin = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
question = Question(
|
||||||
|
**question_data.model_dump(),
|
||||||
|
points=settings.default_points.get(question_data.difficulty, 300),
|
||||||
|
time_seconds=settings.default_times.get(question_data.difficulty, 25)
|
||||||
|
)
|
||||||
|
db.add(question)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(question)
|
||||||
|
return question
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/questions/{question_id}", response_model=QuestionResponse)
|
||||||
|
async def update_question(
|
||||||
|
question_id: int,
|
||||||
|
question_data: QuestionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: Admin = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Question).where(Question.id == question_id))
|
||||||
|
question = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not question:
|
||||||
|
raise HTTPException(status_code=404, detail="Question not found")
|
||||||
|
|
||||||
|
for key, value in question_data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(question, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(question)
|
||||||
|
return question
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/questions/{question_id}")
|
||||||
|
async def delete_question(
|
||||||
|
question_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: Admin = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Question).where(Question.id == question_id))
|
||||||
|
question = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not question:
|
||||||
|
raise HTTPException(status_code=404, detail="Question not found")
|
||||||
|
|
||||||
|
await db.delete(question)
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/questions/generate")
|
||||||
|
async def generate_questions(
|
||||||
|
request: AIGenerateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: Admin = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
# Get category name
|
||||||
|
result = await db.execute(
|
||||||
|
select(Category).where(Category.id == request.category_id)
|
||||||
|
)
|
||||||
|
category = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
|
# Generate questions with AI
|
||||||
|
generated = await ai_generator.generate_questions(
|
||||||
|
category_name=category.name,
|
||||||
|
difficulty=request.difficulty,
|
||||||
|
count=request.count
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to database as pending
|
||||||
|
questions = []
|
||||||
|
for q_data in generated:
|
||||||
|
question = Question(
|
||||||
|
category_id=request.category_id,
|
||||||
|
question_text=q_data["question"],
|
||||||
|
correct_answer=q_data["correct_answer"],
|
||||||
|
alt_answers=q_data.get("alt_answers", []),
|
||||||
|
difficulty=q_data["difficulty"],
|
||||||
|
points=q_data["points"],
|
||||||
|
time_seconds=q_data["time_seconds"],
|
||||||
|
fun_fact=q_data.get("fun_fact"),
|
||||||
|
status="pending"
|
||||||
|
)
|
||||||
|
db.add(question)
|
||||||
|
questions.append(question)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated": len(questions),
|
||||||
|
"questions": [q.id for q in questions]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/questions/{question_id}/approve")
|
||||||
|
async def approve_question(
|
||||||
|
question_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: Admin = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Question).where(Question.id == question_id))
|
||||||
|
question = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not question:
|
||||||
|
raise HTTPException(status_code=404, detail="Question not found")
|
||||||
|
|
||||||
|
question.status = "approved"
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "approved"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/questions/{question_id}/reject")
|
||||||
|
async def reject_question(
|
||||||
|
question_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: Admin = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Question).where(Question.id == question_id))
|
||||||
|
question = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not question:
|
||||||
|
raise HTTPException(status_code=404, detail="Question not found")
|
||||||
|
|
||||||
|
await db.delete(question)
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "rejected"}
|
||||||
|
|
||||||
|
|
||||||
|
# Categories
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def get_categories(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: Admin = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Category))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories")
|
||||||
|
async def create_category(
|
||||||
|
name: str,
|
||||||
|
icon: str = None,
|
||||||
|
color: str = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: Admin = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
category = Category(name=name, icon=icon, color=color)
|
||||||
|
db.add(category)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(category)
|
||||||
|
return category
|
||||||
119
backend/app/api/game.py
Normal file
119
backend/app/api/game.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import date
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from app.models.base import get_db
|
||||||
|
from app.models.question import Question
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.schemas.game import RoomCreate, RoomJoin, GameState
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def get_game_categories():
|
||||||
|
"""Get all categories for the game board."""
|
||||||
|
# Return hardcoded categories for now
|
||||||
|
# In production, these would come from the database
|
||||||
|
return [
|
||||||
|
{"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"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/board/{room_code}")
|
||||||
|
async def get_game_board(room_code: str):
|
||||||
|
"""
|
||||||
|
Get the game board with questions for today.
|
||||||
|
Returns questions grouped by category.
|
||||||
|
"""
|
||||||
|
from app.services.room_manager import room_manager
|
||||||
|
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
|
|
||||||
|
# If board already exists in room, return it
|
||||||
|
if room.get("board"):
|
||||||
|
return room["board"]
|
||||||
|
|
||||||
|
# Otherwise, this would load from database
|
||||||
|
# For now, return empty board structure
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today-questions")
|
||||||
|
async def get_today_questions():
|
||||||
|
"""
|
||||||
|
Get all approved questions for today, grouped by category and difficulty.
|
||||||
|
This is used to build the game board.
|
||||||
|
"""
|
||||||
|
# This would query the database for questions with date_active = today
|
||||||
|
# For now, return sample structure
|
||||||
|
return {
|
||||||
|
"date": str(date.today()),
|
||||||
|
"categories": {
|
||||||
|
"1": { # Nintendo
|
||||||
|
"name": "Nintendo",
|
||||||
|
"questions": [
|
||||||
|
{"difficulty": 1, "id": 1, "points": 100},
|
||||||
|
{"difficulty": 2, "id": 2, "points": 200},
|
||||||
|
{"difficulty": 3, "id": 3, "points": 300},
|
||||||
|
{"difficulty": 4, "id": 4, "points": 400},
|
||||||
|
{"difficulty": 5, "id": 5, "points": 500},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
# ... other categories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/question/{question_id}")
|
||||||
|
async def get_question(question_id: int):
|
||||||
|
"""
|
||||||
|
Get a specific question (without the answer).
|
||||||
|
Used when a player selects a question.
|
||||||
|
"""
|
||||||
|
# This would query the database
|
||||||
|
# For now, return sample
|
||||||
|
return {
|
||||||
|
"id": question_id,
|
||||||
|
"question_text": "¿En qué año se lanzó la primera consola Nintendo Entertainment System (NES) en Japón?",
|
||||||
|
"difficulty": 3,
|
||||||
|
"points": 300,
|
||||||
|
"time_seconds": 25,
|
||||||
|
"category_id": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/achievements")
|
||||||
|
async def get_achievements():
|
||||||
|
"""Get list of all available achievements."""
|
||||||
|
return [
|
||||||
|
{"id": 1, "name": "Primera Victoria", "description": "Ganar tu primera partida", "icon": "🏆"},
|
||||||
|
{"id": 2, "name": "Racha de 3", "description": "Responder 3 correctas seguidas", "icon": "🔥"},
|
||||||
|
{"id": 3, "name": "Racha de 5", "description": "Responder 5 correctas seguidas", "icon": "🔥🔥"},
|
||||||
|
{"id": 4, "name": "Ladrón Novato", "description": "Primer robo exitoso", "icon": "🦝"},
|
||||||
|
{"id": 5, "name": "Ladrón Maestro", "description": "5 robos exitosos en una partida", "icon": "🦝👑"},
|
||||||
|
{"id": 6, "name": "Especialista Nintendo", "description": "10 correctas en Nintendo", "icon": "🍄"},
|
||||||
|
{"id": 7, "name": "Especialista Xbox", "description": "10 correctas en Xbox", "icon": "🎮"},
|
||||||
|
{"id": 8, "name": "Especialista PlayStation", "description": "10 correctas en PlayStation", "icon": "🎯"},
|
||||||
|
{"id": 9, "name": "Especialista Anime", "description": "10 correctas en Anime", "icon": "⛩️"},
|
||||||
|
{"id": 10, "name": "Especialista Música", "description": "10 correctas en Música", "icon": "🎵"},
|
||||||
|
{"id": 11, "name": "Especialista Películas", "description": "10 correctas en Películas", "icon": "🎬"},
|
||||||
|
{"id": 12, "name": "Especialista Libros", "description": "10 correctas en Libros", "icon": "📚"},
|
||||||
|
{"id": 13, "name": "Especialista Historia", "description": "10 correctas en Historia-Cultura", "icon": "🏛️"},
|
||||||
|
{"id": 14, "name": "Invicto", "description": "Ganar sin fallar ninguna pregunta", "icon": "⭐"},
|
||||||
|
{"id": 15, "name": "Velocista", "description": "Responder correctamente en menos de 3 segundos", "icon": "⚡"},
|
||||||
|
{"id": 16, "name": "Comeback", "description": "Ganar estando 500+ puntos abajo", "icon": "🔄"},
|
||||||
|
{"id": 17, "name": "Dominio Total", "description": "Responder las 5 preguntas de una categoría", "icon": "👑"},
|
||||||
|
{"id": 18, "name": "Arriesgado", "description": "Responder correctamente 3 preguntas de 500 pts", "icon": "🎰"},
|
||||||
|
]
|
||||||
113
backend/app/api/replay.py
Normal file
113
backend/app/api/replay.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.models.base import get_db
|
||||||
|
from app.models.game_session import GameSession
|
||||||
|
from app.models.game_event import GameEvent
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}")
|
||||||
|
async def get_replay(
|
||||||
|
session_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get replay data for a game session.
|
||||||
|
Returns all events in chronological order.
|
||||||
|
"""
|
||||||
|
# Get session
|
||||||
|
result = await db.execute(
|
||||||
|
select(GameSession).where(GameSession.id == session_id)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
# Get all events
|
||||||
|
events_result = await db.execute(
|
||||||
|
select(GameEvent)
|
||||||
|
.where(GameEvent.session_id == session_id)
|
||||||
|
.order_by(GameEvent.timestamp)
|
||||||
|
)
|
||||||
|
events = events_result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session": {
|
||||||
|
"id": session.id,
|
||||||
|
"room_code": session.room_code,
|
||||||
|
"team_a_score": session.team_a_score,
|
||||||
|
"team_b_score": session.team_b_score,
|
||||||
|
"status": session.status,
|
||||||
|
"created_at": session.created_at,
|
||||||
|
"finished_at": session.finished_at
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": e.id,
|
||||||
|
"event_type": e.event_type,
|
||||||
|
"player_name": e.player_name,
|
||||||
|
"team": e.team,
|
||||||
|
"question_id": e.question_id,
|
||||||
|
"answer_given": e.answer_given,
|
||||||
|
"was_correct": e.was_correct,
|
||||||
|
"was_steal": e.was_steal,
|
||||||
|
"points_earned": e.points_earned,
|
||||||
|
"timestamp": e.timestamp
|
||||||
|
}
|
||||||
|
for e in events
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/code/{room_code}")
|
||||||
|
async def get_replay_by_code(
|
||||||
|
room_code: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get replay data by room code.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(GameSession).where(GameSession.room_code == room_code)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
return await get_replay(session.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_replays(
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List recent finished game sessions.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(GameSession)
|
||||||
|
.where(GameSession.status == "finished")
|
||||||
|
.order_by(GameSession.finished_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
sessions = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": s.id,
|
||||||
|
"room_code": s.room_code,
|
||||||
|
"team_a_score": s.team_a_score,
|
||||||
|
"team_b_score": s.team_b_score,
|
||||||
|
"finished_at": s.finished_at
|
||||||
|
}
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
47
backend/app/config.py
Normal file
47
backend/app/config.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# Database
|
||||||
|
database_url: str = "postgresql://trivia:trivia@localhost:5432/trivia"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_url: str = "redis://localhost:6379"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
jwt_secret: str = "dev-secret-key-change-in-production"
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
jwt_expire_minutes: int = 1440 # 24 hours
|
||||||
|
|
||||||
|
# Anthropic
|
||||||
|
anthropic_api_key: str = ""
|
||||||
|
|
||||||
|
# Game settings
|
||||||
|
default_times: dict = {
|
||||||
|
1: 15, # 100 pts
|
||||||
|
2: 20, # 200 pts
|
||||||
|
3: 25, # 300 pts
|
||||||
|
4: 35, # 400 pts
|
||||||
|
5: 45, # 500 pts
|
||||||
|
}
|
||||||
|
|
||||||
|
default_points: dict = {
|
||||||
|
1: 100,
|
||||||
|
2: 200,
|
||||||
|
3: 300,
|
||||||
|
4: 400,
|
||||||
|
5: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
steal_penalty_multiplier: float = 0.5
|
||||||
|
steal_time_multiplier: float = 0.5
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
76
backend/app/main.py
Normal file
76
backend/app/main.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import socketio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.api import admin, game, replay
|
||||||
|
from app.sockets.game_events import register_socket_events
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Socket.IO server
|
||||||
|
sio = socketio.AsyncServer(
|
||||||
|
async_mode="asgi",
|
||||||
|
cors_allowed_origins="*",
|
||||||
|
logger=True,
|
||||||
|
engineio_logger=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup
|
||||||
|
print("Starting WebTriviasMulti server...")
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
print("Shutting down WebTriviasMulti server...")
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title="WebTriviasMulti API",
|
||||||
|
description="API para el juego de trivia multiplayer",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||||
|
app.include_router(game.router, prefix="/api/game", tags=["game"])
|
||||||
|
app.include_router(replay.router, prefix="/api/replay", tags=["replay"])
|
||||||
|
|
||||||
|
# Register Socket.IO events
|
||||||
|
register_socket_events(sio)
|
||||||
|
|
||||||
|
# Mount Socket.IO
|
||||||
|
socket_app = socketio.ASGIApp(sio, app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {
|
||||||
|
"message": "WebTriviasMulti API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
|
# For running with uvicorn directly
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run("app.main:socket_app", host="0.0.0.0", port=8000, reload=True)
|
||||||
7
backend/app/models/__init__.py
Normal file
7
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from app.models.category import Category
|
||||||
|
from app.models.question import Question
|
||||||
|
from app.models.game_session import GameSession
|
||||||
|
from app.models.game_event import GameEvent
|
||||||
|
from app.models.admin import Admin
|
||||||
|
|
||||||
|
__all__ = ["Category", "Question", "GameSession", "GameEvent", "Admin"]
|
||||||
15
backend/app/models/admin.py
Normal file
15
backend/app/models/admin.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Admin(Base):
|
||||||
|
__tablename__ = "admins"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Admin(id={self.id}, username='{self.username}')>"
|
||||||
27
backend/app/models/base.py
Normal file
27
backend/app/models/base.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Convert postgresql:// to postgresql+asyncpg://
|
||||||
|
database_url = settings.database_url.replace(
|
||||||
|
"postgresql://", "postgresql+asyncpg://"
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_async_engine(database_url, echo=True)
|
||||||
|
|
||||||
|
AsyncSessionLocal = sessionmaker(
|
||||||
|
engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
18
backend/app/models/category.py
Normal file
18
backend/app/models/category.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False, unique=True)
|
||||||
|
icon = Column(String(50))
|
||||||
|
color = Column(String(7)) # Hex color
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
questions = relationship("Question", back_populates="category")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Category(id={self.id}, name='{self.name}')>"
|
||||||
27
backend/app/models/game_event.py
Normal file
27
backend/app/models/game_event.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GameEvent(Base):
|
||||||
|
__tablename__ = "game_events"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
session_id = Column(Integer, ForeignKey("game_sessions.id"), nullable=False)
|
||||||
|
event_type = Column(String(50), nullable=False) # question_selected, answer_submitted, steal_attempted, etc.
|
||||||
|
player_name = Column(String(100))
|
||||||
|
team = Column(String(1)) # 'A' or 'B'
|
||||||
|
question_id = Column(Integer, ForeignKey("questions.id"))
|
||||||
|
answer_given = Column(Text)
|
||||||
|
was_correct = Column(Boolean)
|
||||||
|
was_steal = Column(Boolean, default=False)
|
||||||
|
points_earned = Column(Integer)
|
||||||
|
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
session = relationship("GameSession", back_populates="events")
|
||||||
|
question = relationship("Question", back_populates="game_events")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<GameEvent(id={self.id}, type='{self.event_type}', player='{self.player_name}')>"
|
||||||
24
backend/app/models/game_session.py
Normal file
24
backend/app/models/game_session.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ARRAY
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GameSession(Base):
|
||||||
|
__tablename__ = "game_sessions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
room_code = Column(String(6), unique=True, nullable=False, index=True)
|
||||||
|
status = Column(String(20), default="waiting") # waiting, playing, finished
|
||||||
|
team_a_score = Column(Integer, default=0)
|
||||||
|
team_b_score = Column(Integer, default=0)
|
||||||
|
current_team = Column(String(1)) # 'A' or 'B'
|
||||||
|
questions_used = Column(ARRAY(Integer), default=[])
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
finished_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
events = relationship("GameEvent", back_populates="session")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<GameSession(id={self.id}, room_code='{self.room_code}', status='{self.status}')>"
|
||||||
28
backend/app/models/question.py
Normal file
28
backend/app/models/question.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, ForeignKey, ARRAY
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Question(Base):
|
||||||
|
__tablename__ = "questions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=False)
|
||||||
|
question_text = Column(Text, nullable=False)
|
||||||
|
correct_answer = Column(String(500), nullable=False)
|
||||||
|
alt_answers = Column(ARRAY(String), default=[])
|
||||||
|
difficulty = Column(Integer, nullable=False) # 1-5
|
||||||
|
points = Column(Integer, nullable=False)
|
||||||
|
time_seconds = Column(Integer, nullable=False)
|
||||||
|
date_active = Column(Date, index=True)
|
||||||
|
status = Column(String(20), default="pending") # pending, approved, used
|
||||||
|
fun_fact = Column(Text)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
category = relationship("Category", back_populates="questions")
|
||||||
|
game_events = relationship("GameEvent", back_populates="question")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Question(id={self.id}, difficulty={self.difficulty}, status='{self.status}')>"
|
||||||
17
backend/app/schemas/__init__.py
Normal file
17
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from app.schemas.question import QuestionCreate, QuestionUpdate, QuestionResponse
|
||||||
|
from app.schemas.game import (
|
||||||
|
RoomCreate,
|
||||||
|
RoomJoin,
|
||||||
|
PlayerInfo,
|
||||||
|
GameState,
|
||||||
|
AnswerSubmit,
|
||||||
|
StealAttempt
|
||||||
|
)
|
||||||
|
from app.schemas.admin import AdminCreate, AdminLogin, Token
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"QuestionCreate", "QuestionUpdate", "QuestionResponse",
|
||||||
|
"RoomCreate", "RoomJoin", "PlayerInfo", "GameState",
|
||||||
|
"AnswerSubmit", "StealAttempt",
|
||||||
|
"AdminCreate", "AdminLogin", "Token"
|
||||||
|
]
|
||||||
31
backend/app/schemas/admin.py
Normal file
31
backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AdminBase(BaseModel):
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCreate(AdminBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLogin(AdminBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdminResponse(AdminBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
70
backend/app/schemas/game.py
Normal file
70
backend/app/schemas/game.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerInfo(BaseModel):
|
||||||
|
name: str
|
||||||
|
team: str # 'A' or 'B'
|
||||||
|
position: int
|
||||||
|
socket_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoomCreate(BaseModel):
|
||||||
|
player_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class RoomJoin(BaseModel):
|
||||||
|
room_code: str
|
||||||
|
player_name: str
|
||||||
|
team: str # 'A' or 'B'
|
||||||
|
|
||||||
|
|
||||||
|
class TeamState(BaseModel):
|
||||||
|
players: List[PlayerInfo]
|
||||||
|
score: int
|
||||||
|
current_player_index: int
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionState(BaseModel):
|
||||||
|
id: int
|
||||||
|
category_id: int
|
||||||
|
difficulty: int
|
||||||
|
points: int
|
||||||
|
answered: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class GameState(BaseModel):
|
||||||
|
room_code: str
|
||||||
|
status: str # waiting, playing, finished
|
||||||
|
team_a: TeamState
|
||||||
|
team_b: TeamState
|
||||||
|
current_team: Optional[str] = None
|
||||||
|
current_question: Optional[int] = None
|
||||||
|
can_steal: bool = False
|
||||||
|
board: Dict[int, List[QuestionState]] # category_id -> questions
|
||||||
|
timer_end: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnswerSubmit(BaseModel):
|
||||||
|
question_id: int
|
||||||
|
answer: str
|
||||||
|
|
||||||
|
|
||||||
|
class StealAttempt(BaseModel):
|
||||||
|
question_id: int
|
||||||
|
attempt: bool # True = try to steal, False = pass
|
||||||
|
answer: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
player_name: str
|
||||||
|
team: str
|
||||||
|
message: str
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiReaction(BaseModel):
|
||||||
|
player_name: str
|
||||||
|
team: str
|
||||||
|
emoji: str # One of: 👏 😮 😂 🔥 💀 🎉 😭 🤔
|
||||||
61
backend/app/schemas/question.py
Normal file
61
backend/app/schemas/question.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionBase(BaseModel):
|
||||||
|
question_text: str
|
||||||
|
correct_answer: str
|
||||||
|
alt_answers: List[str] = []
|
||||||
|
difficulty: int
|
||||||
|
fun_fact: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionCreate(QuestionBase):
|
||||||
|
category_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionUpdate(BaseModel):
|
||||||
|
question_text: Optional[str] = None
|
||||||
|
correct_answer: Optional[str] = None
|
||||||
|
alt_answers: Optional[List[str]] = None
|
||||||
|
difficulty: Optional[int] = None
|
||||||
|
fun_fact: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
date_active: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionResponse(QuestionBase):
|
||||||
|
id: int
|
||||||
|
category_id: int
|
||||||
|
points: int
|
||||||
|
time_seconds: int
|
||||||
|
date_active: Optional[date]
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionForGame(BaseModel):
|
||||||
|
id: int
|
||||||
|
category_id: int
|
||||||
|
question_text: str
|
||||||
|
difficulty: int
|
||||||
|
points: int
|
||||||
|
time_seconds: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AIGenerateRequest(BaseModel):
|
||||||
|
category_id: int
|
||||||
|
difficulty: int
|
||||||
|
count: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class AIValidateRequest(BaseModel):
|
||||||
|
question_id: int
|
||||||
|
player_answer: str
|
||||||
6
backend/app/services/__init__.py
Normal file
6
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from app.services.ai_validator import AIValidator
|
||||||
|
from app.services.ai_generator import AIGenerator
|
||||||
|
from app.services.game_manager import GameManager
|
||||||
|
from app.services.room_manager import RoomManager
|
||||||
|
|
||||||
|
__all__ = ["AIValidator", "AIGenerator", "GameManager", "RoomManager"]
|
||||||
97
backend/app/services/ai_generator.py
Normal file
97
backend/app/services/ai_generator.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import json
|
||||||
|
from anthropic import Anthropic
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class AIGenerator:
|
||||||
|
def __init__(self):
|
||||||
|
self.client = Anthropic(api_key=settings.anthropic_api_key)
|
||||||
|
|
||||||
|
async def generate_questions(
|
||||||
|
self,
|
||||||
|
category_name: str,
|
||||||
|
difficulty: int,
|
||||||
|
count: int = 5
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Generate trivia questions using Claude AI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category_name: Name of the category (e.g., "Nintendo", "Anime")
|
||||||
|
difficulty: 1-5 (1=very easy, 5=very hard)
|
||||||
|
count: Number of questions to generate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict]: List of question objects
|
||||||
|
"""
|
||||||
|
difficulty_descriptions = {
|
||||||
|
1: "muy fácil - conocimiento básico que la mayoría conoce",
|
||||||
|
2: "fácil - conocimiento común entre fans casuales",
|
||||||
|
3: "medio - requiere ser fan de la categoría",
|
||||||
|
4: "difícil - conocimiento profundo del tema",
|
||||||
|
5: "muy difícil - solo expertos conocerían esto"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = f"""Genera {count} preguntas de trivia para la categoría "{category_name}".
|
||||||
|
Dificultad: {difficulty} ({difficulty_descriptions.get(difficulty, 'medio')})
|
||||||
|
|
||||||
|
Requisitos:
|
||||||
|
- Las preguntas deben ser verificables y precisas
|
||||||
|
- Evitar ambigüedades
|
||||||
|
- Las respuestas deben ser específicas y concisas
|
||||||
|
- Incluir variaciones comunes de la respuesta
|
||||||
|
- Para gaming: referencias a juegos, personajes, mecánicas, fechas de lanzamiento
|
||||||
|
- Para anime: personajes, series, estudios, seiyuus
|
||||||
|
- Para música: artistas, canciones, álbumes, letras famosas
|
||||||
|
- Para películas: actores, directores, frases icónicas, premios
|
||||||
|
- Para libros: autores, obras, personajes literarios
|
||||||
|
- Para historia-cultura: eventos, fechas, personajes históricos, arte
|
||||||
|
|
||||||
|
Formato JSON (array de objetos):
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"question": "texto de la pregunta",
|
||||||
|
"correct_answer": "respuesta principal",
|
||||||
|
"alt_answers": ["variación1", "variación2"],
|
||||||
|
"fun_fact": "dato curioso opcional sobre la respuesta"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
|
||||||
|
Responde SOLO con el JSON, sin texto adicional."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = self.client.messages.create(
|
||||||
|
model="claude-3-5-sonnet-20241022",
|
||||||
|
max_tokens=2000,
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
response_text = message.content[0].text.strip()
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
questions = json.loads(response_text)
|
||||||
|
|
||||||
|
# Add metadata to each question
|
||||||
|
for q in questions:
|
||||||
|
q["difficulty"] = difficulty
|
||||||
|
q["points"] = settings.default_points.get(difficulty, 300)
|
||||||
|
q["time_seconds"] = settings.default_times.get(difficulty, 25)
|
||||||
|
|
||||||
|
return questions
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Error parsing AI response: {e}")
|
||||||
|
print(f"Response was: {response_text}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating questions: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
ai_generator = AIGenerator()
|
||||||
80
backend/app/services/ai_validator.py
Normal file
80
backend/app/services/ai_validator.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import json
|
||||||
|
from anthropic import Anthropic
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class AIValidator:
|
||||||
|
def __init__(self):
|
||||||
|
self.client = Anthropic(api_key=settings.anthropic_api_key)
|
||||||
|
|
||||||
|
async def validate_answer(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
correct_answer: str,
|
||||||
|
alt_answers: list[str],
|
||||||
|
player_answer: str
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Validate if the player's answer is correct using Claude AI.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"valid": bool, "reason": str}
|
||||||
|
"""
|
||||||
|
prompt = f"""Eres un validador de trivia. Determina si la respuesta del jugador
|
||||||
|
es correcta comparándola con la respuesta oficial.
|
||||||
|
|
||||||
|
Pregunta: {question}
|
||||||
|
Respuesta correcta: {correct_answer}
|
||||||
|
Respuestas alternativas válidas: {', '.join(alt_answers) if alt_answers else 'Ninguna'}
|
||||||
|
Respuesta del jugador: {player_answer}
|
||||||
|
|
||||||
|
Considera válido si:
|
||||||
|
- Es sinónimo o variación de la respuesta correcta
|
||||||
|
- Tiene errores menores de ortografía
|
||||||
|
- Usa abreviaciones comunes (ej: "BOTW" = "Breath of the Wild")
|
||||||
|
- Es conceptualmente equivalente
|
||||||
|
|
||||||
|
Responde SOLO con JSON: {{"valid": true/false, "reason": "breve explicación"}}"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = self.client.messages.create(
|
||||||
|
model="claude-3-haiku-20240307",
|
||||||
|
max_tokens=150,
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
response_text = message.content[0].text.strip()
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
result = json.loads(response_text)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# If JSON parsing fails, try to extract the result
|
||||||
|
response_lower = response_text.lower()
|
||||||
|
if "true" in response_lower:
|
||||||
|
return {"valid": True, "reason": "Respuesta validada por IA"}
|
||||||
|
return {"valid": False, "reason": "No se pudo validar la respuesta"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error validating answer: {e}")
|
||||||
|
# Fallback to exact match
|
||||||
|
player_lower = player_answer.lower().strip()
|
||||||
|
correct_lower = correct_answer.lower().strip()
|
||||||
|
|
||||||
|
if player_lower == correct_lower:
|
||||||
|
return {"valid": True, "reason": "Coincidencia exacta"}
|
||||||
|
|
||||||
|
for alt in alt_answers:
|
||||||
|
if player_lower == alt.lower().strip():
|
||||||
|
return {"valid": True, "reason": "Coincide con respuesta alternativa"}
|
||||||
|
|
||||||
|
return {"valid": False, "reason": "Respuesta incorrecta"}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
ai_validator = AIValidator()
|
||||||
204
backend/app/services/game_manager.py
Normal file
204
backend/app/services/game_manager.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.services.room_manager import room_manager
|
||||||
|
from app.services.ai_validator import ai_validator
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class GameManager:
|
||||||
|
async def start_game(self, room_code: str, board: dict) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Start a game in a room.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_code: The room code
|
||||||
|
board: Dict of category_id -> list of questions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated room state
|
||||||
|
"""
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check minimum players
|
||||||
|
if not room["teams"]["A"] or not room["teams"]["B"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Set up game state
|
||||||
|
room["status"] = "playing"
|
||||||
|
room["current_team"] = "A"
|
||||||
|
room["current_player_index"] = {"A": 0, "B": 0}
|
||||||
|
room["board"] = board
|
||||||
|
room["scores"] = {"A": 0, "B": 0}
|
||||||
|
|
||||||
|
await room_manager.update_room(room_code, room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
async def select_question(
|
||||||
|
self,
|
||||||
|
room_code: str,
|
||||||
|
question_id: int,
|
||||||
|
category_id: int
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Select a question from the board."""
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
if not room or room["status"] != "playing":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Mark question as current
|
||||||
|
room["current_question"] = question_id
|
||||||
|
room["can_steal"] = False
|
||||||
|
|
||||||
|
# Find and mark question on board
|
||||||
|
if str(category_id) in room["board"]:
|
||||||
|
for q in room["board"][str(category_id)]:
|
||||||
|
if q["id"] == question_id:
|
||||||
|
q["selected"] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
await room_manager.update_room(room_code, room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
async def submit_answer(
|
||||||
|
self,
|
||||||
|
room_code: str,
|
||||||
|
question: dict,
|
||||||
|
player_answer: str,
|
||||||
|
is_steal: bool = False
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Submit an answer for validation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with validation result and updated game state
|
||||||
|
"""
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
return {"error": "Room not found"}
|
||||||
|
|
||||||
|
# Validate answer with AI
|
||||||
|
result = await ai_validator.validate_answer(
|
||||||
|
question=question["question_text"],
|
||||||
|
correct_answer=question["correct_answer"],
|
||||||
|
alt_answers=question.get("alt_answers", []),
|
||||||
|
player_answer=player_answer
|
||||||
|
)
|
||||||
|
|
||||||
|
is_correct = result.get("valid", False)
|
||||||
|
points = question["points"]
|
||||||
|
|
||||||
|
if is_correct:
|
||||||
|
# Award points
|
||||||
|
current_team = room["current_team"]
|
||||||
|
room["scores"][current_team] += points
|
||||||
|
|
||||||
|
# Mark question as answered
|
||||||
|
category_id = str(question["category_id"])
|
||||||
|
if category_id in room["board"]:
|
||||||
|
for q in room["board"][category_id]:
|
||||||
|
if q["id"] == question["id"]:
|
||||||
|
q["answered"] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Winner chooses next
|
||||||
|
room["current_question"] = None
|
||||||
|
room["can_steal"] = False
|
||||||
|
|
||||||
|
# Advance player rotation
|
||||||
|
team_players = room["teams"][current_team]
|
||||||
|
room["current_player_index"][current_team] = (
|
||||||
|
room["current_player_index"][current_team] + 1
|
||||||
|
) % len(team_players)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if is_steal:
|
||||||
|
# Failed steal - penalize
|
||||||
|
stealing_team = room["current_team"]
|
||||||
|
penalty = int(points * settings.steal_penalty_multiplier)
|
||||||
|
room["scores"][stealing_team] = max(
|
||||||
|
0, room["scores"][stealing_team] - penalty
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark question as answered (nobody gets it)
|
||||||
|
category_id = str(question["category_id"])
|
||||||
|
if category_id in room["board"]:
|
||||||
|
for q in room["board"][category_id]:
|
||||||
|
if q["id"] == question["id"]:
|
||||||
|
q["answered"] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Original team chooses next
|
||||||
|
room["current_team"] = "B" if stealing_team == "A" else "A"
|
||||||
|
room["current_question"] = None
|
||||||
|
room["can_steal"] = False
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Original team failed - enable steal
|
||||||
|
room["can_steal"] = True
|
||||||
|
# Switch to other team for potential steal
|
||||||
|
room["current_team"] = "B" if room["current_team"] == "A" else "A"
|
||||||
|
|
||||||
|
# Check if game is over (all questions answered)
|
||||||
|
all_answered = all(
|
||||||
|
q["answered"]
|
||||||
|
for questions in room["board"].values()
|
||||||
|
for q in questions
|
||||||
|
)
|
||||||
|
if all_answered:
|
||||||
|
room["status"] = "finished"
|
||||||
|
|
||||||
|
await room_manager.update_room(room_code, room)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": is_correct,
|
||||||
|
"reason": result.get("reason", ""),
|
||||||
|
"points_earned": points if is_correct else 0,
|
||||||
|
"room": room
|
||||||
|
}
|
||||||
|
|
||||||
|
async def pass_steal(self, room_code: str, question_id: int) -> Optional[dict]:
|
||||||
|
"""Pass on stealing opportunity."""
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Mark question as answered
|
||||||
|
for category_id, questions in room["board"].items():
|
||||||
|
for q in questions:
|
||||||
|
if q["id"] == question_id:
|
||||||
|
q["answered"] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Switch back to original team for next selection
|
||||||
|
room["current_team"] = "B" if room["current_team"] == "A" else "A"
|
||||||
|
room["current_question"] = None
|
||||||
|
room["can_steal"] = False
|
||||||
|
|
||||||
|
await room_manager.update_room(room_code, room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
async def get_current_player(self, room: dict) -> Optional[dict]:
|
||||||
|
"""Get the current player who should answer."""
|
||||||
|
team = room["current_team"]
|
||||||
|
if not team:
|
||||||
|
return None
|
||||||
|
|
||||||
|
players = room["teams"][team]
|
||||||
|
if not players:
|
||||||
|
return None
|
||||||
|
|
||||||
|
index = room["current_player_index"][team]
|
||||||
|
return players[index % len(players)]
|
||||||
|
|
||||||
|
def calculate_timer_end(self, time_seconds: int, is_steal: bool = False) -> datetime:
|
||||||
|
"""Calculate when the timer should end."""
|
||||||
|
if is_steal:
|
||||||
|
time_seconds = int(time_seconds * settings.steal_time_multiplier)
|
||||||
|
return datetime.utcnow() + timedelta(seconds=time_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
game_manager = GameManager()
|
||||||
173
backend/app/services/room_manager.py
Normal file
173
backend/app/services/room_manager.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import json
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from typing import Optional
|
||||||
|
import redis.asyncio as redis
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class RoomManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.redis: Optional[redis.Redis] = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
if not self.redis:
|
||||||
|
self.redis = await redis.from_url(settings.redis_url)
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
if self.redis:
|
||||||
|
await self.redis.close()
|
||||||
|
|
||||||
|
def _generate_room_code(self) -> str:
|
||||||
|
"""Generate a 6-character room code."""
|
||||||
|
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||||
|
|
||||||
|
async def create_room(self, player_name: str, socket_id: str) -> dict:
|
||||||
|
"""Create a new game room."""
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
# Generate unique room code
|
||||||
|
room_code = self._generate_room_code()
|
||||||
|
while await self.redis.exists(f"room:{room_code}"):
|
||||||
|
room_code = self._generate_room_code()
|
||||||
|
|
||||||
|
# Create room state
|
||||||
|
room_state = {
|
||||||
|
"code": room_code,
|
||||||
|
"status": "waiting",
|
||||||
|
"host": player_name,
|
||||||
|
"teams": {
|
||||||
|
"A": [],
|
||||||
|
"B": []
|
||||||
|
},
|
||||||
|
"current_team": None,
|
||||||
|
"current_player_index": {"A": 0, "B": 0},
|
||||||
|
"current_question": None,
|
||||||
|
"can_steal": False,
|
||||||
|
"scores": {"A": 0, "B": 0},
|
||||||
|
"questions_used": [],
|
||||||
|
"board": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save room state
|
||||||
|
await self.redis.setex(
|
||||||
|
f"room:{room_code}",
|
||||||
|
3600 * 3, # 3 hours TTL
|
||||||
|
json.dumps(room_state)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add player to room
|
||||||
|
await self.add_player(room_code, player_name, "A", socket_id)
|
||||||
|
|
||||||
|
return room_state
|
||||||
|
|
||||||
|
async def get_room(self, room_code: str) -> Optional[dict]:
|
||||||
|
"""Get room state by code."""
|
||||||
|
await self.connect()
|
||||||
|
data = await self.redis.get(f"room:{room_code}")
|
||||||
|
if data:
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_room(self, room_code: str, room_state: dict) -> bool:
|
||||||
|
"""Update room state."""
|
||||||
|
await self.connect()
|
||||||
|
await self.redis.setex(
|
||||||
|
f"room:{room_code}",
|
||||||
|
3600 * 3,
|
||||||
|
json.dumps(room_state)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def add_player(
|
||||||
|
self,
|
||||||
|
room_code: str,
|
||||||
|
player_name: str,
|
||||||
|
team: str,
|
||||||
|
socket_id: str
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Add a player to a room."""
|
||||||
|
room = await self.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if team is full
|
||||||
|
if len(room["teams"][team]) >= 4:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if name is taken
|
||||||
|
for t in ["A", "B"]:
|
||||||
|
for p in room["teams"][t]:
|
||||||
|
if p["name"].lower() == player_name.lower():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Add player
|
||||||
|
player = {
|
||||||
|
"name": player_name,
|
||||||
|
"team": team,
|
||||||
|
"position": len(room["teams"][team]),
|
||||||
|
"socket_id": socket_id
|
||||||
|
}
|
||||||
|
room["teams"][team].append(player)
|
||||||
|
|
||||||
|
# Save player mapping
|
||||||
|
await self.redis.setex(
|
||||||
|
f"player:{socket_id}",
|
||||||
|
3600 * 3,
|
||||||
|
json.dumps({"name": player_name, "room": room_code, "team": team})
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.update_room(room_code, room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
async def remove_player(self, socket_id: str) -> Optional[dict]:
|
||||||
|
"""Remove a player from their room."""
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
# Get player info
|
||||||
|
player_data = await self.redis.get(f"player:{socket_id}")
|
||||||
|
if not player_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
player_info = json.loads(player_data)
|
||||||
|
room_code = player_info["room"]
|
||||||
|
team = player_info["team"]
|
||||||
|
|
||||||
|
# Get room
|
||||||
|
room = await self.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Remove player from team
|
||||||
|
room["teams"][team] = [
|
||||||
|
p for p in room["teams"][team] if p["socket_id"] != socket_id
|
||||||
|
]
|
||||||
|
|
||||||
|
# Update positions
|
||||||
|
for i, p in enumerate(room["teams"][team]):
|
||||||
|
p["position"] = i
|
||||||
|
|
||||||
|
# Delete player mapping
|
||||||
|
await self.redis.delete(f"player:{socket_id}")
|
||||||
|
|
||||||
|
# If room is empty, delete it
|
||||||
|
if not room["teams"]["A"] and not room["teams"]["B"]:
|
||||||
|
await self.redis.delete(f"room:{room_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
await self.update_room(room_code, room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
async def get_player(self, socket_id: str) -> Optional[dict]:
|
||||||
|
"""Get player info by socket ID."""
|
||||||
|
await self.connect()
|
||||||
|
data = await self.redis.get(f"player:{socket_id}")
|
||||||
|
if data:
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
room_manager = RoomManager()
|
||||||
1
backend/app/sockets/__init__.py
Normal file
1
backend/app/sockets/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Socket.IO events
|
||||||
312
backend/app/sockets/game_events.py
Normal file
312
backend/app/sockets/game_events.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import socketio
|
||||||
|
from datetime import datetime
|
||||||
|
from app.services.room_manager import room_manager
|
||||||
|
from app.services.game_manager import game_manager
|
||||||
|
|
||||||
|
|
||||||
|
def register_socket_events(sio: socketio.AsyncServer):
|
||||||
|
"""Register all Socket.IO event handlers."""
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def connect(sid, environ):
|
||||||
|
print(f"Client connected: {sid}")
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def disconnect(sid):
|
||||||
|
print(f"Client disconnected: {sid}")
|
||||||
|
# Remove player from room
|
||||||
|
room = await room_manager.remove_player(sid)
|
||||||
|
if room:
|
||||||
|
await sio.emit(
|
||||||
|
"player_left",
|
||||||
|
{"room": room},
|
||||||
|
room=room["code"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def create_room(sid, data):
|
||||||
|
"""Create a new game room."""
|
||||||
|
player_name = data.get("player_name", "Player")
|
||||||
|
|
||||||
|
room = await room_manager.create_room(player_name, sid)
|
||||||
|
|
||||||
|
# Join socket room
|
||||||
|
sio.enter_room(sid, room["code"])
|
||||||
|
|
||||||
|
await sio.emit("room_created", {"room": room}, to=sid)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def join_room(sid, data):
|
||||||
|
"""Join an existing room."""
|
||||||
|
room_code = data.get("room_code", "").upper()
|
||||||
|
player_name = data.get("player_name", "Player")
|
||||||
|
team = data.get("team", "A")
|
||||||
|
|
||||||
|
room = await room_manager.add_player(room_code, player_name, team, sid)
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
await sio.emit(
|
||||||
|
"error",
|
||||||
|
{"message": "Could not join room. It may be full or the name is taken."},
|
||||||
|
to=sid
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Join socket room
|
||||||
|
sio.enter_room(sid, room_code)
|
||||||
|
|
||||||
|
# Notify all players
|
||||||
|
await sio.emit("player_joined", {"room": room}, room=room_code)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def change_team(sid, data):
|
||||||
|
"""Switch player to another team."""
|
||||||
|
player = await room_manager.get_player(sid)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
|
||||||
|
room_code = player["room"]
|
||||||
|
new_team = data.get("team")
|
||||||
|
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
if not room or len(room["teams"][new_team]) >= 4:
|
||||||
|
await sio.emit(
|
||||||
|
"error",
|
||||||
|
{"message": "Cannot change team. It may be full."},
|
||||||
|
to=sid
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove from current team
|
||||||
|
current_team = player["team"]
|
||||||
|
room["teams"][current_team] = [
|
||||||
|
p for p in room["teams"][current_team] if p["socket_id"] != sid
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add to new team
|
||||||
|
room["teams"][new_team].append({
|
||||||
|
"name": player["name"],
|
||||||
|
"team": new_team,
|
||||||
|
"position": len(room["teams"][new_team]),
|
||||||
|
"socket_id": sid
|
||||||
|
})
|
||||||
|
|
||||||
|
await room_manager.update_room(room_code, room)
|
||||||
|
await sio.emit("team_changed", {"room": room}, room=room_code)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def start_game(sid, data):
|
||||||
|
"""Start the game (host only)."""
|
||||||
|
player = await room_manager.get_player(sid)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
|
||||||
|
room_code = player["room"]
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if player is host
|
||||||
|
if room["host"] != player["name"]:
|
||||||
|
await sio.emit(
|
||||||
|
"error",
|
||||||
|
{"message": "Only the host can start the game."},
|
||||||
|
to=sid
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check minimum players
|
||||||
|
if not room["teams"]["A"] or not room["teams"]["B"]:
|
||||||
|
await sio.emit(
|
||||||
|
"error",
|
||||||
|
{"message": "Both teams need at least one player."},
|
||||||
|
to=sid
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get board from data or generate
|
||||||
|
board = data.get("board", {})
|
||||||
|
|
||||||
|
updated_room = await game_manager.start_game(room_code, board)
|
||||||
|
|
||||||
|
if updated_room:
|
||||||
|
await sio.emit("game_started", {"room": updated_room}, room=room_code)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def select_question(sid, data):
|
||||||
|
"""Select a question from the board."""
|
||||||
|
player = await room_manager.get_player(sid)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
|
||||||
|
room_code = player["room"]
|
||||||
|
question_id = data.get("question_id")
|
||||||
|
category_id = data.get("category_id")
|
||||||
|
|
||||||
|
room = await game_manager.select_question(room_code, question_id, category_id)
|
||||||
|
|
||||||
|
if room:
|
||||||
|
# Get current player info
|
||||||
|
current_player = await game_manager.get_current_player(room)
|
||||||
|
|
||||||
|
await sio.emit(
|
||||||
|
"question_selected",
|
||||||
|
{
|
||||||
|
"room": room,
|
||||||
|
"question_id": question_id,
|
||||||
|
"current_player": current_player
|
||||||
|
},
|
||||||
|
room=room_code
|
||||||
|
)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def submit_answer(sid, data):
|
||||||
|
"""Submit an answer to the current question."""
|
||||||
|
player = await room_manager.get_player(sid)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
|
||||||
|
room_code = player["room"]
|
||||||
|
answer = data.get("answer", "")
|
||||||
|
question = data.get("question", {})
|
||||||
|
is_steal = data.get("is_steal", False)
|
||||||
|
|
||||||
|
result = await game_manager.submit_answer(
|
||||||
|
room_code, question, answer, is_steal
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
await sio.emit("error", {"message": result["error"]}, to=sid)
|
||||||
|
return
|
||||||
|
|
||||||
|
await sio.emit(
|
||||||
|
"answer_result",
|
||||||
|
{
|
||||||
|
"player_name": player["name"],
|
||||||
|
"team": player["team"],
|
||||||
|
"answer": answer,
|
||||||
|
"valid": result["valid"],
|
||||||
|
"reason": result["reason"],
|
||||||
|
"points_earned": result["points_earned"],
|
||||||
|
"was_steal": is_steal,
|
||||||
|
"room": result["room"]
|
||||||
|
},
|
||||||
|
room=room_code
|
||||||
|
)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def steal_decision(sid, data):
|
||||||
|
"""Decide whether to attempt stealing."""
|
||||||
|
player = await room_manager.get_player(sid)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
|
||||||
|
room_code = player["room"]
|
||||||
|
attempt = data.get("attempt", False)
|
||||||
|
question_id = data.get("question_id")
|
||||||
|
|
||||||
|
if not attempt:
|
||||||
|
# Pass on steal
|
||||||
|
room = await game_manager.pass_steal(room_code, question_id)
|
||||||
|
if room:
|
||||||
|
await sio.emit(
|
||||||
|
"steal_passed",
|
||||||
|
{"room": room, "team": player["team"]},
|
||||||
|
room=room_code
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Will attempt steal - just notify, answer comes separately
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
await sio.emit(
|
||||||
|
"steal_attempted",
|
||||||
|
{
|
||||||
|
"team": player["team"],
|
||||||
|
"player_name": player["name"],
|
||||||
|
"room": room
|
||||||
|
},
|
||||||
|
room=room_code
|
||||||
|
)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def chat_message(sid, data):
|
||||||
|
"""Send a chat message to team."""
|
||||||
|
player = await room_manager.get_player(sid)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
|
||||||
|
room_code = player["room"]
|
||||||
|
message = data.get("message", "")[:500] # Limit message length
|
||||||
|
|
||||||
|
# Get all team members' socket IDs
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
return
|
||||||
|
|
||||||
|
team_sockets = [
|
||||||
|
p["socket_id"] for p in room["teams"][player["team"]]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Send only to team members
|
||||||
|
for socket_id in team_sockets:
|
||||||
|
await sio.emit(
|
||||||
|
"chat_message",
|
||||||
|
{
|
||||||
|
"player_name": player["name"],
|
||||||
|
"team": player["team"],
|
||||||
|
"message": message,
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
},
|
||||||
|
to=socket_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def emoji_reaction(sid, data):
|
||||||
|
"""Send an emoji reaction visible to all."""
|
||||||
|
player = await room_manager.get_player(sid)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
|
||||||
|
room_code = player["room"]
|
||||||
|
emoji = data.get("emoji", "")
|
||||||
|
|
||||||
|
# Validate emoji
|
||||||
|
allowed_emojis = ["👏", "😮", "😂", "🔥", "💀", "🎉", "😭", "🤔"]
|
||||||
|
if emoji not in allowed_emojis:
|
||||||
|
return
|
||||||
|
|
||||||
|
await sio.emit(
|
||||||
|
"emoji_reaction",
|
||||||
|
{
|
||||||
|
"player_name": player["name"],
|
||||||
|
"team": player["team"],
|
||||||
|
"emoji": emoji
|
||||||
|
},
|
||||||
|
room=room_code
|
||||||
|
)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def timer_expired(sid, data):
|
||||||
|
"""Handle timer expiration."""
|
||||||
|
player = await room_manager.get_player(sid)
|
||||||
|
if not player:
|
||||||
|
return
|
||||||
|
|
||||||
|
room_code = player["room"]
|
||||||
|
room = await room_manager.get_room(room_code)
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Treat as wrong answer
|
||||||
|
if room["can_steal"]:
|
||||||
|
# Steal timer expired - pass
|
||||||
|
question_id = room["current_question"]
|
||||||
|
room = await game_manager.pass_steal(room_code, question_id)
|
||||||
|
await sio.emit("time_up", {"room": room, "was_steal": True}, room=room_code)
|
||||||
|
else:
|
||||||
|
# Answer timer expired - enable steal
|
||||||
|
room["can_steal"] = True
|
||||||
|
room["current_team"] = "B" if room["current_team"] == "A" else "A"
|
||||||
|
await room_manager.update_room(room_code, room)
|
||||||
|
await sio.emit("time_up", {"room": room, "was_steal": False}, room=room_code)
|
||||||
37
backend/requirements.txt
Normal file
37
backend/requirements.txt
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# FastAPI
|
||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|
||||||
|
# WebSockets
|
||||||
|
python-socketio==5.11.0
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy==2.0.25
|
||||||
|
asyncpg==0.29.0
|
||||||
|
alembic==1.13.1
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis==5.0.1
|
||||||
|
aioredis==2.0.1
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
|
||||||
|
# AI
|
||||||
|
anthropic==0.18.1
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
pydantic==2.6.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
|
# Scheduler
|
||||||
|
apscheduler==3.10.4
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==8.0.0
|
||||||
|
pytest-asyncio==0.23.4
|
||||||
|
httpx==0.26.0
|
||||||
63
docker-compose.yml
Normal file
63
docker-compose.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=${VITE_API_URL:-http://localhost:8000}
|
||||||
|
- VITE_WS_URL=${VITE_WS_URL:-ws://localhost:8000}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL:-postgresql://trivia:trivia@db:5432/trivia}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-dev-secret-key}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=trivia
|
||||||
|
- POSTGRES_USER=trivia
|
||||||
|
- POSTGRES_PASSWORD=trivia
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
cloudflared:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
command: tunnel run
|
||||||
|
environment:
|
||||||
|
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
profiles:
|
||||||
|
- production
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
659
docs/plans/2026-01-26-webtriviasmulti-design.md
Normal file
659
docs/plans/2026-01-26-webtriviasmulti-design.md
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
# WebTriviasMulti - Documento de Diseño
|
||||||
|
|
||||||
|
**Fecha:** 2026-01-26
|
||||||
|
**Versión:** 1.0
|
||||||
|
**Estado:** Aprobado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visión General
|
||||||
|
|
||||||
|
### 1.1 Descripción
|
||||||
|
WebTriviasMulti es una aplicación web de trivia multiplayer en tiempo real, inspirada en el formato de Jeopardy. Permite partidas entre 2 equipos de hasta 4 jugadores cada uno, con preguntas organizadas por categorías y niveles de dificultad.
|
||||||
|
|
||||||
|
### 1.2 Características Principales
|
||||||
|
- Partidas en tiempo real con WebSockets
|
||||||
|
- 8 categorías temáticas: Nintendo, Xbox, PlayStation, Anime, Música, Películas, Libros, Historia-Cultura
|
||||||
|
- Tablero estilo Jeopardy (5 niveles de dificultad por categoría)
|
||||||
|
- Sistema de "robo" de puntos entre equipos
|
||||||
|
- Validación de respuestas mediante IA (Claude)
|
||||||
|
- Generación automática de preguntas con aprobación de administrador
|
||||||
|
- 5 temas visuales intercambiables
|
||||||
|
- Sistema de logros
|
||||||
|
- Replays de partidas
|
||||||
|
- Sonidos temáticos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Mecánicas del Juego
|
||||||
|
|
||||||
|
### 2.1 Flujo de Partida
|
||||||
|
|
||||||
|
1. **Creación de sala**: Un jugador crea sala y recibe código de 6 caracteres
|
||||||
|
2. **Lobby**: Jugadores se unen con el código, eligen equipo (máx 4 por equipo), ingresan nombre
|
||||||
|
3. **Inicio**: El host inicia cuando hay al menos 1 jugador por equipo
|
||||||
|
4. **Tablero**: Se muestra el tablero con 8 categorías × 5 preguntas (100-500 pts)
|
||||||
|
5. **Turno**: El jugador en rotación del equipo activo selecciona una pregunta
|
||||||
|
6. **Respuesta**: Tiene X segundos (según dificultad) para escribir respuesta
|
||||||
|
7. **Validación IA**: Claude valida si la respuesta es correcta
|
||||||
|
8. **Robo opcional**: Si falla, equipo contrario decide si intenta robar
|
||||||
|
9. **Siguiente turno**: El equipo ganador elige siguiente pregunta
|
||||||
|
10. **Final**: Partida termina cuando se agotan las preguntas
|
||||||
|
|
||||||
|
### 2.2 Sistema de Turnos
|
||||||
|
- **Rotación obligatoria**: Cada pregunta la responde un miembro diferente del equipo
|
||||||
|
- **El que acierta elige**: El equipo que responde correctamente (o roba) elige la siguiente pregunta
|
||||||
|
|
||||||
|
### 2.3 Sistema de Puntos y Tiempo
|
||||||
|
|
||||||
|
| Dificultad | Puntos | Tiempo |
|
||||||
|
|------------|--------|--------|
|
||||||
|
| 1 (Fácil) | 100 | 15 seg |
|
||||||
|
| 2 | 200 | 20 seg |
|
||||||
|
| 3 (Media) | 300 | 25 seg |
|
||||||
|
| 4 | 400 | 35 seg |
|
||||||
|
| 5 (Difícil)| 500 | 45 seg |
|
||||||
|
|
||||||
|
### 2.4 Mecánica de Robo
|
||||||
|
- **Voluntario**: El equipo contrario decide si intenta robar o pasar
|
||||||
|
- **Penalización**: Si fallan el robo, pierden la mitad de los puntos de la pregunta
|
||||||
|
- **Tiempo reducido**: El equipo que roba tiene la mitad del tiempo original
|
||||||
|
|
||||||
|
### 2.5 Formato de Respuestas
|
||||||
|
- Respuesta abierta (el jugador escribe libremente)
|
||||||
|
- Validación semántica con IA (acepta sinónimos, variaciones, errores de ortografía menores)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Arquitectura Técnica
|
||||||
|
|
||||||
|
### 3.1 Stack Tecnológico
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ FRONTEND (React) │
|
||||||
|
│ - Vite + React 18 + TypeScript │
|
||||||
|
│ - Tailwind CSS + Framer Motion (animaciones) │
|
||||||
|
│ - Socket.io-client (tiempo real) │
|
||||||
|
│ - Zustand (estado global) │
|
||||||
|
│ - 5 temas visuales intercambiables │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ BACKEND (Python FastAPI) │
|
||||||
|
│ - FastAPI + Uvicorn │
|
||||||
|
│ - python-socketio (WebSockets) │
|
||||||
|
│ - SQLAlchemy (ORM) │
|
||||||
|
│ - Anthropic SDK (validación IA) │
|
||||||
|
│ - APScheduler (tareas programadas) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│PostgreSQL│ │ Redis │ │ Claude │
|
||||||
|
│(datos) │ │ (estado) │ │ (IA) │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Responsabilidades por Componente
|
||||||
|
|
||||||
|
- **PostgreSQL**: Preguntas, categorías, historial de partidas, configuración, logros
|
||||||
|
- **Redis**: Estado de salas activas, turnos, temporizadores, sesiones WebSocket
|
||||||
|
- **Claude API**: Validar respuestas, generar preguntas nuevas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modelo de Datos
|
||||||
|
|
||||||
|
### 4.1 PostgreSQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Categorías
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
icon VARCHAR(50),
|
||||||
|
color VARCHAR(7)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Preguntas
|
||||||
|
CREATE TABLE questions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
category_id INTEGER REFERENCES categories(id),
|
||||||
|
question_text TEXT NOT NULL,
|
||||||
|
correct_answer VARCHAR(500) NOT NULL,
|
||||||
|
alt_answers TEXT[], -- Respuestas alternativas válidas
|
||||||
|
difficulty INTEGER CHECK (difficulty BETWEEN 1 AND 5),
|
||||||
|
points INTEGER NOT NULL,
|
||||||
|
time_seconds INTEGER NOT NULL,
|
||||||
|
date_active DATE,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending, approved, used
|
||||||
|
fun_fact TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sesiones de juego
|
||||||
|
CREATE TABLE game_sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
room_code VARCHAR(6) UNIQUE NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'waiting', -- waiting, playing, finished
|
||||||
|
team_a_score INTEGER DEFAULT 0,
|
||||||
|
team_b_score INTEGER DEFAULT 0,
|
||||||
|
current_team VARCHAR(1), -- 'A' o 'B'
|
||||||
|
questions_used INTEGER[],
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
finished_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Eventos de juego (para replays)
|
||||||
|
CREATE TABLE game_events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
session_id INTEGER REFERENCES game_sessions(id),
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
player_name VARCHAR(100),
|
||||||
|
team VARCHAR(1),
|
||||||
|
question_id INTEGER REFERENCES questions(id),
|
||||||
|
answer_given TEXT,
|
||||||
|
was_correct BOOLEAN,
|
||||||
|
was_steal BOOLEAN DEFAULT FALSE,
|
||||||
|
points_earned INTEGER,
|
||||||
|
timestamp TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Administradores
|
||||||
|
CREATE TABLE admins (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Redis (Estado Volátil)
|
||||||
|
|
||||||
|
```
|
||||||
|
room:{code} → {
|
||||||
|
players: [{name, team, position, socketId}],
|
||||||
|
teams: {A: [], B: []},
|
||||||
|
currentTeam: 'A',
|
||||||
|
currentPlayerIndex: {A: 0, B: 0},
|
||||||
|
status: 'waiting|playing|finished',
|
||||||
|
timer: timestamp,
|
||||||
|
canSteal: false,
|
||||||
|
currentQuestion: questionId
|
||||||
|
}
|
||||||
|
|
||||||
|
player:{socket_id} → {name, room, team, position}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sistema de IA
|
||||||
|
|
||||||
|
### 5.1 Validación de Respuestas
|
||||||
|
|
||||||
|
```python
|
||||||
|
VALIDATION_PROMPT = """
|
||||||
|
Eres un validador de trivia. Determina si la respuesta del jugador
|
||||||
|
es correcta comparándola con la respuesta oficial.
|
||||||
|
|
||||||
|
Pregunta: {question}
|
||||||
|
Respuesta correcta: {correct_answer}
|
||||||
|
Respuestas alternativas válidas: {alt_answers}
|
||||||
|
Respuesta del jugador: {player_answer}
|
||||||
|
|
||||||
|
Considera válido si:
|
||||||
|
- Es sinónimo o variación de la respuesta correcta
|
||||||
|
- Tiene errores menores de ortografía
|
||||||
|
- Usa abreviaciones comunes (ej: "BOTW" = "Breath of the Wild")
|
||||||
|
- Es conceptualmente equivalente
|
||||||
|
|
||||||
|
Responde SOLO con JSON: {"valid": true/false, "reason": "breve explicación"}
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Generación de Preguntas
|
||||||
|
|
||||||
|
```python
|
||||||
|
GENERATION_PROMPT = """
|
||||||
|
Genera 5 preguntas de trivia para la categoría {category}.
|
||||||
|
Dificultad: {difficulty} (1=muy fácil, 5=muy difícil)
|
||||||
|
|
||||||
|
Formato JSON por pregunta:
|
||||||
|
{
|
||||||
|
"question": "texto de la pregunta",
|
||||||
|
"correct_answer": "respuesta principal",
|
||||||
|
"alt_answers": ["variación1", "variación2"],
|
||||||
|
"fun_fact": "dato curioso opcional"
|
||||||
|
}
|
||||||
|
|
||||||
|
Requisitos:
|
||||||
|
- Las preguntas deben ser verificables y precisas
|
||||||
|
- Evitar ambigüedades
|
||||||
|
- Ajustar complejidad según dificultad
|
||||||
|
- Para gaming: incluir referencias a juegos, personajes, mecánicas
|
||||||
|
- Para cultura: hechos históricos, arte, literatura
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Flujo de Aprobación
|
||||||
|
|
||||||
|
1. Admin solicita generar preguntas desde panel
|
||||||
|
2. Claude genera batch de preguntas
|
||||||
|
3. Quedan en estado `pending`
|
||||||
|
4. Admin revisa, edita si necesario, aprueba o rechaza
|
||||||
|
5. Preguntas aprobadas se asignan a fecha futura
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Temas Visuales
|
||||||
|
|
||||||
|
### 6.1 Temas Disponibles
|
||||||
|
|
||||||
|
| Tema | Paleta Principal | Características |
|
||||||
|
|------|------------------|-----------------|
|
||||||
|
| **DRRR (Dollars)** | Negro, amarillo neón (#FFE135), cyan (#00FFFF) | Chat estilo Dollars, efectos glitch, tipografía urbana, bordes neón pulsantes |
|
||||||
|
| **Retro Arcade** | Púrpura (#9B59B6), rosa (#E91E63), cyan pixelado | Pixel art UI, tipografía 8-bit (Press Start 2P), scanlines, efectos CRT |
|
||||||
|
| **Moderno Minimalista** | Blanco (#FFFFFF), grises, acento azul (#3498DB) | Limpio, sombras suaves, tipografía sans-serif, transiciones elegantes |
|
||||||
|
| **Gaming RGB** | Negro (#0D0D0D) con gradientes RGB | Efectos de luz LED, gradientes animados, bordes brillantes, estilo "gamer" |
|
||||||
|
| **Anime Clásico 90s** | Pasteles, rosa (#FFB6C1), lavanda (#E6E6FA) | Estrellas brillantes, efectos sparkle, bordes redondeados, estilo shoujo |
|
||||||
|
|
||||||
|
### 6.2 Implementación
|
||||||
|
|
||||||
|
```
|
||||||
|
/src/themes/
|
||||||
|
├── ThemeProvider.tsx # Context para tema activo
|
||||||
|
├── index.ts # Exporta todos los temas
|
||||||
|
├── drrr/
|
||||||
|
│ ├── variables.css
|
||||||
|
│ ├── components.tsx
|
||||||
|
│ └── sounds/
|
||||||
|
├── retro-arcade/
|
||||||
|
├── minimal/
|
||||||
|
├── gaming-rgb/
|
||||||
|
└── anime-90s/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Sistema de Sonidos
|
||||||
|
|
||||||
|
### 7.1 Eventos con Sonido
|
||||||
|
|
||||||
|
| Evento | DRRR | Retro | Minimal | RGB | Anime 90s |
|
||||||
|
|--------|------|-------|---------|-----|-----------|
|
||||||
|
| Correcto | Glitch digital | 8-bit coin | Soft chime | Synth rise | Sparkle |
|
||||||
|
| Incorrecto | Static buzz | 8-bit fail | Low tone | Bass drop | Comedic |
|
||||||
|
| Robo | Suspense | Power-up | Click | Laser | Drama sting |
|
||||||
|
| Timer (tick) | Heartbeat | Beeps | Ticks | Pulse | Tension |
|
||||||
|
| Timer (urgente) | Fast heartbeat | Fast beeps | Fast ticks | Fast pulse | Panic |
|
||||||
|
| Victoria | Epic synth | Fanfare | Elegant | EDM drop | Anime victory |
|
||||||
|
| Derrota | Glitch fade | Game over | Soft close | Power down | Sad piano |
|
||||||
|
| Selección | Click neón | 8-bit select | Pop | RGB sweep | Cute pop |
|
||||||
|
|
||||||
|
### 7.2 Almacenamiento
|
||||||
|
- Archivos en formato WebM/OGG para compatibilidad
|
||||||
|
- Precargados al seleccionar tema
|
||||||
|
- Volumen configurable por usuario
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sistema de Logros
|
||||||
|
|
||||||
|
### 8.1 Lista de Logros
|
||||||
|
|
||||||
|
| ID | Nombre | Condición | Icono |
|
||||||
|
|----|--------|-----------|-------|
|
||||||
|
| 1 | Primera Victoria | Ganar tu primera partida | 🏆 |
|
||||||
|
| 2 | Racha de 3 | Responder 3 correctas seguidas | 🔥 |
|
||||||
|
| 3 | Racha de 5 | Responder 5 correctas seguidas | 🔥🔥 |
|
||||||
|
| 4 | Ladrón Novato | Primer robo exitoso | 🦝 |
|
||||||
|
| 5 | Ladrón Maestro | 5 robos exitosos en una partida | 🦝👑 |
|
||||||
|
| 6 | Especialista Nintendo | 10 correctas en Nintendo | 🍄 |
|
||||||
|
| 7 | Especialista Xbox | 10 correctas en Xbox | 🎮 |
|
||||||
|
| 8 | Especialista PlayStation | 10 correctas en PlayStation | 🎯 |
|
||||||
|
| 9 | Especialista Anime | 10 correctas en Anime | ⛩️ |
|
||||||
|
| 10 | Especialista Música | 10 correctas en Música | 🎵 |
|
||||||
|
| 11 | Especialista Películas | 10 correctas en Películas | 🎬 |
|
||||||
|
| 12 | Especialista Libros | 10 correctas en Libros | 📚 |
|
||||||
|
| 13 | Especialista Historia | 10 correctas en Historia-Cultura | 🏛️ |
|
||||||
|
| 14 | Invicto | Ganar sin fallar ninguna pregunta | ⭐ |
|
||||||
|
| 15 | Velocista | Responder correctamente en menos de 3 segundos | ⚡ |
|
||||||
|
| 16 | Comeback | Ganar estando 500+ puntos abajo | 🔄 |
|
||||||
|
| 17 | Dominio Total | Responder las 5 preguntas de una categoría correctamente | 👑 |
|
||||||
|
| 18 | Arriesgado | Responder correctamente 3 preguntas de 500 pts | 🎰 |
|
||||||
|
|
||||||
|
### 8.2 Almacenamiento
|
||||||
|
- localStorage por navegador (sin cuentas)
|
||||||
|
- Estructura: `{odooId: {achievements: [...], stats: {...}}}`
|
||||||
|
- Mostrados al final de cada partida (nuevos desbloqueados)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Sistema de Replays
|
||||||
|
|
||||||
|
### 9.1 Datos Capturados
|
||||||
|
|
||||||
|
Usando la tabla `game_events`, cada evento registra:
|
||||||
|
- Tipo de evento (question_selected, answer_submitted, steal_attempted, etc.)
|
||||||
|
- Jugador y equipo
|
||||||
|
- Pregunta seleccionada
|
||||||
|
- Respuesta dada
|
||||||
|
- Resultado (correcto/incorrecto)
|
||||||
|
- Puntos ganados/perdidos
|
||||||
|
- Timestamp preciso
|
||||||
|
|
||||||
|
### 9.2 Reproducción
|
||||||
|
|
||||||
|
1. Al finalizar partida, opción "Ver Replay"
|
||||||
|
2. Carga eventos ordenados por timestamp
|
||||||
|
3. Reproduce animación acelerada (x2, x4, x8)
|
||||||
|
4. Muestra:
|
||||||
|
- Tablero con preguntas revelándose
|
||||||
|
- Respuestas de jugadores
|
||||||
|
- Marcador actualizándose
|
||||||
|
- Momentos de robo
|
||||||
|
5. Controles: Play/Pause, velocidad, timeline scrubber
|
||||||
|
|
||||||
|
### 9.3 Compartir
|
||||||
|
- Código único de replay (basado en session_id)
|
||||||
|
- URL compartible: `/replay/{code}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Panel de Administración
|
||||||
|
|
||||||
|
### 10.1 Funcionalidades
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ PANEL ADMINISTRADOR │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 📊 Dashboard │
|
||||||
|
│ - Partidas activas en tiempo real │
|
||||||
|
│ - Estadísticas del día │
|
||||||
|
│ - Preguntas pendientes de aprobación │
|
||||||
|
│ │
|
||||||
|
│ ❓ Gestión de Preguntas │
|
||||||
|
│ - CRUD de preguntas por categoría │
|
||||||
|
│ - Generar con IA (botón por categoría/dificultad) │
|
||||||
|
│ - Cola de aprobación │
|
||||||
|
│ - Asignar a fechas │
|
||||||
|
│ - Importar/exportar CSV │
|
||||||
|
│ │
|
||||||
|
│ 📅 Calendario │
|
||||||
|
│ - Vista mensual de preguntas programadas │
|
||||||
|
│ - Alertas de días sin contenido │
|
||||||
|
│ │
|
||||||
|
│ 🎮 Monitor │
|
||||||
|
│ - Salas activas │
|
||||||
|
│ - Cerrar salas problemáticas │
|
||||||
|
│ │
|
||||||
|
│ ⚙️ Configuración │
|
||||||
|
│ - Tiempos y puntos por dificultad │
|
||||||
|
│ - Penalización de robo │
|
||||||
|
│ - API keys │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Autenticación
|
||||||
|
- Login con usuario/contraseña
|
||||||
|
- JWT para sesiones
|
||||||
|
- Rutas protegidas `/admin/*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Comunicación en Partida
|
||||||
|
|
||||||
|
### 11.1 Chat de Equipo
|
||||||
|
- Visible solo para miembros del mismo equipo
|
||||||
|
- Mensajes en tiempo real via WebSocket
|
||||||
|
- Historial durante la partida
|
||||||
|
|
||||||
|
### 11.2 Reacciones Globales
|
||||||
|
- Emojis predefinidos que todos pueden ver
|
||||||
|
- Limitado para evitar spam (1 cada 3 segundos)
|
||||||
|
- Emojis disponibles: 👏 😮 😂 🔥 💀 🎉 😭 🤔
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
WebTriviasMulti/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── main.py
|
||||||
|
│ │ ├── config.py
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── category.py
|
||||||
|
│ │ │ ├── question.py
|
||||||
|
│ │ │ ├── game_session.py
|
||||||
|
│ │ │ ├── game_event.py
|
||||||
|
│ │ │ └── admin.py
|
||||||
|
│ │ ├── schemas/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── question.py
|
||||||
|
│ │ │ ├── game.py
|
||||||
|
│ │ │ └── admin.py
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── ai_validator.py
|
||||||
|
│ │ │ ├── ai_generator.py
|
||||||
|
│ │ │ ├── game_manager.py
|
||||||
|
│ │ │ └── room_manager.py
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── admin.py
|
||||||
|
│ │ │ ├── game.py
|
||||||
|
│ │ │ └── replay.py
|
||||||
|
│ │ └── sockets/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ └── game_events.py
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── alembic.ini
|
||||||
|
│ └── alembic/
|
||||||
|
│ └── versions/
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── game/
|
||||||
|
│ │ │ │ ├── Board.tsx
|
||||||
|
│ │ │ │ ├── QuestionCard.tsx
|
||||||
|
│ │ │ │ ├── Timer.tsx
|
||||||
|
│ │ │ │ ├── ScoreBoard.tsx
|
||||||
|
│ │ │ │ └── AnswerInput.tsx
|
||||||
|
│ │ │ ├── lobby/
|
||||||
|
│ │ │ │ ├── CreateRoom.tsx
|
||||||
|
│ │ │ │ ├── JoinRoom.tsx
|
||||||
|
│ │ │ │ ├── TeamSelect.tsx
|
||||||
|
│ │ │ │ └── PlayerList.tsx
|
||||||
|
│ │ │ ├── chat/
|
||||||
|
│ │ │ │ ├── TeamChat.tsx
|
||||||
|
│ │ │ │ └── EmojiReactions.tsx
|
||||||
|
│ │ │ ├── replay/
|
||||||
|
│ │ │ │ ├── ReplayPlayer.tsx
|
||||||
|
│ │ │ │ └── ReplayControls.tsx
|
||||||
|
│ │ │ ├── achievements/
|
||||||
|
│ │ │ │ ├── AchievementPopup.tsx
|
||||||
|
│ │ │ │ └── AchievementList.tsx
|
||||||
|
│ │ │ └── ui/
|
||||||
|
│ │ │ ├── Button.tsx
|
||||||
|
│ │ │ ├── Modal.tsx
|
||||||
|
│ │ │ ├── Input.tsx
|
||||||
|
│ │ │ └── Toast.tsx
|
||||||
|
│ │ ├── themes/
|
||||||
|
│ │ │ ├── ThemeProvider.tsx
|
||||||
|
│ │ │ ├── index.ts
|
||||||
|
│ │ │ ├── drrr/
|
||||||
|
│ │ │ ├── retro-arcade/
|
||||||
|
│ │ │ ├── minimal/
|
||||||
|
│ │ │ ├── gaming-rgb/
|
||||||
|
│ │ │ └── anime-90s/
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useSocket.ts
|
||||||
|
│ │ │ ├── useGame.ts
|
||||||
|
│ │ │ ├── useSound.ts
|
||||||
|
│ │ │ └── useAchievements.ts
|
||||||
|
│ │ ├── stores/
|
||||||
|
│ │ │ ├── gameStore.ts
|
||||||
|
│ │ │ ├── themeStore.ts
|
||||||
|
│ │ │ └── soundStore.ts
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── Home.tsx
|
||||||
|
│ │ │ ├── Lobby.tsx
|
||||||
|
│ │ │ ├── Game.tsx
|
||||||
|
│ │ │ ├── Replay.tsx
|
||||||
|
│ │ │ ├── Results.tsx
|
||||||
|
│ │ │ └── admin/
|
||||||
|
│ │ │ ├── Dashboard.tsx
|
||||||
|
│ │ │ ├── Questions.tsx
|
||||||
|
│ │ │ ├── Calendar.tsx
|
||||||
|
│ │ │ ├── Monitor.tsx
|
||||||
|
│ │ │ └── Settings.tsx
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── socket.ts
|
||||||
|
│ │ │ └── api.ts
|
||||||
|
│ │ ├── types/
|
||||||
|
│ │ │ └── index.ts
|
||||||
|
│ │ ├── App.tsx
|
||||||
|
│ │ └── main.tsx
|
||||||
|
│ ├── public/
|
||||||
|
│ │ └── sounds/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── vite.config.ts
|
||||||
|
│ ├── tailwind.config.js
|
||||||
|
│ └── tsconfig.json
|
||||||
|
│
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env.example
|
||||||
|
├── .gitignore
|
||||||
|
├── README.md
|
||||||
|
└── docs/
|
||||||
|
└── plans/
|
||||||
|
└── 2026-01-26-webtriviasmulti-design.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Despliegue
|
||||||
|
|
||||||
|
### 13.1 Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://localhost:8000
|
||||||
|
- VITE_WS_URL=ws://localhost:8000
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://trivia:trivia@db:5432/trivia
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=trivia
|
||||||
|
- POSTGRES_USER=trivia
|
||||||
|
- POSTGRES_PASSWORD=trivia
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
cloudflared:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
command: tunnel run
|
||||||
|
environment:
|
||||||
|
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.2 Variables de Entorno
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Backend
|
||||||
|
DATABASE_URL=postgresql://trivia:trivia@db:5432/trivia
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
JWT_SECRET=your-secret-key
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
VITE_API_URL=https://trivia.tudominio.com/api
|
||||||
|
VITE_WS_URL=wss://trivia.tudominio.com
|
||||||
|
|
||||||
|
# Cloudflare
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Roadmap Futuro
|
||||||
|
|
||||||
|
### Fase 3 - Competitivo
|
||||||
|
- Ranking global (requiere cuentas opcionales)
|
||||||
|
- Torneos programados
|
||||||
|
- Temporadas con recompensas
|
||||||
|
|
||||||
|
### Fase 4 - Social
|
||||||
|
- Compartir resultados en redes
|
||||||
|
- Salas recurrentes
|
||||||
|
- Desafíos diarios
|
||||||
|
|
||||||
|
### Fase 5 - Contenido
|
||||||
|
- Categorías rotativas por eventos
|
||||||
|
- Preguntas de la comunidad
|
||||||
|
- Modo "Experto"
|
||||||
|
|
||||||
|
### Fase 6 - Técnico
|
||||||
|
- PWA instalable
|
||||||
|
- API pública
|
||||||
|
- Modo offline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Referencias
|
||||||
|
|
||||||
|
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
||||||
|
- [Socket.IO](https://socket.io/)
|
||||||
|
- [Anthropic Claude API](https://docs.anthropic.com/)
|
||||||
|
- [React](https://react.dev/)
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
- [Framer Motion](https://www.framer.com/motion/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento generado el 2026-01-26*
|
||||||
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Development command
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
18
frontend/index.html
Normal file
18
frontend/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="WebTriviasMulti - Trivia multiplayer en tiempo real" />
|
||||||
|
<title>WebTriviasMulti</title>
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Bebas+Neue&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
frontend/package.json
Normal file
39
frontend/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "webtriviasmulti-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.3",
|
||||||
|
"socket.io-client": "^4.7.4",
|
||||||
|
"zustand": "^4.5.0",
|
||||||
|
"framer-motion": "^11.0.3",
|
||||||
|
"howler": "^2.2.4",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@types/howler": "^2.2.11",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||||
|
"@typescript-eslint/parser": "^6.20.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
22
frontend/src/App.tsx
Normal file
22
frontend/src/App.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import Home from './pages/Home'
|
||||||
|
import Lobby from './pages/Lobby'
|
||||||
|
import Game from './pages/Game'
|
||||||
|
import Results from './pages/Results'
|
||||||
|
import Replay from './pages/Replay'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/lobby/:roomCode" element={<Lobby />} />
|
||||||
|
<Route path="/game/:roomCode" element={<Game />} />
|
||||||
|
<Route path="/results/:roomCode" element={<Results />} />
|
||||||
|
<Route path="/replay/:sessionId" element={<Replay />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
165
frontend/src/hooks/useAchievements.ts
Normal file
165
frontend/src/hooks/useAchievements.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useGameStore } from '../stores/gameStore'
|
||||||
|
import type { Achievement } from '../types'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'trivia-achievements'
|
||||||
|
|
||||||
|
// Achievement definitions
|
||||||
|
const achievementDefinitions: Achievement[] = [
|
||||||
|
{ id: 1, name: 'Primera Victoria', description: 'Ganar tu primera partida', icon: '🏆' },
|
||||||
|
{ id: 2, name: 'Racha de 3', description: 'Responder 3 correctas seguidas', icon: '🔥' },
|
||||||
|
{ id: 3, name: 'Racha de 5', description: 'Responder 5 correctas seguidas', icon: '🔥🔥' },
|
||||||
|
{ id: 4, name: 'Ladrón Novato', description: 'Primer robo exitoso', icon: '🦝' },
|
||||||
|
{ id: 5, name: 'Ladrón Maestro', description: '5 robos exitosos en una partida', icon: '🦝👑' },
|
||||||
|
{ id: 6, name: 'Especialista Nintendo', description: '10 correctas en Nintendo', icon: '🍄' },
|
||||||
|
{ id: 7, name: 'Especialista Xbox', description: '10 correctas en Xbox', icon: '🎮' },
|
||||||
|
{ id: 8, name: 'Especialista PlayStation', description: '10 correctas en PlayStation', icon: '🎯' },
|
||||||
|
{ id: 9, name: 'Especialista Anime', description: '10 correctas en Anime', icon: '⛩️' },
|
||||||
|
{ id: 10, name: 'Especialista Música', description: '10 correctas en Música', icon: '🎵' },
|
||||||
|
{ id: 11, name: 'Especialista Películas', description: '10 correctas en Películas', icon: '🎬' },
|
||||||
|
{ id: 12, name: 'Especialista Libros', description: '10 correctas en Libros', icon: '📚' },
|
||||||
|
{ id: 13, name: 'Especialista Historia', description: '10 correctas en Historia-Cultura', icon: '🏛️' },
|
||||||
|
{ id: 14, name: 'Invicto', description: 'Ganar sin fallar ninguna pregunta', icon: '⭐' },
|
||||||
|
{ id: 15, name: 'Velocista', description: 'Responder correctamente en menos de 3 segundos', icon: '⚡' },
|
||||||
|
{ id: 16, name: 'Comeback', description: 'Ganar estando 500+ puntos abajo', icon: '🔄' },
|
||||||
|
{ id: 17, name: 'Dominio Total', description: 'Responder las 5 preguntas de una categoría', icon: '👑' },
|
||||||
|
{ id: 18, name: 'Arriesgado', description: 'Responder correctamente 3 preguntas de 500 pts', icon: '🎰' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function useAchievements() {
|
||||||
|
const { achievements, setAchievements, unlockAchievement, stats } = useGameStore()
|
||||||
|
|
||||||
|
// Load achievements from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
// Merge with definitions to ensure all achievements exist
|
||||||
|
const merged = achievementDefinitions.map((def) => ({
|
||||||
|
...def,
|
||||||
|
unlocked: parsed.find((a: Achievement) => a.id === def.id)?.unlocked || false,
|
||||||
|
unlockedAt: parsed.find((a: Achievement) => a.id === def.id)?.unlockedAt,
|
||||||
|
}))
|
||||||
|
setAchievements(merged)
|
||||||
|
} catch {
|
||||||
|
setAchievements(achievementDefinitions)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAchievements(achievementDefinitions)
|
||||||
|
}
|
||||||
|
}, [setAchievements])
|
||||||
|
|
||||||
|
// Save achievements to localStorage when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (achievements.length > 0) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(achievements))
|
||||||
|
}
|
||||||
|
}, [achievements])
|
||||||
|
|
||||||
|
const checkAchievements = useCallback(
|
||||||
|
(context: {
|
||||||
|
won?: boolean
|
||||||
|
correctStreak?: number
|
||||||
|
stealSuccess?: boolean
|
||||||
|
categoryId?: number
|
||||||
|
answerTime?: number
|
||||||
|
deficit?: number
|
||||||
|
points?: number
|
||||||
|
neverFailed?: boolean
|
||||||
|
categoryComplete?: number
|
||||||
|
}) => {
|
||||||
|
const newUnlocks: number[] = []
|
||||||
|
|
||||||
|
// First Victory
|
||||||
|
if (context.won && !achievements.find((a) => a.id === 1)?.unlocked) {
|
||||||
|
unlockAchievement(1)
|
||||||
|
newUnlocks.push(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaks
|
||||||
|
if (context.correctStreak && context.correctStreak >= 3) {
|
||||||
|
if (!achievements.find((a) => a.id === 2)?.unlocked) {
|
||||||
|
unlockAchievement(2)
|
||||||
|
newUnlocks.push(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (context.correctStreak && context.correctStreak >= 5) {
|
||||||
|
if (!achievements.find((a) => a.id === 3)?.unlocked) {
|
||||||
|
unlockAchievement(3)
|
||||||
|
newUnlocks.push(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steals
|
||||||
|
if (context.stealSuccess) {
|
||||||
|
if (!achievements.find((a) => a.id === 4)?.unlocked) {
|
||||||
|
unlockAchievement(4)
|
||||||
|
newUnlocks.push(4)
|
||||||
|
}
|
||||||
|
if (stats.stealsSuccessful >= 5 && !achievements.find((a) => a.id === 5)?.unlocked) {
|
||||||
|
unlockAchievement(5)
|
||||||
|
newUnlocks.push(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category specialists (6-13)
|
||||||
|
const categoryAchievementMap: Record<number, number> = {
|
||||||
|
1: 6, // Nintendo
|
||||||
|
2: 7, // Xbox
|
||||||
|
3: 8, // PlayStation
|
||||||
|
4: 9, // Anime
|
||||||
|
5: 10, // Música
|
||||||
|
6: 11, // Películas
|
||||||
|
7: 12, // Libros
|
||||||
|
8: 13, // Historia-Cultura
|
||||||
|
}
|
||||||
|
if (context.categoryId) {
|
||||||
|
const achievementId = categoryAchievementMap[context.categoryId]
|
||||||
|
const categoryCount = stats.categoryCorrect[context.categoryId] || 0
|
||||||
|
if (categoryCount >= 10 && achievementId && !achievements.find((a) => a.id === achievementId)?.unlocked) {
|
||||||
|
unlockAchievement(achievementId)
|
||||||
|
newUnlocks.push(achievementId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invicto
|
||||||
|
if (context.won && context.neverFailed && !achievements.find((a) => a.id === 14)?.unlocked) {
|
||||||
|
unlockAchievement(14)
|
||||||
|
newUnlocks.push(14)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Velocista
|
||||||
|
if (context.answerTime && context.answerTime < 3 && !achievements.find((a) => a.id === 15)?.unlocked) {
|
||||||
|
unlockAchievement(15)
|
||||||
|
newUnlocks.push(15)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comeback
|
||||||
|
if (context.won && context.deficit && context.deficit >= 500 && !achievements.find((a) => a.id === 16)?.unlocked) {
|
||||||
|
unlockAchievement(16)
|
||||||
|
newUnlocks.push(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dominio Total
|
||||||
|
if (context.categoryComplete && !achievements.find((a) => a.id === 17)?.unlocked) {
|
||||||
|
unlockAchievement(17)
|
||||||
|
newUnlocks.push(17)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arriesgado
|
||||||
|
if (context.points === 500 && stats.fastAnswers >= 3 && !achievements.find((a) => a.id === 18)?.unlocked) {
|
||||||
|
unlockAchievement(18)
|
||||||
|
newUnlocks.push(18)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newUnlocks.map((id) => achievements.find((a) => a.id === id)!)
|
||||||
|
},
|
||||||
|
[achievements, stats, unlockAchievement]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
achievements,
|
||||||
|
checkAchievements,
|
||||||
|
}
|
||||||
|
}
|
||||||
175
frontend/src/hooks/useSocket.ts
Normal file
175
frontend/src/hooks/useSocket.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { io, Socket } from 'socket.io-client'
|
||||||
|
import { useGameStore } from '../stores/gameStore'
|
||||||
|
import type { GameRoom, ChatMessage, AnswerResult } from '../types'
|
||||||
|
|
||||||
|
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
|
||||||
|
|
||||||
|
export function useSocket() {
|
||||||
|
const socketRef = useRef<Socket | null>(null)
|
||||||
|
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd } =
|
||||||
|
useGameStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Create socket connection
|
||||||
|
socketRef.current = io(SOCKET_URL, {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
autoConnect: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const socket = socketRef.current
|
||||||
|
|
||||||
|
// Connection events
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('Connected to server')
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('Disconnected from server')
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('error', (data: { message: string }) => {
|
||||||
|
console.error('Socket error:', data.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Room events
|
||||||
|
socket.on('room_created', (data: { room: GameRoom }) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('player_joined', (data: { room: GameRoom }) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('player_left', (data: { room: GameRoom }) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('team_changed', (data: { room: GameRoom }) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Game events
|
||||||
|
socket.on('game_started', (data: { room: GameRoom }) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
// Fetch full question details
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('answer_result', (data: AnswerResult) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
if (!data.valid && !data.was_steal && data.room.can_steal) {
|
||||||
|
setShowStealPrompt(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('steal_attempted', (data: { room: GameRoom }) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
setShowStealPrompt(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('steal_passed', (data: { room: GameRoom }) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
setShowStealPrompt(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => {
|
||||||
|
setRoom(data.room)
|
||||||
|
if (!data.was_steal && data.room.can_steal) {
|
||||||
|
setShowStealPrompt(true)
|
||||||
|
} else {
|
||||||
|
setShowStealPrompt(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat events
|
||||||
|
socket.on('chat_message', (data: ChatMessage) => {
|
||||||
|
addMessage(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('emoji_reaction', (data: { player_name: string; team: string; emoji: string }) => {
|
||||||
|
// Handle emoji reaction display
|
||||||
|
console.log(`${data.player_name} reacted with ${data.emoji}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect()
|
||||||
|
}
|
||||||
|
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd])
|
||||||
|
|
||||||
|
// Socket methods
|
||||||
|
const createRoom = useCallback((playerName: string) => {
|
||||||
|
socketRef.current?.emit('create_room', { player_name: playerName })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => {
|
||||||
|
socketRef.current?.emit('join_room', {
|
||||||
|
room_code: roomCode,
|
||||||
|
player_name: playerName,
|
||||||
|
team,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const changeTeam = useCallback((team: 'A' | 'B') => {
|
||||||
|
socketRef.current?.emit('change_team', { team })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startGame = useCallback((board: Record<string, unknown>) => {
|
||||||
|
socketRef.current?.emit('start_game', { board })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectQuestion = useCallback((questionId: number, categoryId: number) => {
|
||||||
|
socketRef.current?.emit('select_question', {
|
||||||
|
question_id: questionId,
|
||||||
|
category_id: categoryId,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const submitAnswer = useCallback(
|
||||||
|
(answer: string, question: Record<string, unknown>, isSteal: boolean = false) => {
|
||||||
|
socketRef.current?.emit('submit_answer', {
|
||||||
|
answer,
|
||||||
|
question,
|
||||||
|
is_steal: isSteal,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => {
|
||||||
|
socketRef.current?.emit('steal_decision', {
|
||||||
|
attempt,
|
||||||
|
question_id: questionId,
|
||||||
|
answer,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const sendChatMessage = useCallback((message: string) => {
|
||||||
|
socketRef.current?.emit('chat_message', { message })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const sendEmojiReaction = useCallback((emoji: string) => {
|
||||||
|
socketRef.current?.emit('emoji_reaction', { emoji })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const notifyTimerExpired = useCallback(() => {
|
||||||
|
socketRef.current?.emit('timer_expired', {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
socket: socketRef.current,
|
||||||
|
createRoom,
|
||||||
|
joinRoom,
|
||||||
|
changeTeam,
|
||||||
|
startGame,
|
||||||
|
selectQuestion,
|
||||||
|
submitAnswer,
|
||||||
|
stealDecision,
|
||||||
|
sendChatMessage,
|
||||||
|
sendEmojiReaction,
|
||||||
|
notifyTimerExpired,
|
||||||
|
}
|
||||||
|
}
|
||||||
84
frontend/src/hooks/useSound.ts
Normal file
84
frontend/src/hooks/useSound.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { Howl } from 'howler'
|
||||||
|
import { useSoundStore, soundPaths } from '../stores/soundStore'
|
||||||
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
|
import type { ThemeName } from '../types'
|
||||||
|
|
||||||
|
type SoundEffect =
|
||||||
|
| 'correct'
|
||||||
|
| 'incorrect'
|
||||||
|
| 'steal'
|
||||||
|
| 'timer_tick'
|
||||||
|
| 'timer_urgent'
|
||||||
|
| 'victory'
|
||||||
|
| 'defeat'
|
||||||
|
| 'select'
|
||||||
|
|
||||||
|
export function useSound() {
|
||||||
|
const { volume, muted } = useSoundStore()
|
||||||
|
const { currentTheme } = useThemeStore()
|
||||||
|
const soundsRef = useRef<Map<string, Howl>>(new Map())
|
||||||
|
|
||||||
|
// Preload sounds for current theme
|
||||||
|
useEffect(() => {
|
||||||
|
const themeSounds = soundPaths[currentTheme]
|
||||||
|
if (!themeSounds) return
|
||||||
|
|
||||||
|
// Clear old sounds
|
||||||
|
soundsRef.current.forEach((sound) => sound.unload())
|
||||||
|
soundsRef.current.clear()
|
||||||
|
|
||||||
|
// Load new sounds
|
||||||
|
Object.entries(themeSounds).forEach(([key, path]) => {
|
||||||
|
const sound = new Howl({
|
||||||
|
src: [path],
|
||||||
|
volume: volume,
|
||||||
|
preload: true,
|
||||||
|
onloaderror: () => {
|
||||||
|
console.warn(`Failed to load sound: ${path}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
soundsRef.current.set(key, sound)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
soundsRef.current.forEach((sound) => sound.unload())
|
||||||
|
}
|
||||||
|
}, [currentTheme])
|
||||||
|
|
||||||
|
// Update volume when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
soundsRef.current.forEach((sound) => {
|
||||||
|
sound.volume(volume)
|
||||||
|
})
|
||||||
|
}, [volume])
|
||||||
|
|
||||||
|
const play = useCallback(
|
||||||
|
(effect: SoundEffect) => {
|
||||||
|
if (muted) return
|
||||||
|
|
||||||
|
const sound = soundsRef.current.get(effect)
|
||||||
|
if (sound) {
|
||||||
|
sound.play()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[muted]
|
||||||
|
)
|
||||||
|
|
||||||
|
const stop = useCallback((effect: SoundEffect) => {
|
||||||
|
const sound = soundsRef.current.get(effect)
|
||||||
|
if (sound) {
|
||||||
|
sound.stop()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopAll = useCallback(() => {
|
||||||
|
soundsRef.current.forEach((sound) => sound.stop())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
play,
|
||||||
|
stop,
|
||||||
|
stopAll,
|
||||||
|
}
|
||||||
|
}
|
||||||
120
frontend/src/index.css
Normal file
120
frontend/src/index.css
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
:root {
|
||||||
|
--color-bg: #0a0a0a;
|
||||||
|
--color-primary: #FFE135;
|
||||||
|
--color-secondary: #00FFFF;
|
||||||
|
--color-accent: #FF00FF;
|
||||||
|
--color-text: #ffffff;
|
||||||
|
--color-text-muted: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme transitions */
|
||||||
|
* {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.text-shadow-neon {
|
||||||
|
text-shadow: 0 0 10px currentColor, 0 0 20px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-neon {
|
||||||
|
box-shadow: 0 0 5px currentColor, 0 0 10px currentColor, inset 0 0 5px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRT scanline effect for retro theme */
|
||||||
|
.crt-scanlines::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
transparent 50%,
|
||||||
|
rgba(0, 0, 0, 0.1) 50%
|
||||||
|
);
|
||||||
|
background-size: 100% 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glitch effect for DRRR theme */
|
||||||
|
.glitch-text {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch-text::before,
|
||||||
|
.glitch-text::after {
|
||||||
|
content: attr(data-text);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch-text::before {
|
||||||
|
animation: glitch-1 0.3s infinite;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch-text::after {
|
||||||
|
animation: glitch-2 0.3s infinite;
|
||||||
|
color: var(--color-accent);
|
||||||
|
z-index: -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch-1 {
|
||||||
|
0%, 100% { clip-path: inset(0 0 0 0); transform: translate(0); }
|
||||||
|
20% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, -2px); }
|
||||||
|
40% { clip-path: inset(40% 0 40% 0); transform: translate(2px, 2px); }
|
||||||
|
60% { clip-path: inset(60% 0 20% 0); transform: translate(-2px, 2px); }
|
||||||
|
80% { clip-path: inset(80% 0 0% 0); transform: translate(2px, -2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch-2 {
|
||||||
|
0%, 100% { clip-path: inset(0 0 0 0); transform: translate(0); }
|
||||||
|
20% { clip-path: inset(60% 0 20% 0); transform: translate(2px, 2px); }
|
||||||
|
40% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, -2px); }
|
||||||
|
60% { clip-path: inset(80% 0 0% 0); transform: translate(2px, -2px); }
|
||||||
|
80% { clip-path: inset(40% 0 40% 0); transform: translate(-2px, 2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sparkle effect for anime theme */
|
||||||
|
.sparkle::before {
|
||||||
|
content: '✦';
|
||||||
|
position: absolute;
|
||||||
|
animation: sparkle-float 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sparkle-float {
|
||||||
|
0%, 100% { opacity: 0; transform: translateY(0) scale(0); }
|
||||||
|
50% { opacity: 1; transform: translateY(-20px) scale(1); }
|
||||||
|
}
|
||||||
16
frontend/src/main.tsx
Normal file
16
frontend/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import { ThemeProvider } from './themes/ThemeProvider'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
335
frontend/src/pages/Game.tsx
Normal file
335
frontend/src/pages/Game.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useSocket } from '../hooks/useSocket'
|
||||||
|
import { useSound } from '../hooks/useSound'
|
||||||
|
import { useGameStore } from '../stores/gameStore'
|
||||||
|
import { useThemeStyles } from '../themes/ThemeProvider'
|
||||||
|
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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Game() {
|
||||||
|
const { roomCode } = useParams<{ roomCode: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket()
|
||||||
|
const { play } = useSound()
|
||||||
|
const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt } = useGameStore()
|
||||||
|
const { config, styles } = useThemeStyles()
|
||||||
|
|
||||||
|
const [answer, setAnswer] = useState('')
|
||||||
|
const [timeLeft, setTimeLeft] = useState(0)
|
||||||
|
const [showingQuestion, setShowingQuestion] = useState(false)
|
||||||
|
|
||||||
|
// Redirect if game finished
|
||||||
|
useEffect(() => {
|
||||||
|
if (room?.status === 'finished') {
|
||||||
|
navigate(`/results/${room.code}`)
|
||||||
|
}
|
||||||
|
}, [room?.status, room?.code, navigate])
|
||||||
|
|
||||||
|
// Timer logic
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentQuestion || !showingQuestion) return
|
||||||
|
|
||||||
|
setTimeLeft(currentQuestion.time_seconds)
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTimeLeft((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(interval)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (prev === 6) play('timer_urgent')
|
||||||
|
return prev - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [currentQuestion, showingQuestion, play])
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
|
||||||
|
<p style={styles.textSecondary}>Cargando...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const myTeam = room.teams.A.find(p => p.name === playerName) ? 'A' : 'B'
|
||||||
|
const isMyTurn = room.current_team === myTeam
|
||||||
|
const currentPlayer = isMyTurn
|
||||||
|
? room.teams[myTeam][room.current_player_index[myTeam]]
|
||||||
|
: null
|
||||||
|
const amICurrentPlayer = currentPlayer?.name === playerName
|
||||||
|
|
||||||
|
const handleSelectQuestion = (question: Question, categoryId: number) => {
|
||||||
|
if (!amICurrentPlayer || question.answered) return
|
||||||
|
play('select')
|
||||||
|
selectQuestion(question.id, categoryId)
|
||||||
|
setShowingQuestion(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitAnswer = () => {
|
||||||
|
if (!currentQuestion || !answer.trim()) return
|
||||||
|
submitAnswer(answer, currentQuestion as Record<string, unknown>, room.can_steal)
|
||||||
|
setAnswer('')
|
||||||
|
setShowingQuestion(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStealDecision = (attempt: boolean) => {
|
||||||
|
if (!currentQuestion) return
|
||||||
|
if (attempt) {
|
||||||
|
setShowingQuestion(true)
|
||||||
|
} else {
|
||||||
|
stealDecision(false, currentQuestion.id)
|
||||||
|
}
|
||||||
|
setShowStealPrompt(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = ['👏', '😮', '😂', '🔥', '💀', '🎉', '😭', '🤔']
|
||||||
|
|
||||||
|
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="text-sm" style={styles.textSecondary}>Equipo A</div>
|
||||||
|
<div className="text-3xl font-bold" style={{ color: config.colors.primary }}>
|
||||||
|
{room.scores.A}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm" style={styles.textSecondary}>
|
||||||
|
Turno de {room.current_team === 'A' ? 'Equipo A' : 'Equipo B'}
|
||||||
|
</div>
|
||||||
|
{amICurrentPlayer && (
|
||||||
|
<div className="text-lg font-bold" style={{ color: config.colors.accent }}>
|
||||||
|
¡Tu turno!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="text-center px-6 py-2 rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.secondary + '20',
|
||||||
|
border: `2px solid ${config.colors.secondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
|
||||||
|
<div className="text-3xl font-bold" style={{ color: config.colors.secondary }}>
|
||||||
|
{room.scores.B}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 }}
|
||||||
|
>
|
||||||
|
<div className="text-2xl">{cat.icon}</div>
|
||||||
|
<div className="text-xs text-white font-bold truncate">{cat.name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Questions Grid */}
|
||||||
|
{[1, 2, 3, 4, 5].map((difficulty) =>
|
||||||
|
categories.map((cat) => {
|
||||||
|
const questions = room.board[String(cat.id)] || []
|
||||||
|
const question = questions.find(q => q.difficulty === difficulty)
|
||||||
|
const isAnswered = question?.answered
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={`${cat.id}-${difficulty}`}
|
||||||
|
whileHover={!isAnswered && amICurrentPlayer ? { scale: 1.05 } : {}}
|
||||||
|
whileTap={!isAnswered && amICurrentPlayer ? { scale: 0.95 } : {}}
|
||||||
|
onClick={() => question && handleSelectQuestion(question, cat.id)}
|
||||||
|
disabled={isAnswered || !amICurrentPlayer}
|
||||||
|
className={`p-4 rounded transition-all ${
|
||||||
|
isAnswered ? 'opacity-30' : amICurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isAnswered ? config.colors.bg : cat.color + '40',
|
||||||
|
border: `2px solid ${cat.color}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xl font-bold" style={{ color: config.colors.text }}>
|
||||||
|
{difficulty * 100}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showingQuestion && 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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.bg,
|
||||||
|
border: `3px solid ${config.colors.primary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Answer Input */}
|
||||||
|
{amICurrentPlayer && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={answer}
|
||||||
|
onChange={(e) => setAnswer(e.target.value)}
|
||||||
|
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"
|
||||||
|
style={{
|
||||||
|
border: `2px solid ${config.colors.primary}`,
|
||||||
|
color: config.colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitAnswer}
|
||||||
|
disabled={!answer.trim()}
|
||||||
|
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.primary,
|
||||||
|
color: config.colors.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Responder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!amICurrentPlayer && (
|
||||||
|
<p className="text-center" style={styles.textSecondary}>
|
||||||
|
Esperando respuesta de {currentPlayer?.name}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Steal Prompt */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showStealPrompt && room.current_team === myTeam && (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="p-6 rounded-lg text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.bg,
|
||||||
|
border: `3px solid ${config.colors.accent}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-bold 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>
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStealDecision(true)}
|
||||||
|
className="px-6 py-3 rounded-lg font-bold"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.accent,
|
||||||
|
color: config.colors.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
¡Robar!
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStealDecision(false)}
|
||||||
|
className="px-6 py-3 rounded-lg font-bold"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: config.colors.text,
|
||||||
|
border: `2px solid ${config.colors.text}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pasar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Emoji Reactions */}
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
||||||
|
{emojis.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
onClick={() => sendEmojiReaction(emoji)}
|
||||||
|
className="text-2xl p-2 rounded-lg transition-transform hover:scale-125"
|
||||||
|
style={{ backgroundColor: config.colors.bg + '80' }}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
213
frontend/src/pages/Home.tsx
Normal file
213
frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useSocket } from '../hooks/useSocket'
|
||||||
|
import { useGameStore } from '../stores/gameStore'
|
||||||
|
import { useThemeStore, themes } from '../stores/themeStore'
|
||||||
|
import { useThemeStyles } from '../themes/ThemeProvider'
|
||||||
|
import type { ThemeName } from '../types'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [playerName, setPlayerName] = useState('')
|
||||||
|
const [roomCode, setRoomCode] = useState('')
|
||||||
|
const [mode, setMode] = useState<'select' | 'create' | 'join'>('select')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { createRoom, joinRoom } = useSocket()
|
||||||
|
const { setPlayerName: storeSetPlayerName, room } = useGameStore()
|
||||||
|
const { currentTheme, setTheme } = useThemeStore()
|
||||||
|
const { config, styles } = useThemeStyles()
|
||||||
|
|
||||||
|
// Navigate when room is created/joined
|
||||||
|
if (room) {
|
||||||
|
navigate(`/lobby/${room.code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateRoom = () => {
|
||||||
|
if (!playerName.trim()) {
|
||||||
|
setError('Ingresa tu nombre')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storeSetPlayerName(playerName.trim())
|
||||||
|
createRoom(playerName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJoinRoom = () => {
|
||||||
|
if (!playerName.trim()) {
|
||||||
|
setError('Ingresa tu nombre')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!roomCode.trim() || roomCode.length !== 6) {
|
||||||
|
setError('Ingresa un código de sala válido (6 caracteres)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storeSetPlayerName(playerName.trim())
|
||||||
|
joinRoom(roomCode.toUpperCase(), playerName.trim(), 'A')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex flex-col items-center justify-center p-4"
|
||||||
|
style={styles.bgPrimary}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-center mb-8"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className={`text-4xl md:text-6xl font-bold mb-2 ${styles.glowEffect}`}
|
||||||
|
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
|
||||||
|
data-text="WebTriviasMulti"
|
||||||
|
>
|
||||||
|
WebTriviasMulti
|
||||||
|
</h1>
|
||||||
|
<p style={styles.textSecondary}>Trivia multiplayer en tiempo real</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Theme Selector */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="flex flex-wrap gap-2 mb-8 justify-center"
|
||||||
|
>
|
||||||
|
{(Object.keys(themes) as ThemeName[]).map((themeName) => (
|
||||||
|
<button
|
||||||
|
key={themeName}
|
||||||
|
onClick={() => setTheme(themeName)}
|
||||||
|
className={`px-3 py-1 rounded text-sm transition-all ${
|
||||||
|
currentTheme === themeName
|
||||||
|
? 'ring-2 ring-offset-2'
|
||||||
|
: 'opacity-70 hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: themes[themeName].colors.primary + '30',
|
||||||
|
color: themes[themeName].colors.primary,
|
||||||
|
ringColor: themes[themeName].colors.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{themes[themeName].displayName}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="w-full max-w-md p-6 rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.bg,
|
||||||
|
border: `2px solid ${config.colors.primary}`,
|
||||||
|
boxShadow: config.effects.glow
|
||||||
|
? `0 0 20px ${config.colors.primary}40`
|
||||||
|
: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'select' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('create')}
|
||||||
|
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.primary,
|
||||||
|
color: config.colors.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Crear Sala
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('join')}
|
||||||
|
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: config.colors.primary,
|
||||||
|
border: `2px solid ${config.colors.primary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unirse a Sala
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMode('select')
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
className="text-sm mb-2"
|
||||||
|
style={{ color: config.colors.textMuted }}
|
||||||
|
>
|
||||||
|
← Volver
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1" style={styles.textSecondary}>
|
||||||
|
Tu nombre
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={playerName}
|
||||||
|
onChange={(e) => setPlayerName(e.target.value)}
|
||||||
|
placeholder="Ingresa tu nombre"
|
||||||
|
maxLength={20}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-transparent outline-none"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${config.colors.primary}`,
|
||||||
|
color: config.colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'join' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1" style={styles.textSecondary}>
|
||||||
|
Código de sala
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={roomCode}
|
||||||
|
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
|
||||||
|
placeholder="ABCD12"
|
||||||
|
maxLength={6}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-transparent outline-none uppercase tracking-widest text-center text-xl"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${config.colors.primary}`,
|
||||||
|
color: config.colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-500 text-sm text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={mode === 'create' ? handleCreateRoom : handleJoinRoom}
|
||||||
|
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.primary,
|
||||||
|
color: config.colors.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'create' ? 'Crear Sala' : 'Unirse'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="mt-8 text-sm"
|
||||||
|
style={styles.textSecondary}
|
||||||
|
>
|
||||||
|
8 categorías • 2 equipos • Preguntas diarias
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
frontend/src/pages/Lobby.tsx
Normal file
227
frontend/src/pages/Lobby.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useSocket } from '../hooks/useSocket'
|
||||||
|
import { useGameStore } from '../stores/gameStore'
|
||||||
|
import { useThemeStyles } from '../themes/ThemeProvider'
|
||||||
|
|
||||||
|
export default function Lobby() {
|
||||||
|
const { roomCode } = useParams<{ roomCode: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { changeTeam, startGame } = useSocket()
|
||||||
|
const { room, playerName } = useGameStore()
|
||||||
|
const { config, styles } = useThemeStyles()
|
||||||
|
|
||||||
|
// Redirect if no room
|
||||||
|
useEffect(() => {
|
||||||
|
if (!room && !roomCode) {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}, [room, roomCode, navigate])
|
||||||
|
|
||||||
|
// Navigate to game when started
|
||||||
|
useEffect(() => {
|
||||||
|
if (room?.status === 'playing') {
|
||||||
|
navigate(`/game/${room.code}`)
|
||||||
|
}
|
||||||
|
}, [room?.status, room?.code, navigate])
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
|
||||||
|
<p style={styles.textSecondary}>Cargando...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHost = room.host === playerName
|
||||||
|
const canStart = room.teams.A.length > 0 && room.teams.B.length > 0
|
||||||
|
|
||||||
|
const handleStartGame = () => {
|
||||||
|
// In production, fetch today's questions and build board
|
||||||
|
const sampleBoard = {
|
||||||
|
'1': [
|
||||||
|
{ id: 1, category_id: 1, difficulty: 1, points: 100, time_seconds: 15, answered: false },
|
||||||
|
{ id: 2, category_id: 1, difficulty: 2, points: 200, time_seconds: 20, answered: false },
|
||||||
|
{ id: 3, category_id: 1, difficulty: 3, points: 300, time_seconds: 25, answered: false },
|
||||||
|
{ id: 4, category_id: 1, difficulty: 4, points: 400, time_seconds: 35, answered: false },
|
||||||
|
{ id: 5, category_id: 1, difficulty: 5, points: 500, time_seconds: 45, answered: false },
|
||||||
|
],
|
||||||
|
// Add more categories...
|
||||||
|
}
|
||||||
|
startGame(sampleBoard)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-4" style={styles.bgPrimary}>
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-center mb-8"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className={`text-3xl font-bold mb-2 ${styles.glowEffect}`}
|
||||||
|
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
|
||||||
|
>
|
||||||
|
Sala de Espera
|
||||||
|
</h1>
|
||||||
|
<div
|
||||||
|
className="inline-block px-6 py-2 rounded-lg text-2xl tracking-widest"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.primary + '20',
|
||||||
|
color: config.colors.primary,
|
||||||
|
border: `2px solid ${config.colors.primary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{room.code}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm" style={styles.textSecondary}>
|
||||||
|
Comparte este código con tus amigos
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Teams */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 mb-8">
|
||||||
|
{/* Team A */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="p-4 rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.primary + '10',
|
||||||
|
border: `2px solid ${config.colors.primary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-xl font-bold mb-4 text-center"
|
||||||
|
style={{ color: config.colors.primary }}
|
||||||
|
>
|
||||||
|
Equipo A
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2 min-h-[200px]">
|
||||||
|
{room.teams.A.map((player, index) => (
|
||||||
|
<div
|
||||||
|
key={player.socket_id || index}
|
||||||
|
className="px-4 py-2 rounded flex items-center justify-between"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.bg,
|
||||||
|
border: `1px solid ${config.colors.primary}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: config.colors.text }}>{player.name}</span>
|
||||||
|
{player.name === room.host && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded" style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}>
|
||||||
|
Host
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{room.teams.A.length < 4 && (
|
||||||
|
<button
|
||||||
|
onClick={() => changeTeam('A')}
|
||||||
|
className="w-full py-2 rounded border-2 border-dashed opacity-50 hover:opacity-100 transition-opacity"
|
||||||
|
style={{
|
||||||
|
borderColor: config.colors.primary,
|
||||||
|
color: config.colors.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Unirse al Equipo A
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Team B */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="p-4 rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.secondary + '10',
|
||||||
|
border: `2px solid ${config.colors.secondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-xl font-bold mb-4 text-center"
|
||||||
|
style={{ color: config.colors.secondary }}
|
||||||
|
>
|
||||||
|
Equipo B
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2 min-h-[200px]">
|
||||||
|
{room.teams.B.map((player, index) => (
|
||||||
|
<div
|
||||||
|
key={player.socket_id || index}
|
||||||
|
className="px-4 py-2 rounded flex items-center justify-between"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.bg,
|
||||||
|
border: `1px solid ${config.colors.secondary}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: config.colors.text }}>{player.name}</span>
|
||||||
|
{player.name === room.host && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded" style={{ backgroundColor: config.colors.secondary, color: config.colors.bg }}>
|
||||||
|
Host
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{room.teams.B.length < 4 && (
|
||||||
|
<button
|
||||||
|
onClick={() => changeTeam('B')}
|
||||||
|
className="w-full py-2 rounded border-2 border-dashed opacity-50 hover:opacity-100 transition-opacity"
|
||||||
|
style={{
|
||||||
|
borderColor: config.colors.secondary,
|
||||||
|
color: config.colors.secondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Unirse al Equipo B
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start Button */}
|
||||||
|
{isHost && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleStartGame}
|
||||||
|
disabled={!canStart}
|
||||||
|
className={`px-8 py-4 rounded-lg text-xl font-bold transition-all ${
|
||||||
|
canStart ? 'hover:scale-105' : 'opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.primary,
|
||||||
|
color: config.colors.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{canStart ? 'Iniciar Partida' : 'Esperando jugadores...'}
|
||||||
|
</button>
|
||||||
|
{!canStart && (
|
||||||
|
<p className="mt-2 text-sm" style={styles.textSecondary}>
|
||||||
|
Ambos equipos necesitan al menos un jugador
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isHost && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="text-center"
|
||||||
|
style={styles.textSecondary}
|
||||||
|
>
|
||||||
|
Esperando a que el host inicie la partida...
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
247
frontend/src/pages/Replay.tsx
Normal file
247
frontend/src/pages/Replay.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useThemeStyles } from '../themes/ThemeProvider'
|
||||||
|
import type { ReplayData, GameEvent } from '../types'
|
||||||
|
|
||||||
|
export default function Replay() {
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>()
|
||||||
|
const { config, styles } = useThemeStyles()
|
||||||
|
|
||||||
|
const [replayData, setReplayData] = useState<ReplayData | null>(null)
|
||||||
|
const [currentEventIndex, setCurrentEventIndex] = useState(0)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [speed, setSpeed] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Fetch replay data
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchReplay = async () => {
|
||||||
|
try {
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
|
const response = await fetch(`${apiUrl}/api/replay/code/${sessionId}`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setReplayData(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch replay:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchReplay()
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
// Playback logic
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !replayData) return
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentEventIndex((prev) => {
|
||||||
|
if (prev >= replayData.events.length - 1) {
|
||||||
|
setIsPlaying(false)
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return prev + 1
|
||||||
|
})
|
||||||
|
}, 1000 / speed)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isPlaying, speed, replayData])
|
||||||
|
|
||||||
|
const currentEvents = replayData?.events.slice(0, currentEventIndex + 1) || []
|
||||||
|
const currentScores = currentEvents.reduce(
|
||||||
|
(acc, event) => {
|
||||||
|
if (event.was_correct && event.points_earned) {
|
||||||
|
acc[event.team] += event.points_earned
|
||||||
|
} else if (!event.was_correct && event.was_steal && event.points_earned) {
|
||||||
|
acc[event.team] -= Math.abs(event.points_earned)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ A: 0, B: 0 }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
|
||||||
|
<p style={styles.textSecondary}>Cargando replay...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replayData) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center gap-4" style={styles.bgPrimary}>
|
||||||
|
<p style={styles.textSecondary}>No se encontró el replay</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="px-6 py-2 rounded-lg"
|
||||||
|
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
|
||||||
|
>
|
||||||
|
Volver al inicio
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-4" style={styles.bgPrimary}>
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<Link to="/" style={{ color: config.colors.primary }}>
|
||||||
|
← Volver
|
||||||
|
</Link>
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
|
||||||
|
>
|
||||||
|
Replay: {replayData.session.room_code}
|
||||||
|
</h1>
|
||||||
|
<div style={styles.textSecondary}>
|
||||||
|
{new Date(replayData.session.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scores */}
|
||||||
|
<div className="flex justify-center gap-8 mb-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm" style={styles.textSecondary}>Equipo A</div>
|
||||||
|
<div className="text-4xl font-bold" style={{ color: config.colors.primary }}>
|
||||||
|
{currentScores.A}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
|
||||||
|
<div className="text-4xl font-bold" style={{ color: config.colors.secondary }}>
|
||||||
|
{currentScores.B}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-4 mb-8 p-4 rounded-lg"
|
||||||
|
style={{ backgroundColor: config.colors.bg, border: `1px solid ${config.colors.primary}` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentEventIndex(0)}
|
||||||
|
className="p-2 rounded"
|
||||||
|
style={{ color: config.colors.text }}
|
||||||
|
>
|
||||||
|
⏮️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsPlaying(!isPlaying)}
|
||||||
|
className="px-6 py-2 rounded-lg font-bold"
|
||||||
|
style={{ backgroundColor: config.colors.primary, color: config.colors.bg }}
|
||||||
|
>
|
||||||
|
{isPlaying ? '⏸️ Pausar' : '▶️ Reproducir'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentEventIndex(replayData.events.length - 1)}
|
||||||
|
className="p-2 rounded"
|
||||||
|
style={{ color: config.colors.text }}
|
||||||
|
>
|
||||||
|
⏭️
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="ml-4 flex items-center gap-2">
|
||||||
|
<span style={styles.textSecondary}>Velocidad:</span>
|
||||||
|
{[1, 2, 4].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setSpeed(s)}
|
||||||
|
className={`px-3 py-1 rounded ${speed === s ? 'font-bold' : 'opacity-50'}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: speed === s ? config.colors.primary : 'transparent',
|
||||||
|
color: speed === s ? config.colors.bg : config.colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={replayData.events.length - 1}
|
||||||
|
value={currentEventIndex}
|
||||||
|
onChange={(e) => setCurrentEventIndex(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-sm" style={styles.textSecondary}>
|
||||||
|
<span>Evento {currentEventIndex + 1}</span>
|
||||||
|
<span>de {replayData.events.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events List */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg overflow-hidden"
|
||||||
|
style={{ border: `1px solid ${config.colors.primary}` }}
|
||||||
|
>
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{replayData.events.map((event, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={event.id}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: index <= currentEventIndex ? 1 : 0.3 }}
|
||||||
|
className={`p-3 border-b ${index === currentEventIndex ? 'ring-2 ring-inset' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderColor: config.colors.primary + '30',
|
||||||
|
backgroundColor: index <= currentEventIndex ? config.colors.bg : config.colors.bg + '50',
|
||||||
|
ringColor: config.colors.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded text-xs font-bold"
|
||||||
|
style={{
|
||||||
|
backgroundColor: event.team === 'A' ? config.colors.primary : config.colors.secondary,
|
||||||
|
color: config.colors.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{event.team}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: config.colors.text }}>{event.player_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{event.was_correct ? (
|
||||||
|
<span className="text-green-500">✓ +{event.points_earned}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-500">✗ {event.was_steal ? `-${Math.abs(event.points_earned || 0)}` : ''}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{event.answer_given && (
|
||||||
|
<div className="mt-1 text-sm" style={styles.textSecondary}>
|
||||||
|
Respuesta: "{event.answer_given}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Final Scores */}
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<div className="text-sm mb-2" style={styles.textSecondary}>Resultado Final</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
<span style={{ color: config.colors.primary }}>{replayData.session.team_a_score}</span>
|
||||||
|
<span style={styles.textSecondary}> - </span>
|
||||||
|
<span style={{ color: config.colors.secondary }}>{replayData.session.team_b_score}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
frontend/src/pages/Results.tsx
Normal file
210
frontend/src/pages/Results.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useSound } from '../hooks/useSound'
|
||||||
|
import { useAchievements } from '../hooks/useAchievements'
|
||||||
|
import { useGameStore } from '../stores/gameStore'
|
||||||
|
import { useThemeStyles } from '../themes/ThemeProvider'
|
||||||
|
|
||||||
|
export default function Results() {
|
||||||
|
const { roomCode } = useParams<{ roomCode: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { play } = useSound()
|
||||||
|
const { achievements } = useAchievements()
|
||||||
|
const { room, playerName, resetGame } = useGameStore()
|
||||||
|
const { config, styles } = useThemeStyles()
|
||||||
|
|
||||||
|
const myTeam = room?.teams.A.find(p => p.name === playerName) ? 'A' : 'B'
|
||||||
|
const won = room ? room.scores[myTeam] > room.scores[myTeam === 'A' ? 'B' : 'A'] : false
|
||||||
|
const tied = room ? room.scores.A === room.scores.B : false
|
||||||
|
|
||||||
|
// Play victory/defeat sound
|
||||||
|
useEffect(() => {
|
||||||
|
if (won) {
|
||||||
|
play('victory')
|
||||||
|
} else if (!tied) {
|
||||||
|
play('defeat')
|
||||||
|
}
|
||||||
|
}, [won, tied, play])
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
|
||||||
|
<p style={styles.textSecondary}>No hay resultados disponibles</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnerTeam = room.scores.A > room.scores.B ? 'A' : room.scores.B > room.scores.A ? 'B' : null
|
||||||
|
const newAchievements = achievements.filter(a => a.unlocked && a.unlockedAt)
|
||||||
|
|
||||||
|
const handlePlayAgain = () => {
|
||||||
|
resetGame()
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-4 flex flex-col items-center justify-center" style={styles.bgPrimary}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="text-center max-w-2xl w-full"
|
||||||
|
>
|
||||||
|
{/* Result Header */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -50 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
transition={{ type: 'spring', bounce: 0.5 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
{tied ? (
|
||||||
|
<h1
|
||||||
|
className={`text-5xl font-bold mb-2 ${styles.glowEffect}`}
|
||||||
|
style={{ color: config.colors.text, fontFamily: config.fonts.heading }}
|
||||||
|
>
|
||||||
|
¡EMPATE!
|
||||||
|
</h1>
|
||||||
|
) : won ? (
|
||||||
|
<>
|
||||||
|
<h1
|
||||||
|
className={`text-5xl font-bold mb-2 ${styles.glowEffect}`}
|
||||||
|
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
|
||||||
|
>
|
||||||
|
¡VICTORIA!
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl" style={styles.textSecondary}>
|
||||||
|
Tu equipo ha ganado
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1
|
||||||
|
className={`text-5xl font-bold mb-2`}
|
||||||
|
style={{ color: config.colors.textMuted, fontFamily: config.fonts.heading }}
|
||||||
|
>
|
||||||
|
DERROTA
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl" style={styles.textSecondary}>
|
||||||
|
Mejor suerte la próxima vez
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Scores */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="flex justify-center gap-8 mb-8"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`p-6 rounded-lg text-center ${winnerTeam === 'A' ? 'ring-4' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.primary + '20',
|
||||||
|
border: `2px solid ${config.colors.primary}`,
|
||||||
|
ringColor: config.colors.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo A</div>
|
||||||
|
<div className="text-5xl font-bold" style={{ color: config.colors.primary }}>
|
||||||
|
{room.scores.A}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm" style={styles.textSecondary}>
|
||||||
|
{room.teams.A.map(p => p.name).join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-3xl" style={styles.textSecondary}>VS</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`p-6 rounded-lg text-center ${winnerTeam === 'B' ? 'ring-4' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.secondary + '20',
|
||||||
|
border: `2px solid ${config.colors.secondary}`,
|
||||||
|
ringColor: config.colors.secondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo B</div>
|
||||||
|
<div className="text-5xl font-bold" style={{ color: config.colors.secondary }}>
|
||||||
|
{room.scores.B}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm" style={styles.textSecondary}>
|
||||||
|
{room.teams.B.map(p => p.name).join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* New Achievements */}
|
||||||
|
{newAchievements.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-bold mb-4" style={{ color: config.colors.accent }}>
|
||||||
|
¡Nuevos Logros Desbloqueados!
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
|
{newAchievements.map((achievement) => (
|
||||||
|
<motion.div
|
||||||
|
key={achievement.id}
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', bounce: 0.5 }}
|
||||||
|
className="p-4 rounded-lg text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.accent + '20',
|
||||||
|
border: `2px solid ${config.colors.accent}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-3xl mb-2">{achievement.icon}</div>
|
||||||
|
<div className="font-bold" style={{ color: config.colors.text }}>
|
||||||
|
{achievement.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={styles.textSecondary}>
|
||||||
|
{achievement.description}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.7 }}
|
||||||
|
className="flex flex-col sm:flex-row gap-4 justify-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handlePlayAgain}
|
||||||
|
className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105"
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.colors.primary,
|
||||||
|
color: config.colors.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Jugar de Nuevo
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/replay/${roomCode}`}
|
||||||
|
className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105 text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: config.colors.text,
|
||||||
|
border: `2px solid ${config.colors.text}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver Replay
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
frontend/src/services/api.ts
Normal file
116
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
|
|
||||||
|
class ApiService {
|
||||||
|
private baseUrl: string
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = API_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${endpoint}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API Error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game endpoints
|
||||||
|
async getCategories() {
|
||||||
|
return this.request<
|
||||||
|
Array<{ id: number; name: string; icon: string; color: string }>
|
||||||
|
>('/api/game/categories')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTodayQuestions() {
|
||||||
|
return this.request<{
|
||||||
|
date: string
|
||||||
|
categories: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string
|
||||||
|
questions: Array<{
|
||||||
|
difficulty: number
|
||||||
|
id: number
|
||||||
|
points: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}>('/api/game/today-questions')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQuestion(questionId: number) {
|
||||||
|
return this.request<{
|
||||||
|
id: number
|
||||||
|
question_text: string
|
||||||
|
difficulty: number
|
||||||
|
points: number
|
||||||
|
time_seconds: number
|
||||||
|
category_id: number
|
||||||
|
}>(`/api/game/question/${questionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAchievements() {
|
||||||
|
return this.request<
|
||||||
|
Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
}>
|
||||||
|
>('/api/game/achievements')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay endpoints
|
||||||
|
async getReplay(sessionId: string) {
|
||||||
|
return this.request<{
|
||||||
|
session: {
|
||||||
|
id: number
|
||||||
|
room_code: string
|
||||||
|
team_a_score: number
|
||||||
|
team_b_score: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
finished_at: string
|
||||||
|
}
|
||||||
|
events: Array<{
|
||||||
|
id: number
|
||||||
|
event_type: string
|
||||||
|
player_name: string
|
||||||
|
team: 'A' | 'B'
|
||||||
|
question_id: number
|
||||||
|
answer_given: string
|
||||||
|
was_correct: boolean
|
||||||
|
was_steal: boolean
|
||||||
|
points_earned: number
|
||||||
|
timestamp: string
|
||||||
|
}>
|
||||||
|
}>(`/api/replay/code/${sessionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listReplays(limit = 20, offset = 0) {
|
||||||
|
return this.request<
|
||||||
|
Array<{
|
||||||
|
id: number
|
||||||
|
room_code: string
|
||||||
|
team_a_score: number
|
||||||
|
team_b_score: number
|
||||||
|
finished_at: string
|
||||||
|
}>
|
||||||
|
>(`/api/replay?limit=${limit}&offset=${offset}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiService()
|
||||||
69
frontend/src/services/socket.ts
Normal file
69
frontend/src/services/socket.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { io, Socket } from 'socket.io-client'
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
connect(): Socket {
|
||||||
|
if (!this.socket) {
|
||||||
|
this.socket = io(SOCKET_URL, {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
autoConnect: true,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('Socket connected:', this.socket?.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.socket.on('disconnect', (reason) => {
|
||||||
|
console.log('Socket disconnected:', reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.socket.on('error', (error) => {
|
||||||
|
console.error('Socket error:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.socket
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.disconnect()
|
||||||
|
this.socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, callback: (data: unknown) => void): void {
|
||||||
|
this.listeners.get(event)?.delete(callback)
|
||||||
|
this.socket?.off(event, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, data?: unknown): void {
|
||||||
|
this.socket?.emit(event, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
get connected(): boolean {
|
||||||
|
return this.socket?.connected ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string | undefined {
|
||||||
|
return this.socket?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const socketService = new SocketService()
|
||||||
104
frontend/src/stores/gameStore.ts
Normal file
104
frontend/src/stores/gameStore.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { GameRoom, Player, Question, ChatMessage, Achievement } from '../types'
|
||||||
|
|
||||||
|
interface GameState {
|
||||||
|
// Room state
|
||||||
|
room: GameRoom | null
|
||||||
|
setRoom: (room: GameRoom | null) => void
|
||||||
|
|
||||||
|
// Player info
|
||||||
|
playerName: string
|
||||||
|
setPlayerName: (name: string) => void
|
||||||
|
|
||||||
|
// Current question
|
||||||
|
currentQuestion: Question | null
|
||||||
|
setCurrentQuestion: (question: Question | null) => void
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
timerEnd: Date | null
|
||||||
|
setTimerEnd: (end: Date | null) => void
|
||||||
|
|
||||||
|
// Chat messages
|
||||||
|
messages: ChatMessage[]
|
||||||
|
addMessage: (message: ChatMessage) => void
|
||||||
|
clearMessages: () => void
|
||||||
|
|
||||||
|
// Achievements
|
||||||
|
achievements: Achievement[]
|
||||||
|
setAchievements: (achievements: Achievement[]) => void
|
||||||
|
unlockAchievement: (id: number) => void
|
||||||
|
|
||||||
|
// Game stats (for achievements tracking)
|
||||||
|
stats: {
|
||||||
|
correctStreak: number
|
||||||
|
stealsAttempted: number
|
||||||
|
stealsSuccessful: number
|
||||||
|
categoryCorrect: Record<number, number>
|
||||||
|
fastAnswers: number
|
||||||
|
maxDeficit: number
|
||||||
|
}
|
||||||
|
updateStats: (updates: Partial<GameState['stats']>) => void
|
||||||
|
resetStats: () => void
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
showStealPrompt: boolean
|
||||||
|
setShowStealPrompt: (show: boolean) => void
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
resetGame: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialStats = {
|
||||||
|
correctStreak: 0,
|
||||||
|
stealsAttempted: 0,
|
||||||
|
stealsSuccessful: 0,
|
||||||
|
categoryCorrect: {},
|
||||||
|
fastAnswers: 0,
|
||||||
|
maxDeficit: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGameStore = create<GameState>((set) => ({
|
||||||
|
room: null,
|
||||||
|
setRoom: (room) => set({ room }),
|
||||||
|
|
||||||
|
playerName: '',
|
||||||
|
setPlayerName: (playerName) => set({ playerName }),
|
||||||
|
|
||||||
|
currentQuestion: null,
|
||||||
|
setCurrentQuestion: (currentQuestion) => set({ currentQuestion }),
|
||||||
|
|
||||||
|
timerEnd: null,
|
||||||
|
setTimerEnd: (timerEnd) => set({ timerEnd }),
|
||||||
|
|
||||||
|
messages: [],
|
||||||
|
addMessage: (message) =>
|
||||||
|
set((state) => ({ messages: [...state.messages, message].slice(-100) })),
|
||||||
|
clearMessages: () => set({ messages: [] }),
|
||||||
|
|
||||||
|
achievements: [],
|
||||||
|
setAchievements: (achievements) => set({ achievements }),
|
||||||
|
unlockAchievement: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
achievements: state.achievements.map((a) =>
|
||||||
|
a.id === id ? { ...a, unlocked: true, unlockedAt: new Date().toISOString() } : a
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
stats: initialStats,
|
||||||
|
updateStats: (updates) =>
|
||||||
|
set((state) => ({ stats: { ...state.stats, ...updates } })),
|
||||||
|
resetStats: () => set({ stats: initialStats }),
|
||||||
|
|
||||||
|
showStealPrompt: false,
|
||||||
|
setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }),
|
||||||
|
|
||||||
|
resetGame: () =>
|
||||||
|
set({
|
||||||
|
room: null,
|
||||||
|
currentQuestion: null,
|
||||||
|
timerEnd: null,
|
||||||
|
messages: [],
|
||||||
|
stats: initialStats,
|
||||||
|
showStealPrompt: false,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
90
frontend/src/stores/soundStore.ts
Normal file
90
frontend/src/stores/soundStore.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { ThemeName } from '../types'
|
||||||
|
|
||||||
|
type SoundEffect =
|
||||||
|
| 'correct'
|
||||||
|
| 'incorrect'
|
||||||
|
| 'steal'
|
||||||
|
| 'timer_tick'
|
||||||
|
| 'timer_urgent'
|
||||||
|
| 'victory'
|
||||||
|
| 'defeat'
|
||||||
|
| 'select'
|
||||||
|
|
||||||
|
interface SoundState {
|
||||||
|
volume: number
|
||||||
|
muted: boolean
|
||||||
|
setVolume: (volume: number) => void
|
||||||
|
setMuted: (muted: boolean) => void
|
||||||
|
toggleMute: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSoundStore = create<SoundState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
volume: 0.7,
|
||||||
|
muted: false,
|
||||||
|
setVolume: (volume) => set({ volume }),
|
||||||
|
setMuted: (muted) => set({ muted }),
|
||||||
|
toggleMute: () => set((state) => ({ muted: !state.muted })),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'trivia-sound',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sound file paths per theme
|
||||||
|
export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
||||||
|
drrr: {
|
||||||
|
correct: '/sounds/drrr/correct.mp3',
|
||||||
|
incorrect: '/sounds/drrr/incorrect.mp3',
|
||||||
|
steal: '/sounds/drrr/steal.mp3',
|
||||||
|
timer_tick: '/sounds/drrr/tick.mp3',
|
||||||
|
timer_urgent: '/sounds/drrr/urgent.mp3',
|
||||||
|
victory: '/sounds/drrr/victory.mp3',
|
||||||
|
defeat: '/sounds/drrr/defeat.mp3',
|
||||||
|
select: '/sounds/drrr/select.mp3',
|
||||||
|
},
|
||||||
|
retro: {
|
||||||
|
correct: '/sounds/retro/correct.mp3',
|
||||||
|
incorrect: '/sounds/retro/incorrect.mp3',
|
||||||
|
steal: '/sounds/retro/steal.mp3',
|
||||||
|
timer_tick: '/sounds/retro/tick.mp3',
|
||||||
|
timer_urgent: '/sounds/retro/urgent.mp3',
|
||||||
|
victory: '/sounds/retro/victory.mp3',
|
||||||
|
defeat: '/sounds/retro/defeat.mp3',
|
||||||
|
select: '/sounds/retro/select.mp3',
|
||||||
|
},
|
||||||
|
minimal: {
|
||||||
|
correct: '/sounds/minimal/correct.mp3',
|
||||||
|
incorrect: '/sounds/minimal/incorrect.mp3',
|
||||||
|
steal: '/sounds/minimal/steal.mp3',
|
||||||
|
timer_tick: '/sounds/minimal/tick.mp3',
|
||||||
|
timer_urgent: '/sounds/minimal/urgent.mp3',
|
||||||
|
victory: '/sounds/minimal/victory.mp3',
|
||||||
|
defeat: '/sounds/minimal/defeat.mp3',
|
||||||
|
select: '/sounds/minimal/select.mp3',
|
||||||
|
},
|
||||||
|
rgb: {
|
||||||
|
correct: '/sounds/rgb/correct.mp3',
|
||||||
|
incorrect: '/sounds/rgb/incorrect.mp3',
|
||||||
|
steal: '/sounds/rgb/steal.mp3',
|
||||||
|
timer_tick: '/sounds/rgb/tick.mp3',
|
||||||
|
timer_urgent: '/sounds/rgb/urgent.mp3',
|
||||||
|
victory: '/sounds/rgb/victory.mp3',
|
||||||
|
defeat: '/sounds/rgb/defeat.mp3',
|
||||||
|
select: '/sounds/rgb/select.mp3',
|
||||||
|
},
|
||||||
|
anime: {
|
||||||
|
correct: '/sounds/anime/correct.mp3',
|
||||||
|
incorrect: '/sounds/anime/incorrect.mp3',
|
||||||
|
steal: '/sounds/anime/steal.mp3',
|
||||||
|
timer_tick: '/sounds/anime/tick.mp3',
|
||||||
|
timer_urgent: '/sounds/anime/urgent.mp3',
|
||||||
|
victory: '/sounds/anime/victory.mp3',
|
||||||
|
defeat: '/sounds/anime/defeat.mp3',
|
||||||
|
select: '/sounds/anime/select.mp3',
|
||||||
|
},
|
||||||
|
}
|
||||||
140
frontend/src/stores/themeStore.ts
Normal file
140
frontend/src/stores/themeStore.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { ThemeName, ThemeConfig } from '../types'
|
||||||
|
|
||||||
|
export const themes: Record<ThemeName, ThemeConfig> = {
|
||||||
|
drrr: {
|
||||||
|
name: 'drrr',
|
||||||
|
displayName: 'DRRR (Dollars)',
|
||||||
|
colors: {
|
||||||
|
bg: '#0a0a0a',
|
||||||
|
primary: '#FFE135',
|
||||||
|
secondary: '#00FFFF',
|
||||||
|
accent: '#FF00FF',
|
||||||
|
text: '#ffffff',
|
||||||
|
textMuted: '#888888',
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
heading: 'Bebas Neue, sans-serif',
|
||||||
|
body: 'Inter, sans-serif',
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
glow: true,
|
||||||
|
scanlines: false,
|
||||||
|
glitch: true,
|
||||||
|
sparkles: false,
|
||||||
|
rgbShift: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retro: {
|
||||||
|
name: 'retro',
|
||||||
|
displayName: 'Retro Arcade',
|
||||||
|
colors: {
|
||||||
|
bg: '#1a1a2e',
|
||||||
|
primary: '#9B59B6',
|
||||||
|
secondary: '#E91E63',
|
||||||
|
accent: '#00FFFF',
|
||||||
|
text: '#ffffff',
|
||||||
|
textMuted: '#aaaaaa',
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
heading: '"Press Start 2P", cursive',
|
||||||
|
body: '"Press Start 2P", cursive',
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
glow: true,
|
||||||
|
scanlines: true,
|
||||||
|
glitch: false,
|
||||||
|
sparkles: false,
|
||||||
|
rgbShift: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
minimal: {
|
||||||
|
name: 'minimal',
|
||||||
|
displayName: 'Moderno Minimalista',
|
||||||
|
colors: {
|
||||||
|
bg: '#ffffff',
|
||||||
|
primary: '#3498DB',
|
||||||
|
secondary: '#2ECC71',
|
||||||
|
accent: '#E74C3C',
|
||||||
|
text: '#2c3e50',
|
||||||
|
textMuted: '#7f8c8d',
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
heading: 'Inter, sans-serif',
|
||||||
|
body: 'Inter, sans-serif',
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
glow: false,
|
||||||
|
scanlines: false,
|
||||||
|
glitch: false,
|
||||||
|
sparkles: false,
|
||||||
|
rgbShift: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rgb: {
|
||||||
|
name: 'rgb',
|
||||||
|
displayName: 'Gaming RGB',
|
||||||
|
colors: {
|
||||||
|
bg: '#0D0D0D',
|
||||||
|
primary: '#FF0080',
|
||||||
|
secondary: '#00FF80',
|
||||||
|
accent: '#8000FF',
|
||||||
|
text: '#ffffff',
|
||||||
|
textMuted: '#666666',
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
heading: 'Inter, sans-serif',
|
||||||
|
body: 'Inter, sans-serif',
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
glow: true,
|
||||||
|
scanlines: false,
|
||||||
|
glitch: false,
|
||||||
|
sparkles: false,
|
||||||
|
rgbShift: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
anime: {
|
||||||
|
name: 'anime',
|
||||||
|
displayName: 'Anime Clásico 90s',
|
||||||
|
colors: {
|
||||||
|
bg: '#FFF5F5',
|
||||||
|
primary: '#FFB6C1',
|
||||||
|
secondary: '#E6E6FA',
|
||||||
|
accent: '#FF69B4',
|
||||||
|
text: '#4a4a4a',
|
||||||
|
textMuted: '#888888',
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
heading: 'Inter, sans-serif',
|
||||||
|
body: 'Inter, sans-serif',
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
glow: false,
|
||||||
|
scanlines: false,
|
||||||
|
glitch: false,
|
||||||
|
sparkles: true,
|
||||||
|
rgbShift: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
currentTheme: ThemeName
|
||||||
|
setTheme: (theme: ThemeName) => void
|
||||||
|
getThemeConfig: () => ThemeConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeStore = create<ThemeState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
currentTheme: 'drrr',
|
||||||
|
setTheme: (currentTheme) => set({ currentTheme }),
|
||||||
|
getThemeConfig: () => themes[get().currentTheme],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'trivia-theme',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
65
frontend/src/themes/ThemeProvider.tsx
Normal file
65
frontend/src/themes/ThemeProvider.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
import { useThemeStore, themes } from '../stores/themeStore'
|
||||||
|
|
||||||
|
interface ThemeProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||||
|
const { currentTheme } = useThemeStore()
|
||||||
|
const theme = themes[currentTheme]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Apply CSS variables
|
||||||
|
const root = document.documentElement
|
||||||
|
root.style.setProperty('--color-bg', theme.colors.bg)
|
||||||
|
root.style.setProperty('--color-primary', theme.colors.primary)
|
||||||
|
root.style.setProperty('--color-secondary', theme.colors.secondary)
|
||||||
|
root.style.setProperty('--color-accent', theme.colors.accent)
|
||||||
|
root.style.setProperty('--color-text', theme.colors.text)
|
||||||
|
root.style.setProperty('--color-text-muted', theme.colors.textMuted)
|
||||||
|
root.style.setProperty('--font-heading', theme.fonts.heading)
|
||||||
|
root.style.setProperty('--font-body', theme.fonts.body)
|
||||||
|
|
||||||
|
// Apply body background
|
||||||
|
document.body.style.backgroundColor = theme.colors.bg
|
||||||
|
document.body.style.color = theme.colors.text
|
||||||
|
|
||||||
|
// Add theme class to body
|
||||||
|
document.body.className = `theme-${currentTheme}`
|
||||||
|
}, [currentTheme, theme])
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme-aware component wrapper
|
||||||
|
export function useThemeStyles() {
|
||||||
|
const { currentTheme, getThemeConfig } = useThemeStore()
|
||||||
|
const config = getThemeConfig()
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme: currentTheme,
|
||||||
|
config,
|
||||||
|
styles: {
|
||||||
|
// Background styles
|
||||||
|
bgPrimary: { backgroundColor: config.colors.bg },
|
||||||
|
bgSecondary: { backgroundColor: config.colors.primary + '20' },
|
||||||
|
|
||||||
|
// Text styles
|
||||||
|
textPrimary: { color: config.colors.text },
|
||||||
|
textSecondary: { color: config.colors.textMuted },
|
||||||
|
textAccent: { color: config.colors.primary },
|
||||||
|
|
||||||
|
// Border styles
|
||||||
|
borderPrimary: { borderColor: config.colors.primary },
|
||||||
|
borderSecondary: { borderColor: config.colors.secondary },
|
||||||
|
|
||||||
|
// Effect classes
|
||||||
|
glowEffect: config.effects.glow ? 'text-shadow-neon' : '',
|
||||||
|
scanlineEffect: config.effects.scanlines ? 'crt-scanlines' : '',
|
||||||
|
glitchEffect: config.effects.glitch ? 'glitch-text' : '',
|
||||||
|
sparkleEffect: config.effects.sparkles ? 'sparkle' : '',
|
||||||
|
rgbEffect: config.effects.rgbShift ? 'animate-rgb-shift' : '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
135
frontend/src/types/index.ts
Normal file
135
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Game Types
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
name: string
|
||||||
|
team: 'A' | 'B'
|
||||||
|
position: number
|
||||||
|
socket_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamState {
|
||||||
|
players: Player[]
|
||||||
|
score: number
|
||||||
|
currentPlayerIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
question_text?: string
|
||||||
|
difficulty: number
|
||||||
|
points: number
|
||||||
|
time_seconds: number
|
||||||
|
answered?: boolean
|
||||||
|
selected?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameRoom {
|
||||||
|
code: string
|
||||||
|
status: 'waiting' | 'playing' | 'finished'
|
||||||
|
host: string
|
||||||
|
teams: {
|
||||||
|
A: Player[]
|
||||||
|
B: Player[]
|
||||||
|
}
|
||||||
|
current_team: 'A' | 'B' | null
|
||||||
|
current_player_index: { A: number; B: number }
|
||||||
|
current_question: number | null
|
||||||
|
can_steal: boolean
|
||||||
|
scores: { A: number; B: number }
|
||||||
|
board: Record<string, Question[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
player_name: string
|
||||||
|
team: 'A' | 'B'
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmojiReaction {
|
||||||
|
player_name: string
|
||||||
|
team: 'A' | 'B'
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Achievement {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
unlocked?: boolean
|
||||||
|
unlockedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnswerResult {
|
||||||
|
player_name: string
|
||||||
|
team: 'A' | 'B'
|
||||||
|
answer: string
|
||||||
|
valid: boolean
|
||||||
|
reason: string
|
||||||
|
points_earned: number
|
||||||
|
was_steal: boolean
|
||||||
|
room: GameRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameEvent {
|
||||||
|
id: number
|
||||||
|
event_type: string
|
||||||
|
player_name: string
|
||||||
|
team: 'A' | 'B'
|
||||||
|
question_id: number
|
||||||
|
answer_given: string
|
||||||
|
was_correct: boolean
|
||||||
|
was_steal: boolean
|
||||||
|
points_earned: number
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayData {
|
||||||
|
session: {
|
||||||
|
id: number
|
||||||
|
room_code: string
|
||||||
|
team_a_score: number
|
||||||
|
team_b_score: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
finished_at: string
|
||||||
|
}
|
||||||
|
events: GameEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme Types
|
||||||
|
|
||||||
|
export type ThemeName = 'drrr' | 'retro' | 'minimal' | 'rgb' | 'anime'
|
||||||
|
|
||||||
|
export interface ThemeConfig {
|
||||||
|
name: ThemeName
|
||||||
|
displayName: string
|
||||||
|
colors: {
|
||||||
|
bg: string
|
||||||
|
primary: string
|
||||||
|
secondary: string
|
||||||
|
accent: string
|
||||||
|
text: string
|
||||||
|
textMuted: string
|
||||||
|
}
|
||||||
|
fonts: {
|
||||||
|
heading: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
effects: {
|
||||||
|
glow: boolean
|
||||||
|
scanlines: boolean
|
||||||
|
glitch: boolean
|
||||||
|
sparkles: boolean
|
||||||
|
rgbShift: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
78
frontend/tailwind.config.js
Normal file
78
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// DRRR Theme
|
||||||
|
drrr: {
|
||||||
|
bg: '#0a0a0a',
|
||||||
|
primary: '#FFE135',
|
||||||
|
secondary: '#00FFFF',
|
||||||
|
accent: '#FF00FF',
|
||||||
|
},
|
||||||
|
// Retro Arcade Theme
|
||||||
|
retro: {
|
||||||
|
bg: '#1a1a2e',
|
||||||
|
primary: '#9B59B6',
|
||||||
|
secondary: '#E91E63',
|
||||||
|
accent: '#00FFFF',
|
||||||
|
},
|
||||||
|
// Gaming RGB Theme
|
||||||
|
rgb: {
|
||||||
|
bg: '#0D0D0D',
|
||||||
|
primary: '#FF0000',
|
||||||
|
secondary: '#00FF00',
|
||||||
|
accent: '#0000FF',
|
||||||
|
},
|
||||||
|
// Anime 90s Theme
|
||||||
|
anime: {
|
||||||
|
bg: '#FFF5F5',
|
||||||
|
primary: '#FFB6C1',
|
||||||
|
secondary: '#E6E6FA',
|
||||||
|
accent: '#FF69B4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
'pixel': ['"Press Start 2P"', 'cursive'],
|
||||||
|
'urban': ['Bebas Neue', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'glitch': 'glitch 0.3s infinite',
|
||||||
|
'pulse-neon': 'pulse-neon 2s infinite',
|
||||||
|
'scanline': 'scanline 8s linear infinite',
|
||||||
|
'sparkle': 'sparkle 1.5s infinite',
|
||||||
|
'rgb-shift': 'rgb-shift 3s infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
glitch: {
|
||||||
|
'0%, 100%': { transform: 'translate(0)' },
|
||||||
|
'20%': { transform: 'translate(-2px, 2px)' },
|
||||||
|
'40%': { transform: 'translate(-2px, -2px)' },
|
||||||
|
'60%': { transform: 'translate(2px, 2px)' },
|
||||||
|
'80%': { transform: 'translate(2px, -2px)' },
|
||||||
|
},
|
||||||
|
'pulse-neon': {
|
||||||
|
'0%, 100%': { boxShadow: '0 0 5px currentColor, 0 0 10px currentColor' },
|
||||||
|
'50%': { boxShadow: '0 0 20px currentColor, 0 0 30px currentColor' },
|
||||||
|
},
|
||||||
|
scanline: {
|
||||||
|
'0%': { transform: 'translateY(-100%)' },
|
||||||
|
'100%': { transform: 'translateY(100%)' },
|
||||||
|
},
|
||||||
|
sparkle: {
|
||||||
|
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
|
||||||
|
'50%': { opacity: 0.5, transform: 'scale(0.8)' },
|
||||||
|
},
|
||||||
|
'rgb-shift': {
|
||||||
|
'0%': { filter: 'hue-rotate(0deg)' },
|
||||||
|
'100%': { filter: 'hue-rotate(360deg)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: true
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user