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:
2026-01-26 07:50:48 +00:00
commit 43021b9c3c
57 changed files with 5446 additions and 0 deletions

335
frontend/src/pages/Game.tsx Normal file
View 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>
)
}