diff --git a/backend/app/services/question_service.py b/backend/app/services/question_service.py index ddab07d..ccef0d1 100644 --- a/backend/app/services/question_service.py +++ b/backend/app/services/question_service.py @@ -1,11 +1,15 @@ from typing import Optional, List, Dict from datetime import date -from sqlalchemy import select, and_ +import random +from sqlalchemy import select, and_, func from sqlalchemy.ext.asyncio import AsyncSession from app.models.question import Question from app.models.category import Category +# Number of categories per game +CATEGORIES_PER_GAME = 5 + class QuestionService: async def get_daily_questions( @@ -64,16 +68,49 @@ class QuestionService: target_date: Optional[date] = None ) -> Dict[str, List[dict]]: """ - Genera el tablero 8×5 para el juego. - Si no hay suficientes preguntas, retorna lo disponible. + Genera el tablero 5×5 para el juego. + Selecciona 5 categorías aleatorias y 1 pregunta por dificultad. Returns: Dict con category_id como string (para JSON) -> lista de preguntas """ - board = await self.get_daily_questions(db, target_date) + full_board = await self.get_daily_questions(db, target_date) - # Convertir keys a string para JSON - return {str(k): v for k, v in board.items()} + if not full_board: + return {} + + # Get available category IDs that have questions + available_categories = list(full_board.keys()) + + # Select random categories (up to CATEGORIES_PER_GAME) + num_categories = min(CATEGORIES_PER_GAME, len(available_categories)) + selected_categories = random.sample(available_categories, num_categories) + + # Build the game board with selected categories + game_board: Dict[str, List[dict]] = {} + + for cat_id in selected_categories: + questions_by_difficulty: Dict[int, List[dict]] = {} + + # Group questions by difficulty + for q in full_board[cat_id]: + diff = q["difficulty"] + if diff not in questions_by_difficulty: + questions_by_difficulty[diff] = [] + questions_by_difficulty[diff].append(q) + + # Select one random question per difficulty + selected_questions = [] + for difficulty in range(1, 6): # 1-5 + if difficulty in questions_by_difficulty: + questions = questions_by_difficulty[difficulty] + selected_q = random.choice(questions) + selected_questions.append(selected_q) + + if selected_questions: + game_board[str(cat_id)] = selected_questions + + return game_board async def get_question_by_id( self, diff --git a/backend/app/services/room_manager.py b/backend/app/services/room_manager.py index f68266a..97e6e56 100644 --- a/backend/app/services/room_manager.py +++ b/backend/app/services/room_manager.py @@ -59,9 +59,9 @@ class RoomManager: ) # Add player to room - await self.add_player(room_code, player_name, "A", socket_id) + room = await self.add_player(room_code, player_name, "A", socket_id) - return room_state + return room async def get_room(self, room_code: str) -> Optional[dict]: """Get room state by code.""" diff --git a/backend/app/sockets/game_events.py b/backend/app/sockets/game_events.py index db512d1..e8e6f41 100644 --- a/backend/app/sockets/game_events.py +++ b/backend/app/sockets/game_events.py @@ -50,7 +50,7 @@ def register_socket_events(sio: socketio.AsyncServer): await room_manager.init_player_stats(room["code"], player_name) # Join socket room - sio.enter_room(sid, room["code"]) + await sio.enter_room(sid, room["code"]) await sio.emit("room_created", {"room": room}, to=sid) @@ -75,7 +75,7 @@ def register_socket_events(sio: socketio.AsyncServer): await room_manager.init_player_stats(room_code, player_name) # Join socket room - sio.enter_room(sid, room_code) + await sio.enter_room(sid, room_code) # Notify all players await sio.emit("player_joined", {"room": room}, room=room_code) @@ -147,13 +147,18 @@ def register_socket_events(sio: socketio.AsyncServer): ) return - # Get board from data or generate - board = data.get("board", {}) - - updated_room = await game_manager.start_game(room_code, board) + # Load board from database and start game + async with await get_db_session() as db: + updated_room = await game_manager.start_game_with_db(db, room_code) if updated_room: await sio.emit("game_started", {"room": updated_room}, room=room_code) + else: + await sio.emit( + "error", + {"message": "No hay preguntas disponibles para hoy. Contacta al administrador."}, + to=sid + ) @sio.event async def select_question(sid, data): diff --git a/frontend/src/components/ui/SoundControl.tsx b/frontend/src/components/ui/SoundControl.tsx index 6a59195..df05905 100644 --- a/frontend/src/components/ui/SoundControl.tsx +++ b/frontend/src/components/ui/SoundControl.tsx @@ -7,15 +7,12 @@ import { useThemeStyles } from '../../themes/ThemeProvider' interface SoundControlProps { /** Compact mode shows just the icon, expanded shows slider */ compact?: boolean - /** Position for the popup menu when in compact mode */ - popupPosition?: 'top' | 'bottom' | 'left' | 'right' /** Custom class name */ className?: string } export default function SoundControl({ compact = false, - popupPosition = 'top', className = '', }: SoundControlProps) { const { volume, muted, setVolume, toggleMute } = useSoundStore() @@ -84,19 +81,6 @@ export default function SoundControl({ ) } - const getPopupStyles = () => { - switch (popupPosition) { - case 'top': - return 'bottom-full mb-2 left-1/2 -translate-x-1/2' - case 'bottom': - return 'top-full mt-2 left-1/2 -translate-x-1/2' - case 'left': - return 'right-full mr-2 top-1/2 -translate-y-1/2' - case 'right': - return 'left-full ml-2 top-1/2 -translate-y-1/2' - } - } - if (!compact) { return (
-
+
- handleVolumeChange(parseFloat(e.target.value))} - className="w-full h-2 rounded-lg appearance-none cursor-pointer" - style={{ - background: `linear-gradient(to right, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`, - }} - /> + {/* Vertical slider container */} +
+ handleVolumeChange(parseFloat(e.target.value))} + className="h-20 w-2 rounded-lg appearance-none cursor-pointer" + style={{ + writingMode: 'vertical-lr', + direction: 'rtl', + background: `linear-gradient(to top, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`, + }} + /> +
(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) => { - 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, 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, diff --git a/frontend/src/pages/Game.tsx b/frontend/src/pages/Game.tsx index f468527..fd7ddfb 100644 --- a/frontend/src/pages/Game.tsx +++ b/frontend/src/pages/Game.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +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' @@ -11,16 +11,17 @@ import TeamChat from '../components/chat/TeamChat' import SoundControl from '../components/ui/SoundControl' 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' }, -] +// All available categories with their styling +const allCategories: Record = { + 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 }>() @@ -32,7 +33,7 @@ export default function Game() { const [answer, setAnswer] = useState('') const [timeLeft, setTimeLeft] = useState(0) - const [showingQuestion, setShowingQuestion] = useState(false) + const [hoveredCell, setHoveredCell] = useState(null) // Redirect if game finished useEffect(() => { @@ -41,9 +42,16 @@ export default function Game() { } }, [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 || !showingQuestion) return + if (!currentQuestion) return setTimeLeft(currentQuestion.time_seconds) const interval = setInterval(() => { @@ -52,11 +60,11 @@ export default function Game() { clearInterval(interval) return 0 } - // Play urgent sound when time is running low (5 seconds or less) - if (prev <= 6 && prev > 1) { + if (prev <= 4 && prev > 1) { + play('countdown') + } else if (prev <= 6 && prev > 4) { play('timer_urgent') } else if (prev > 6) { - // Play tick sound for normal countdown play('timer_tick') } return prev - 1 @@ -64,12 +72,25 @@ export default function Game() { }, 1000) return () => clearInterval(interval) - }, [currentQuestion, showingQuestion, play]) + }, [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 (
-

Cargando...

+
) } @@ -85,164 +106,379 @@ export default function Game() { if (!amICurrentPlayer || question.answered) return play('select') selectQuestion(question.id, categoryId) - setShowingQuestion(true) } const handleSubmitAnswer = () => { if (!currentQuestion || !answer.trim()) return submitAnswer(answer, currentQuestion as unknown as Record, room.can_steal) setAnswer('') - setShowingQuestion(false) } const handleStealDecision = (attempt: boolean) => { if (!currentQuestion) return - if (attempt) { - setShowingQuestion(true) - } else { + if (!attempt) { stealDecision(false, currentQuestion.id) } setShowStealPrompt(false) } - // Handler for sending team messages const handleSendTeamMessage = (message: string) => { if (room && playerName && myTeam) { sendTeamMessage(message, room.code, myTeam, playerName) } } - // Determine if the game is active (playing status) 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 ( -
-
- {/* Scoreboard */} -
-
+
+ {/* Header with Room Code */} +
+ -
Equipo A
-
- {room.scores.A} -
-
- -
-
- Turno de {room.current_team === 'A' ? 'Equipo A' : 'Equipo B'} -
- {amICurrentPlayer && ( -
- ¡Tu turno! -
- )} -
- -
-
Equipo B
-
- {room.scores.B} -
+ TRIVY + +
+ Sala: {room.code}
- {/* Game Board */} -
- {/* Category Headers */} - {categories.map((cat) => ( -
-
{cat.icon}
-
{cat.name}
+ {/* Scoreboard */} +
+ {/* Team A Score */} + + {room.current_team === 'A' && ( + + )} +
+
+ EQUIPO A +
+ + {room.scores.A} + +
+ {room.teams.A.map(p => p.name).join(', ')} +
- ))} +
+ + {/* Turn Indicator */} +
+ +
TURNO
+
+ {room.current_team} +
+ {amICurrentPlayer && ( + + ¡TU TURNO! + + )} +
+
+ + {/* Team B Score */} + + {room.current_team === 'B' && ( + + )} +
+
+ EQUIPO B +
+ + {room.scores.B} + +
+ {room.teams.B.map(p => p.name).join(', ')} +
+
+
+
+ + {/* Game Board - Jeopardy Style */} + + {/* Category Headers */} +
+ {activeCategories.map((cat, index) => ( + +
{cat.icon}
+
+ {cat.name} +
+
+ ))} +
{/* 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 + {[1, 2, 3, 4, 5].map((difficulty, rowIndex) => ( +
+ {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 ( - 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}`, - }} - > - - {difficulty * 100} - - - ) - }) - )} -
+ return ( + 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 && ( + + )} + + {/* Points display */} + + ${difficulty * 100} + + + {/* Answered checkmark */} + {isAnswered && ( + + + + )} + + ) + })} +
+ ))} + {/* Question Modal */} - {showingQuestion && currentQuestion && ( + {currentQuestion && ( - {/* Timer */} -
- - {currentQuestion.points} puntos - -
5 ? config.colors.primary : undefined }} + {/* Timer Bar */} +
+ 30 ? config.colors.primary : timerProgress > 15 ? '#FFA500' : '#FF4444', + boxShadow: `0 0 10px ${timerProgress > 30 ? config.colors.primary : '#FF4444'}` + }} + /> +
+ + {/* Points & Timer */} +
+ - {timeLeft}s -
+ ${currentQuestion.points} + + 5 ? config.colors.primary : undefined, + textShadow: timeLeft <= 5 ? '0 0 20px #FF0000' : `0 0 20px ${config.colors.primary}50` + }} + > + {timeLeft} +
{/* Question */} -

- {currentQuestion.question_text || 'Pregunta de ejemplo: ¿En qué año se lanzó la NES?'} -

+ + {currentQuestion.question_text} + {/* Answer Input */} {amICurrentPlayer && ( -
+ e.key === 'Enter' && handleSubmitAnswer()} placeholder="Escribe tu respuesta..." autoFocus - className="w-full px-4 py-3 rounded-lg bg-transparent outline-none text-lg" + 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: `2px solid ${config.colors.primary}`, + border: `3px solid ${config.colors.primary}50`, color: config.colors.text, - }} + '--tw-ring-color': config.colors.primary, + } as React.CSSProperties} /> - -
+ RESPONDER + + )} {!amICurrentPlayer && ( -

- Esperando respuesta de {currentPlayer?.name}... -

+ +
+ + + Esperando respuesta de {currentPlayer?.name} + +
+
)} @@ -287,68 +542,82 @@ export default function Game() { 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" + className="fixed inset-0 bg-black/90 flex items-center justify-center p-4 z-50" > -

- ¡Oportunidad de Robo! + + 🎯 + +

+ ¡OPORTUNIDAD DE ROBO!

-

- El equipo contrario falló. ¿Quieres intentar robar los puntos? -
- Advertencia: Si fallas, perderás puntos +

+ El equipo contrario falló +

+

+ ⚠️ Si fallas, perderás puntos

- - +
)} - {/* Emoji Reactions Bar - Fixed at bottom */} + {/* Emoji Reactions Bar */}
- {/* Sound Control - Fixed at top right */} + {/* Sound Control */}
- +
- {/* Reaction Overlay - Full screen overlay for floating reactions */} - {/* Team Chat - Only visible during the game */} {isGameActive && ( void>> = new Map() + private initialized = false connect(): Socket { if (!this.socket) { + console.log('Creating new socket connection to:', SOCKET_URL) this.socket = io(SOCKET_URL, { transports: ['websocket', 'polling'], autoConnect: true, reconnection: true, - reconnectionAttempts: 5, + reconnectionAttempts: 10, reconnectionDelay: 1000, }) @@ -24,9 +25,12 @@ class SocketService { console.log('Socket disconnected:', reason) }) - this.socket.on('error', (error) => { - console.error('Socket error:', error) + this.socket.on('connect_error', (error) => { + console.error('Socket connection error:', error) }) + } else if (!this.socket.connected) { + console.log('Reconnecting socket...') + this.socket.connect() } return this.socket @@ -36,25 +40,35 @@ class SocketService { if (this.socket) { this.socket.disconnect() this.socket = null + this.initialized = false } } - 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) + on(event: string, callback: (...args: unknown[]) => void): void { + const socket = this.connect() + // Remove existing listener to prevent duplicates + socket.off(event, callback) + socket.on(event, callback) } - off(event: string, callback: (data: unknown) => void): void { - this.listeners.get(event)?.delete(callback) - this.socket?.off(event, callback) + off(event: string, callback?: (...args: unknown[]) => void): void { + if (callback) { + this.socket?.off(event, callback) + } else { + this.socket?.off(event) + } } emit(event: string, data?: unknown): void { - this.socket?.emit(event, data) + const socket = this.connect() + if (socket.connected) { + socket.emit(event, data) + } else { + // Wait for connection and then emit + socket.once('connect', () => { + socket.emit(event, data) + }) + } } get connected(): boolean { @@ -64,6 +78,15 @@ class SocketService { get id(): string | undefined { return this.socket?.id } + + // Check if listeners are initialized + get isInitialized(): boolean { + return this.initialized + } + + setInitialized(): void { + this.initialized = true + } } export const socketService = new SocketService() diff --git a/frontend/src/stores/soundStore.ts b/frontend/src/stores/soundStore.ts index 66687b6..4c10cf8 100644 --- a/frontend/src/stores/soundStore.ts +++ b/frontend/src/stores/soundStore.ts @@ -11,6 +11,11 @@ export type SoundEffect = | 'victory' | 'defeat' | 'select' + | 'game_start' + | 'player_join' + | 'question_reveal' + | 'hover' + | 'countdown' interface SoundState { volume: number @@ -56,6 +61,11 @@ export const soundPaths: Record> = { victory: '/sounds/drrr/victory.mp3', defeat: '/sounds/drrr/defeat.mp3', select: '/sounds/drrr/select.mp3', + game_start: '/sounds/drrr/game_start.mp3', + player_join: '/sounds/drrr/player_join.mp3', + question_reveal: '/sounds/drrr/question_reveal.mp3', + hover: '/sounds/drrr/hover.mp3', + countdown: '/sounds/drrr/countdown.mp3', }, retro: { correct: '/sounds/retro/correct.mp3', @@ -66,6 +76,11 @@ export const soundPaths: Record> = { victory: '/sounds/retro/victory.mp3', defeat: '/sounds/retro/defeat.mp3', select: '/sounds/retro/select.mp3', + game_start: '/sounds/retro/game_start.mp3', + player_join: '/sounds/retro/player_join.mp3', + question_reveal: '/sounds/retro/question_reveal.mp3', + hover: '/sounds/retro/hover.mp3', + countdown: '/sounds/retro/countdown.mp3', }, minimal: { correct: '/sounds/minimal/correct.mp3', @@ -76,6 +91,11 @@ export const soundPaths: Record> = { victory: '/sounds/minimal/victory.mp3', defeat: '/sounds/minimal/defeat.mp3', select: '/sounds/minimal/select.mp3', + game_start: '/sounds/minimal/game_start.mp3', + player_join: '/sounds/minimal/player_join.mp3', + question_reveal: '/sounds/minimal/question_reveal.mp3', + hover: '/sounds/minimal/hover.mp3', + countdown: '/sounds/minimal/countdown.mp3', }, rgb: { correct: '/sounds/rgb/correct.mp3', @@ -86,6 +106,11 @@ export const soundPaths: Record> = { victory: '/sounds/rgb/victory.mp3', defeat: '/sounds/rgb/defeat.mp3', select: '/sounds/rgb/select.mp3', + game_start: '/sounds/rgb/game_start.mp3', + player_join: '/sounds/rgb/player_join.mp3', + question_reveal: '/sounds/rgb/question_reveal.mp3', + hover: '/sounds/rgb/hover.mp3', + countdown: '/sounds/rgb/countdown.mp3', }, anime: { correct: '/sounds/anime/correct.mp3', @@ -96,6 +121,11 @@ export const soundPaths: Record> = { victory: '/sounds/anime/victory.mp3', defeat: '/sounds/anime/defeat.mp3', select: '/sounds/anime/select.mp3', + game_start: '/sounds/anime/game_start.mp3', + player_join: '/sounds/anime/player_join.mp3', + question_reveal: '/sounds/anime/question_reveal.mp3', + hover: '/sounds/anime/hover.mp3', + countdown: '/sounds/anime/countdown.mp3', }, } @@ -110,4 +140,9 @@ export const fallbackSoundConfigs: Record