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