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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user