Files
Trivy/frontend/src/pages/Game.tsx
consultoria-as ab201e113a feat: 5 categorías rotativas por partida + pool de 200 preguntas + mejoras UI
Cambios principales:
- Tablero ahora muestra 5 categorías aleatorias (de 8 disponibles)
- Pool de 200 preguntas (8 cats × 5 diffs × 5 opciones)
- Preguntas rotan aleatoriamente entre partidas
- Diseño mejorado estilo Jeopardy con efectos visuales
- Socket singleton para conexión persistente
- Nuevos sonidos: game_start, player_join, question_reveal, hover, countdown
- Control de volumen vertical
- Barra de progreso del timer en modal de preguntas
- Animaciones mejoradas con Framer Motion

Backend:
- question_service: selección aleatoria de 5 categorías
- room_manager: fix retorno de create_room
- game_events: carga board desde DB, await en enter_room

Frontend:
- Game.tsx: tablero dinámico, efectos hover, mejor scoreboard
- useSocket: singleton service, eventos con sonidos
- SoundControl: slider vertical
- soundStore: 5 nuevos efectos de sonido

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 23:44:55 +00:00

633 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useSocket } from '../hooks/useSocket'
import { useSound } from '../hooks/useSound'
import { useGameStore } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider'
import EmojiReactions from '../components/chat/EmojiReactions'
import ReactionOverlay from '../components/chat/ReactionOverlay'
import TeamChat from '../components/chat/TeamChat'
import SoundControl from '../components/ui/SoundControl'
import type { Question } from '../types'
// All available categories with their styling
const allCategories: Record<number, { name: string; icon: string; color: string }> = {
1: { name: 'Nintendo', icon: '🍄', color: '#E60012' },
2: { name: 'Xbox', icon: '🎮', color: '#107C10' },
3: { name: 'PlayStation', icon: '🎯', color: '#003791' },
4: { name: 'Anime', icon: '⛩️', color: '#FF6B9D' },
5: { name: 'Música', icon: '🎵', color: '#1DB954' },
6: { name: 'Películas', icon: '🎬', color: '#F5C518' },
7: { name: 'Libros', icon: '📚', color: '#8B4513' },
8: { name: 'Historia', icon: '🏛️', color: '#6B5B95' },
}
export default function Game() {
useParams<{ roomCode: string }>()
const navigate = useNavigate()
const { selectQuestion, submitAnswer, stealDecision, sendTeamMessage } = useSocket()
const { play } = useSound()
const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt, teamMessages } = useGameStore()
const { config, styles } = useThemeStyles()
const [answer, setAnswer] = useState('')
const [timeLeft, setTimeLeft] = useState(0)
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
// Redirect if game finished
useEffect(() => {
if (room?.status === 'finished') {
navigate(`/results/${room.code}`)
}
}, [room?.status, room?.code, navigate])
// Play sound when question is revealed
useEffect(() => {
if (currentQuestion) {
play('question_reveal')
}
}, [currentQuestion, play])
// Timer logic with sound effects
useEffect(() => {
if (!currentQuestion) return
setTimeLeft(currentQuestion.time_seconds)
const interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
if (prev <= 4 && prev > 1) {
play('countdown')
} else if (prev <= 6 && prev > 4) {
play('timer_urgent')
} else if (prev > 6) {
play('timer_tick')
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [currentQuestion, play])
// Hover sound handler
const handleCellHover = useCallback((cellId: string, canSelect: boolean) => {
if (canSelect && hoveredCell !== cellId) {
setHoveredCell(cellId)
play('hover')
}
}, [hoveredCell, play])
if (!room) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-8 h-8 border-4 rounded-full"
style={{ borderColor: config.colors.primary, borderTopColor: 'transparent' }}
/>
</div>
)
}
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)
}
const handleSubmitAnswer = () => {
if (!currentQuestion || !answer.trim()) return
submitAnswer(answer, currentQuestion as unknown as Record<string, unknown>, room.can_steal)
setAnswer('')
}
const handleStealDecision = (attempt: boolean) => {
if (!currentQuestion) return
if (!attempt) {
stealDecision(false, currentQuestion.id)
}
setShowStealPrompt(false)
}
const handleSendTeamMessage = (message: string) => {
if (room && playerName && myTeam) {
sendTeamMessage(message, room.code, myTeam, playerName)
}
}
const isGameActive = room.status === 'playing'
const timerProgress = currentQuestion ? (timeLeft / currentQuestion.time_seconds) * 100 : 100
// Get active categories from the board (dynamic based on what backend sends)
const activeCategories = useMemo(() => {
if (!room.board) return []
return Object.keys(room.board).map(id => ({
id: parseInt(id),
...allCategories[parseInt(id)] || { name: `Cat ${id}`, icon: '❓', color: '#666' }
}))
}, [room.board])
const numCategories = activeCategories.length || 5
return (
<div className="min-h-screen p-2 md:p-4 overflow-hidden" style={styles.bgPrimary}>
<div className="max-w-7xl mx-auto">
{/* Header with Room Code */}
<div className="text-center mb-4">
<motion.h1
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="text-2xl md:text-3xl font-bold tracking-wider"
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
>
TRIVY
</motion.h1>
<div className="text-xs opacity-60" style={{ color: config.colors.textMuted }}>
Sala: {room.code}
</div>
</div>
{/* Scoreboard */}
<div className="flex justify-between items-stretch gap-4 mb-4">
{/* Team A Score */}
<motion.div
initial={{ x: -50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
className={`flex-1 text-center p-3 md:p-4 rounded-xl relative overflow-hidden ${
room.current_team === 'A' ? 'ring-2 ring-offset-2' : ''
}`}
style={{
background: `linear-gradient(135deg, ${config.colors.primary}30 0%, ${config.colors.primary}10 100%)`,
border: `3px solid ${config.colors.primary}`,
boxShadow: room.current_team === 'A' ? `0 0 20px ${config.colors.primary}50` : 'none',
'--tw-ring-color': config.colors.primary,
'--tw-ring-offset-color': config.colors.bg,
} as React.CSSProperties}
>
{room.current_team === 'A' && (
<motion.div
className="absolute inset-0"
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
style={{ background: `radial-gradient(circle, ${config.colors.primary}20 0%, transparent 70%)` }}
/>
)}
<div className="relative z-10">
<div className="text-xs md:text-sm font-medium opacity-80" style={{ color: config.colors.primary }}>
EQUIPO A
</div>
<motion.div
key={room.scores.A}
initial={{ scale: 1.3 }}
animate={{ scale: 1 }}
className="text-3xl md:text-5xl font-black"
style={{ color: config.colors.primary, textShadow: `0 0 10px ${config.colors.primary}50` }}
>
{room.scores.A}
</motion.div>
<div className="text-xs opacity-60 mt-1" style={{ color: config.colors.textMuted }}>
{room.teams.A.map(p => p.name).join(', ')}
</div>
</div>
</motion.div>
{/* Turn Indicator */}
<div className="flex flex-col items-center justify-center px-2">
<motion.div
animate={amICurrentPlayer ? { scale: [1, 1.1, 1] } : {}}
transition={{ duration: 0.5, repeat: amICurrentPlayer ? Infinity : 0 }}
className="text-center"
>
<div className="text-xs opacity-60 mb-1" style={{ color: config.colors.textMuted }}>TURNO</div>
<div
className="text-lg md:text-xl font-bold px-3 py-1 rounded-full"
style={{
backgroundColor: room.current_team === 'A' ? config.colors.primary : config.colors.secondary,
color: config.colors.bg
}}
>
{room.current_team}
</div>
{amICurrentPlayer && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs mt-1 font-bold"
style={{ color: config.colors.accent }}
>
¡TU TURNO!
</motion.div>
)}
</motion.div>
</div>
{/* Team B Score */}
<motion.div
initial={{ x: 50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
className={`flex-1 text-center p-3 md:p-4 rounded-xl relative overflow-hidden ${
room.current_team === 'B' ? 'ring-2 ring-offset-2' : ''
}`}
style={{
background: `linear-gradient(135deg, ${config.colors.secondary}30 0%, ${config.colors.secondary}10 100%)`,
border: `3px solid ${config.colors.secondary}`,
boxShadow: room.current_team === 'B' ? `0 0 20px ${config.colors.secondary}50` : 'none',
'--tw-ring-color': config.colors.secondary,
'--tw-ring-offset-color': config.colors.bg,
} as React.CSSProperties}
>
{room.current_team === 'B' && (
<motion.div
className="absolute inset-0"
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
style={{ background: `radial-gradient(circle, ${config.colors.secondary}20 0%, transparent 70%)` }}
/>
)}
<div className="relative z-10">
<div className="text-xs md:text-sm font-medium opacity-80" style={{ color: config.colors.secondary }}>
EQUIPO B
</div>
<motion.div
key={room.scores.B}
initial={{ scale: 1.3 }}
animate={{ scale: 1 }}
className="text-3xl md:text-5xl font-black"
style={{ color: config.colors.secondary, textShadow: `0 0 10px ${config.colors.secondary}50` }}
>
{room.scores.B}
</motion.div>
<div className="text-xs opacity-60 mt-1" style={{ color: config.colors.textMuted }}>
{room.teams.B.map(p => p.name).join(', ')}
</div>
</div>
</motion.div>
</div>
{/* Game Board - Jeopardy Style */}
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="rounded-xl overflow-hidden mb-16"
style={{
border: `3px solid ${config.colors.primary}40`,
boxShadow: `0 10px 40px ${config.colors.primary}20`
}}
>
{/* Category Headers */}
<div
className="grid"
style={{ gridTemplateColumns: `repeat(${numCategories}, minmax(0, 1fr))` }}
>
{activeCategories.map((cat, index) => (
<motion.div
key={cat.id}
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: index * 0.05 }}
className="text-center p-2 md:p-4 relative overflow-hidden"
style={{
background: `linear-gradient(180deg, ${cat.color} 0%, ${cat.color}CC 100%)`,
borderRight: index < numCategories - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none'
}}
>
<div className="text-2xl md:text-4xl mb-1 drop-shadow-lg">{cat.icon}</div>
<div className="text-xs md:text-sm text-white font-bold uppercase tracking-wide truncate drop-shadow">
{cat.name}
</div>
</motion.div>
))}
</div>
{/* Questions Grid */}
{[1, 2, 3, 4, 5].map((difficulty, rowIndex) => (
<div
key={difficulty}
className="grid"
style={{ gridTemplateColumns: `repeat(${numCategories}, minmax(0, 1fr))` }}
>
{activeCategories.map((cat, colIndex) => {
const questions = room.board[String(cat.id)] || []
const question = questions.find(q => q.difficulty === difficulty)
const isAnswered = question?.answered
const cellId = `${cat.id}-${difficulty}`
const canSelect = !isAnswered && amICurrentPlayer && question
return (
<motion.button
key={cellId}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: (rowIndex * 8 + colIndex) * 0.02 + 0.3 }}
whileHover={canSelect ? {
scale: 1.08,
zIndex: 10,
boxShadow: `0 0 25px ${cat.color}80`
} : {}}
whileTap={canSelect ? { scale: 0.95 } : {}}
onClick={() => question && handleSelectQuestion(question, cat.id)}
onMouseEnter={() => handleCellHover(cellId, !!canSelect)}
onMouseLeave={() => setHoveredCell(null)}
disabled={isAnswered || !amICurrentPlayer}
className={`relative aspect-[4/3] md:aspect-square flex items-center justify-center transition-all duration-200 ${
isAnswered
? 'cursor-default'
: canSelect
? 'cursor-pointer'
: 'cursor-not-allowed'
}`}
style={{
background: isAnswered
? `linear-gradient(135deg, ${config.colors.bg} 0%, ${config.colors.bg}90 100%)`
: `linear-gradient(135deg, ${cat.color}50 0%, ${cat.color}30 100%)`,
borderRight: colIndex < numCategories - 1 ? '1px solid rgba(255,255,255,0.1)' : 'none',
borderBottom: '1px solid rgba(255,255,255,0.1)',
opacity: isAnswered ? 0.3 : (!amICurrentPlayer ? 0.6 : 1),
}}
>
{/* Glow effect on hover */}
{canSelect && hoveredCell === cellId && (
<motion.div
layoutId="cellHighlight"
className="absolute inset-0 pointer-events-none"
style={{
background: `radial-gradient(circle, ${cat.color}40 0%, transparent 70%)`,
}}
/>
)}
{/* Points display */}
<span
className={`text-lg md:text-2xl lg:text-3xl font-black relative z-10 transition-all ${
isAnswered ? 'line-through opacity-40' : ''
}`}
style={{
color: isAnswered ? config.colors.textMuted : '#FFD700',
textShadow: isAnswered ? 'none' : '2px 2px 4px rgba(0,0,0,0.5), 0 0 10px rgba(255,215,0,0.3)'
}}
>
${difficulty * 100}
</span>
{/* Answered checkmark */}
{isAnswered && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute inset-0 flex items-center justify-center"
>
<span className="text-2xl opacity-40"></span>
</motion.div>
)}
</motion.button>
)
})}
</div>
))}
</motion.div>
{/* Question Modal */}
<AnimatePresence>
{currentQuestion && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/90 flex items-center justify-center p-4 z-50"
>
<motion.div
initial={{ scale: 0.8, y: 50, rotateX: -15 }}
animate={{ scale: 1, y: 0, rotateX: 0 }}
exit={{ scale: 0.8, y: 50, opacity: 0 }}
transition={{ type: "spring", damping: 20 }}
className="w-full max-w-2xl p-6 md:p-8 rounded-2xl relative overflow-hidden"
style={{
background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
border: `4px solid ${config.colors.primary}`,
boxShadow: `0 0 60px ${config.colors.primary}40, inset 0 0 60px ${config.colors.primary}10`
}}
>
{/* Timer Bar */}
<div className="absolute top-0 left-0 right-0 h-2 bg-black/30 overflow-hidden">
<motion.div
initial={{ width: '100%' }}
animate={{ width: `${timerProgress}%` }}
transition={{ duration: 0.5 }}
className="h-full"
style={{
backgroundColor: timerProgress > 30 ? config.colors.primary : timerProgress > 15 ? '#FFA500' : '#FF4444',
boxShadow: `0 0 10px ${timerProgress > 30 ? config.colors.primary : '#FF4444'}`
}}
/>
</div>
{/* Points & Timer */}
<div className="flex justify-between items-center mb-6 mt-2">
<motion.div
className="px-4 py-2 rounded-full font-bold"
style={{
backgroundColor: '#FFD700',
color: '#000',
boxShadow: '0 0 15px rgba(255,215,0,0.5)'
}}
>
${currentQuestion.points}
</motion.div>
<motion.div
animate={timeLeft <= 5 ? { scale: [1, 1.1, 1] } : {}}
transition={{ duration: 0.3, repeat: timeLeft <= 5 ? Infinity : 0 }}
className={`text-4xl md:text-5xl font-black ${timeLeft <= 5 ? 'text-red-500' : ''}`}
style={{
color: timeLeft > 5 ? config.colors.primary : undefined,
textShadow: timeLeft <= 5 ? '0 0 20px #FF0000' : `0 0 20px ${config.colors.primary}50`
}}
>
{timeLeft}
</motion.div>
</div>
{/* Question */}
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-xl md:text-2xl mb-8 text-center leading-relaxed"
style={{ color: config.colors.text }}
>
{currentQuestion.question_text}
</motion.p>
{/* Answer Input */}
{amICurrentPlayer && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="space-y-4"
>
<input
type="text"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()}
placeholder="Escribe tu respuesta..."
autoFocus
className="w-full px-6 py-4 rounded-xl bg-black/30 outline-none text-xl text-center transition-all focus:ring-2"
style={{
border: `3px solid ${config.colors.primary}50`,
color: config.colors.text,
'--tw-ring-color': config.colors.primary,
} as React.CSSProperties}
/>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleSubmitAnswer}
disabled={!answer.trim()}
className="w-full py-4 rounded-xl font-bold text-xl transition-all disabled:opacity-30"
style={{
background: `linear-gradient(135deg, ${config.colors.primary} 0%, ${config.colors.accent} 100%)`,
color: config.colors.bg,
boxShadow: answer.trim() ? `0 5px 30px ${config.colors.primary}50` : 'none'
}}
>
RESPONDER
</motion.button>
</motion.div>
)}
{!amICurrentPlayer && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-4"
>
<div className="inline-flex items-center gap-2 px-6 py-3 rounded-full"
style={{ backgroundColor: config.colors.primary + '20' }}>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-4 h-4 border-2 rounded-full"
style={{ borderColor: config.colors.primary, borderTopColor: 'transparent' }}
/>
<span style={{ color: config.colors.textMuted }}>
Esperando respuesta de <strong style={{ color: config.colors.primary }}>{currentPlayer?.name}</strong>
</span>
</div>
</motion.div>
)}
</motion.div>
</motion.div>
)}
</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/90 flex items-center justify-center p-4 z-50"
>
<motion.div
initial={{ scale: 0.5, rotate: -5 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ type: "spring", damping: 15 }}
className="p-8 rounded-2xl text-center max-w-md"
style={{
background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
border: `4px solid ${config.colors.accent}`,
boxShadow: `0 0 60px ${config.colors.accent}60`
}}
>
<motion.div
animate={{ rotate: [0, -10, 10, 0] }}
transition={{ duration: 0.5, repeat: Infinity }}
className="text-6xl mb-4"
>
🎯
</motion.div>
<h3 className="text-3xl font-black mb-4" style={{ color: config.colors.accent }}>
¡OPORTUNIDAD DE ROBO!
</h3>
<p className="mb-2" style={{ color: config.colors.textMuted }}>
El equipo contrario falló
</p>
<p className="mb-6 text-red-400 text-sm">
Si fallas, perderás puntos
</p>
<div className="flex gap-4 justify-center">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleStealDecision(true)}
className="px-8 py-4 rounded-xl font-bold text-lg"
style={{
background: `linear-gradient(135deg, ${config.colors.accent} 0%, #FF6B6B 100%)`,
color: '#FFF',
boxShadow: `0 5px 30px ${config.colors.accent}50`
}}
>
¡ROBAR!
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleStealDecision(false)}
className="px-8 py-4 rounded-xl font-bold text-lg"
style={{
backgroundColor: 'transparent',
color: config.colors.text,
border: `2px solid ${config.colors.text}50`,
}}
>
Pasar
</motion.button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Emoji Reactions Bar */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
<EmojiReactions />
</div>
{/* Sound Control */}
<div className="fixed top-4 right-4 z-30">
<SoundControl compact />
</div>
</div>
<ReactionOverlay />
{isGameActive && (
<TeamChat
roomCode={room.code}
playerName={playerName}
team={myTeam}
sendTeamMessage={handleSendTeamMessage}
teamMessages={teamMessages}
/>
)}
</div>
)
}