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>
This commit is contained in:
2026-01-26 23:44:55 +00:00
parent e5a2b016a0
commit ab201e113a
8 changed files with 631 additions and 264 deletions

View File

@@ -1,14 +1,12 @@
import { useEffect, useRef, useCallback } from 'react'
import { io, Socket } from 'socket.io-client'
import { useEffect, useCallback } from 'react'
import { useGameStore } from '../stores/gameStore'
import { soundPlayer } from './useSound'
import { useThemeStore } from '../stores/themeStore'
import { useSoundStore } from '../stores/soundStore'
import { socketService } from '../services/socket'
import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
import type { Reaction } from '../stores/gameStore'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
// Team message type
export interface TeamMessage {
player_name: string
@@ -18,8 +16,7 @@ export interface TeamMessage {
}
export function useSocket() {
const socketRef = useRef<Socket | null>(null)
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage } =
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setGameResult, addReaction, addTeamMessage } =
useGameStore()
// Initialize sound player with current theme
@@ -27,23 +24,16 @@ export function useSocket() {
soundPlayer.loadTheme(currentTheme)
useEffect(() => {
// Create socket connection
socketRef.current = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
autoConnect: true,
})
// Get singleton socket connection
const socket = socketService.connect()
const socket = socketRef.current
// Connection events
socket.on('connect', () => {
console.log('Connected to server')
})
socket.on('disconnect', () => {
console.log('Disconnected from server')
})
// Only set up listeners once globally
if (socketService.isInitialized) {
return // No cleanup - socket persists
}
socketService.setInitialized()
// Error handler
socket.on('error', (data: { message: string }) => {
console.error('Socket error:', data.message)
})
@@ -55,6 +45,9 @@ export function useSocket() {
socket.on('player_joined', (data: { room: GameRoom }) => {
setRoom(data.room)
// Play sound when a player joins
const volume = useSoundStore.getState().volume
soundPlayer.play('player_join', volume)
})
socket.on('player_left', (data: { room: GameRoom }) => {
@@ -68,11 +61,22 @@ export function useSocket() {
// Game events
socket.on('game_started', (data: { room: GameRoom }) => {
setRoom(data.room)
// Play game start sound
const volume = useSoundStore.getState().volume
soundPlayer.play('game_start', volume)
})
socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => {
setRoom(data.room)
// Fetch full question details
// Find the question in the board and set it as current
const questionId = data.question_id
for (const categoryQuestions of Object.values(data.room.board || {})) {
const question = (categoryQuestions as Array<{ id: number }>).find(q => q.id === questionId)
if (question) {
setCurrentQuestion(question as unknown as import('../types').Question)
break
}
}
})
socket.on('answer_result', (data: AnswerResult) => {
@@ -82,12 +86,17 @@ export function useSocket() {
const volume = useSoundStore.getState().volume
if (data.valid) {
soundPlayer.play('correct', volume)
// Clear current question after correct answer
setCurrentQuestion(null)
} else {
soundPlayer.play('incorrect', volume)
}
if (!data.valid && !data.was_steal && data.room.can_steal) {
setShowStealPrompt(true)
} else if (data.was_steal) {
// Clear question after steal attempt (success or fail)
setCurrentQuestion(null)
}
})
@@ -103,6 +112,7 @@ export function useSocket() {
socket.on('steal_passed', (data: { room: GameRoom }) => {
setRoom(data.room)
setShowStealPrompt(false)
setCurrentQuestion(null)
})
socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => {
@@ -111,6 +121,7 @@ export function useSocket() {
setShowStealPrompt(true)
} else {
setShowStealPrompt(false)
setCurrentQuestion(null)
}
})
@@ -175,18 +186,16 @@ export function useSocket() {
})
})
return () => {
socket.disconnect()
}
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage])
// No cleanup - socket connection persists across components
}, [])
// Socket methods
// Socket methods - use singleton service
const createRoom = useCallback((playerName: string) => {
socketRef.current?.emit('create_room', { player_name: playerName })
socketService.emit('create_room', { player_name: playerName })
}, [])
const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => {
socketRef.current?.emit('join_room', {
socketService.emit('join_room', {
room_code: roomCode,
player_name: playerName,
team,
@@ -194,15 +203,15 @@ export function useSocket() {
}, [])
const changeTeam = useCallback((team: 'A' | 'B') => {
socketRef.current?.emit('change_team', { team })
socketService.emit('change_team', { team })
}, [])
const startGame = useCallback((board: Record<string, unknown>) => {
socketRef.current?.emit('start_game', { board })
socketService.emit('start_game', { board })
}, [])
const selectQuestion = useCallback((questionId: number, categoryId: number) => {
socketRef.current?.emit('select_question', {
socketService.emit('select_question', {
question_id: questionId,
category_id: categoryId,
})
@@ -210,7 +219,7 @@ export function useSocket() {
const submitAnswer = useCallback(
(answer: string, question: Record<string, unknown>, isSteal: boolean = false) => {
socketRef.current?.emit('submit_answer', {
socketService.emit('submit_answer', {
answer,
question,
is_steal: isSteal,
@@ -220,7 +229,7 @@ export function useSocket() {
)
const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => {
socketRef.current?.emit('steal_decision', {
socketService.emit('steal_decision', {
attempt,
question_id: questionId,
answer,
@@ -228,15 +237,15 @@ export function useSocket() {
}, [])
const sendChatMessage = useCallback((message: string) => {
socketRef.current?.emit('chat_message', { message })
socketService.emit('chat_message', { message })
}, [])
const sendEmojiReaction = useCallback((emoji: string) => {
socketRef.current?.emit('emoji_reaction', { emoji })
socketService.emit('emoji_reaction', { emoji })
}, [])
const sendReaction = useCallback((emoji: string, roomCode?: string, playerName?: string) => {
socketRef.current?.emit('send_reaction', {
socketService.emit('send_reaction', {
emoji,
room_code: roomCode,
player_name: playerName,
@@ -245,7 +254,7 @@ export function useSocket() {
const sendTeamMessage = useCallback(
(message: string, roomCode: string, team: 'A' | 'B', playerName: string) => {
socketRef.current?.emit('team_message', {
socketService.emit('team_message', {
room_code: roomCode,
team,
player_name: playerName,
@@ -256,11 +265,11 @@ export function useSocket() {
)
const notifyTimerExpired = useCallback(() => {
socketRef.current?.emit('timer_expired', {})
socketService.emit('timer_expired', {})
}, [])
return {
socket: socketRef.current,
socket: socketService.connect(),
createRoom,
joinRoom,
changeTeam,