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:
@@ -1,11 +1,15 @@
|
|||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from sqlalchemy import select, and_
|
import random
|
||||||
|
from sqlalchemy import select, and_, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.question import Question
|
from app.models.question import Question
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
|
|
||||||
|
# Number of categories per game
|
||||||
|
CATEGORIES_PER_GAME = 5
|
||||||
|
|
||||||
|
|
||||||
class QuestionService:
|
class QuestionService:
|
||||||
async def get_daily_questions(
|
async def get_daily_questions(
|
||||||
@@ -64,16 +68,49 @@ class QuestionService:
|
|||||||
target_date: Optional[date] = None
|
target_date: Optional[date] = None
|
||||||
) -> Dict[str, List[dict]]:
|
) -> Dict[str, List[dict]]:
|
||||||
"""
|
"""
|
||||||
Genera el tablero 8×5 para el juego.
|
Genera el tablero 5×5 para el juego.
|
||||||
Si no hay suficientes preguntas, retorna lo disponible.
|
Selecciona 5 categorías aleatorias y 1 pregunta por dificultad.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict con category_id como string (para JSON) -> lista de preguntas
|
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
|
if not full_board:
|
||||||
return {str(k): v for k, v in board.items()}
|
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(
|
async def get_question_by_id(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ class RoomManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add player to room
|
# 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]:
|
async def get_room(self, room_code: str) -> Optional[dict]:
|
||||||
"""Get room state by code."""
|
"""Get room state by code."""
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
await room_manager.init_player_stats(room["code"], player_name)
|
await room_manager.init_player_stats(room["code"], player_name)
|
||||||
|
|
||||||
# Join socket room
|
# 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)
|
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)
|
await room_manager.init_player_stats(room_code, player_name)
|
||||||
|
|
||||||
# Join socket room
|
# Join socket room
|
||||||
sio.enter_room(sid, room_code)
|
await sio.enter_room(sid, room_code)
|
||||||
|
|
||||||
# Notify all players
|
# Notify all players
|
||||||
await sio.emit("player_joined", {"room": room}, room=room_code)
|
await sio.emit("player_joined", {"room": room}, room=room_code)
|
||||||
@@ -147,13 +147,18 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get board from data or generate
|
# Load board from database and start game
|
||||||
board = data.get("board", {})
|
async with await get_db_session() as db:
|
||||||
|
updated_room = await game_manager.start_game_with_db(db, room_code)
|
||||||
updated_room = await game_manager.start_game(room_code, board)
|
|
||||||
|
|
||||||
if updated_room:
|
if updated_room:
|
||||||
await sio.emit("game_started", {"room": updated_room}, room=room_code)
|
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
|
@sio.event
|
||||||
async def select_question(sid, data):
|
async def select_question(sid, data):
|
||||||
|
|||||||
@@ -7,15 +7,12 @@ import { useThemeStyles } from '../../themes/ThemeProvider'
|
|||||||
interface SoundControlProps {
|
interface SoundControlProps {
|
||||||
/** Compact mode shows just the icon, expanded shows slider */
|
/** Compact mode shows just the icon, expanded shows slider */
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
/** Position for the popup menu when in compact mode */
|
|
||||||
popupPosition?: 'top' | 'bottom' | 'left' | 'right'
|
|
||||||
/** Custom class name */
|
/** Custom class name */
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SoundControl({
|
export default function SoundControl({
|
||||||
compact = false,
|
compact = false,
|
||||||
popupPosition = 'top',
|
|
||||||
className = '',
|
className = '',
|
||||||
}: SoundControlProps) {
|
}: SoundControlProps) {
|
||||||
const { volume, muted, setVolume, toggleMute } = useSoundStore()
|
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) {
|
if (!compact) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -158,32 +142,37 @@ export default function SoundControl({
|
|||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
className={`absolute ${getPopupStyles()} p-3 rounded-lg shadow-lg z-50`}
|
className="absolute top-full mt-2 right-0 p-3 rounded-lg shadow-lg z-50"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: config.colors.bg,
|
backgroundColor: config.colors.bg,
|
||||||
border: `1px solid ${config.colors.primary}`,
|
border: `1px solid ${config.colors.primary}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-2 min-w-[120px]">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleMute}
|
onClick={handleToggleMute}
|
||||||
className="p-2 rounded-lg transition-colors hover:opacity-80"
|
className="p-1 rounded-lg transition-colors hover:opacity-80"
|
||||||
style={{ color: config.colors.primary }}
|
style={{ color: config.colors.primary }}
|
||||||
>
|
>
|
||||||
{getVolumeIcon()}
|
{getVolumeIcon()}
|
||||||
</button>
|
</button>
|
||||||
<input
|
{/* Vertical slider container */}
|
||||||
type="range"
|
<div className="relative h-24 w-6 flex items-center justify-center">
|
||||||
min="0"
|
<input
|
||||||
max="1"
|
type="range"
|
||||||
step="0.05"
|
min="0"
|
||||||
value={muted ? 0 : volume}
|
max="1"
|
||||||
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
|
step="0.05"
|
||||||
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
value={muted ? 0 : volume}
|
||||||
style={{
|
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
|
||||||
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%)`,
|
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%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
style={{ color: config.colors.textMuted }}
|
style={{ color: config.colors.textMuted }}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import { io, Socket } from 'socket.io-client'
|
|
||||||
import { useGameStore } from '../stores/gameStore'
|
import { useGameStore } from '../stores/gameStore'
|
||||||
import { soundPlayer } from './useSound'
|
import { soundPlayer } from './useSound'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import { useSoundStore } from '../stores/soundStore'
|
import { useSoundStore } from '../stores/soundStore'
|
||||||
|
import { socketService } from '../services/socket'
|
||||||
import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
|
import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
|
||||||
import type { Reaction } from '../stores/gameStore'
|
import type { Reaction } from '../stores/gameStore'
|
||||||
|
|
||||||
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
|
|
||||||
|
|
||||||
// Team message type
|
// Team message type
|
||||||
export interface TeamMessage {
|
export interface TeamMessage {
|
||||||
player_name: string
|
player_name: string
|
||||||
@@ -18,8 +16,7 @@ export interface TeamMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSocket() {
|
export function useSocket() {
|
||||||
const socketRef = useRef<Socket | null>(null)
|
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setGameResult, addReaction, addTeamMessage } =
|
||||||
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage } =
|
|
||||||
useGameStore()
|
useGameStore()
|
||||||
|
|
||||||
// Initialize sound player with current theme
|
// Initialize sound player with current theme
|
||||||
@@ -27,23 +24,16 @@ export function useSocket() {
|
|||||||
soundPlayer.loadTheme(currentTheme)
|
soundPlayer.loadTheme(currentTheme)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Create socket connection
|
// Get singleton socket connection
|
||||||
socketRef.current = io(SOCKET_URL, {
|
const socket = socketService.connect()
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
autoConnect: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const socket = socketRef.current
|
// Only set up listeners once globally
|
||||||
|
if (socketService.isInitialized) {
|
||||||
// Connection events
|
return // No cleanup - socket persists
|
||||||
socket.on('connect', () => {
|
}
|
||||||
console.log('Connected to server')
|
socketService.setInitialized()
|
||||||
})
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
console.log('Disconnected from server')
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Error handler
|
||||||
socket.on('error', (data: { message: string }) => {
|
socket.on('error', (data: { message: string }) => {
|
||||||
console.error('Socket error:', data.message)
|
console.error('Socket error:', data.message)
|
||||||
})
|
})
|
||||||
@@ -55,6 +45,9 @@ export function useSocket() {
|
|||||||
|
|
||||||
socket.on('player_joined', (data: { room: GameRoom }) => {
|
socket.on('player_joined', (data: { room: GameRoom }) => {
|
||||||
setRoom(data.room)
|
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 }) => {
|
socket.on('player_left', (data: { room: GameRoom }) => {
|
||||||
@@ -68,11 +61,22 @@ export function useSocket() {
|
|||||||
// Game events
|
// Game events
|
||||||
socket.on('game_started', (data: { room: GameRoom }) => {
|
socket.on('game_started', (data: { room: GameRoom }) => {
|
||||||
setRoom(data.room)
|
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 }) => {
|
socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => {
|
||||||
setRoom(data.room)
|
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) => {
|
socket.on('answer_result', (data: AnswerResult) => {
|
||||||
@@ -82,12 +86,17 @@ export function useSocket() {
|
|||||||
const volume = useSoundStore.getState().volume
|
const volume = useSoundStore.getState().volume
|
||||||
if (data.valid) {
|
if (data.valid) {
|
||||||
soundPlayer.play('correct', volume)
|
soundPlayer.play('correct', volume)
|
||||||
|
// Clear current question after correct answer
|
||||||
|
setCurrentQuestion(null)
|
||||||
} else {
|
} else {
|
||||||
soundPlayer.play('incorrect', volume)
|
soundPlayer.play('incorrect', volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.valid && !data.was_steal && data.room.can_steal) {
|
if (!data.valid && !data.was_steal && data.room.can_steal) {
|
||||||
setShowStealPrompt(true)
|
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 }) => {
|
socket.on('steal_passed', (data: { room: GameRoom }) => {
|
||||||
setRoom(data.room)
|
setRoom(data.room)
|
||||||
setShowStealPrompt(false)
|
setShowStealPrompt(false)
|
||||||
|
setCurrentQuestion(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => {
|
socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => {
|
||||||
@@ -111,6 +121,7 @@ export function useSocket() {
|
|||||||
setShowStealPrompt(true)
|
setShowStealPrompt(true)
|
||||||
} else {
|
} else {
|
||||||
setShowStealPrompt(false)
|
setShowStealPrompt(false)
|
||||||
|
setCurrentQuestion(null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -175,18 +186,16 @@ export function useSocket() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
// No cleanup - socket connection persists across components
|
||||||
socket.disconnect()
|
}, [])
|
||||||
}
|
|
||||||
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage])
|
|
||||||
|
|
||||||
// Socket methods
|
// Socket methods - use singleton service
|
||||||
const createRoom = useCallback((playerName: string) => {
|
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') => {
|
const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => {
|
||||||
socketRef.current?.emit('join_room', {
|
socketService.emit('join_room', {
|
||||||
room_code: roomCode,
|
room_code: roomCode,
|
||||||
player_name: playerName,
|
player_name: playerName,
|
||||||
team,
|
team,
|
||||||
@@ -194,15 +203,15 @@ export function useSocket() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const changeTeam = useCallback((team: 'A' | 'B') => {
|
const changeTeam = useCallback((team: 'A' | 'B') => {
|
||||||
socketRef.current?.emit('change_team', { team })
|
socketService.emit('change_team', { team })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const startGame = useCallback((board: Record<string, unknown>) => {
|
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) => {
|
const selectQuestion = useCallback((questionId: number, categoryId: number) => {
|
||||||
socketRef.current?.emit('select_question', {
|
socketService.emit('select_question', {
|
||||||
question_id: questionId,
|
question_id: questionId,
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
})
|
})
|
||||||
@@ -210,7 +219,7 @@ export function useSocket() {
|
|||||||
|
|
||||||
const submitAnswer = useCallback(
|
const submitAnswer = useCallback(
|
||||||
(answer: string, question: Record<string, unknown>, isSteal: boolean = false) => {
|
(answer: string, question: Record<string, unknown>, isSteal: boolean = false) => {
|
||||||
socketRef.current?.emit('submit_answer', {
|
socketService.emit('submit_answer', {
|
||||||
answer,
|
answer,
|
||||||
question,
|
question,
|
||||||
is_steal: isSteal,
|
is_steal: isSteal,
|
||||||
@@ -220,7 +229,7 @@ export function useSocket() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => {
|
const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => {
|
||||||
socketRef.current?.emit('steal_decision', {
|
socketService.emit('steal_decision', {
|
||||||
attempt,
|
attempt,
|
||||||
question_id: questionId,
|
question_id: questionId,
|
||||||
answer,
|
answer,
|
||||||
@@ -228,15 +237,15 @@ export function useSocket() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const sendChatMessage = useCallback((message: string) => {
|
const sendChatMessage = useCallback((message: string) => {
|
||||||
socketRef.current?.emit('chat_message', { message })
|
socketService.emit('chat_message', { message })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const sendEmojiReaction = useCallback((emoji: string) => {
|
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) => {
|
const sendReaction = useCallback((emoji: string, roomCode?: string, playerName?: string) => {
|
||||||
socketRef.current?.emit('send_reaction', {
|
socketService.emit('send_reaction', {
|
||||||
emoji,
|
emoji,
|
||||||
room_code: roomCode,
|
room_code: roomCode,
|
||||||
player_name: playerName,
|
player_name: playerName,
|
||||||
@@ -245,7 +254,7 @@ export function useSocket() {
|
|||||||
|
|
||||||
const sendTeamMessage = useCallback(
|
const sendTeamMessage = useCallback(
|
||||||
(message: string, roomCode: string, team: 'A' | 'B', playerName: string) => {
|
(message: string, roomCode: string, team: 'A' | 'B', playerName: string) => {
|
||||||
socketRef.current?.emit('team_message', {
|
socketService.emit('team_message', {
|
||||||
room_code: roomCode,
|
room_code: roomCode,
|
||||||
team,
|
team,
|
||||||
player_name: playerName,
|
player_name: playerName,
|
||||||
@@ -256,11 +265,11 @@ export function useSocket() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const notifyTimerExpired = useCallback(() => {
|
const notifyTimerExpired = useCallback(() => {
|
||||||
socketRef.current?.emit('timer_expired', {})
|
socketService.emit('timer_expired', {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
socket: socketRef.current,
|
socket: socketService.connect(),
|
||||||
createRoom,
|
createRoom,
|
||||||
joinRoom,
|
joinRoom,
|
||||||
changeTeam,
|
changeTeam,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useSocket } from '../hooks/useSocket'
|
import { useSocket } from '../hooks/useSocket'
|
||||||
@@ -11,16 +11,17 @@ import TeamChat from '../components/chat/TeamChat'
|
|||||||
import SoundControl from '../components/ui/SoundControl'
|
import SoundControl from '../components/ui/SoundControl'
|
||||||
import type { Question } from '../types'
|
import type { Question } from '../types'
|
||||||
|
|
||||||
const categories = [
|
// All available categories with their styling
|
||||||
{ id: 1, name: 'Nintendo', icon: '🍄', color: '#E60012' },
|
const allCategories: Record<number, { name: string; icon: string; color: string }> = {
|
||||||
{ id: 2, name: 'Xbox', icon: '🎮', color: '#107C10' },
|
1: { name: 'Nintendo', icon: '🍄', color: '#E60012' },
|
||||||
{ id: 3, name: 'PlayStation', icon: '🎯', color: '#003791' },
|
2: { name: 'Xbox', icon: '🎮', color: '#107C10' },
|
||||||
{ id: 4, name: 'Anime', icon: '⛩️', color: '#FF6B9D' },
|
3: { name: 'PlayStation', icon: '🎯', color: '#003791' },
|
||||||
{ id: 5, name: 'Música', icon: '🎵', color: '#1DB954' },
|
4: { name: 'Anime', icon: '⛩️', color: '#FF6B9D' },
|
||||||
{ id: 6, name: 'Películas', icon: '🎬', color: '#F5C518' },
|
5: { name: 'Música', icon: '🎵', color: '#1DB954' },
|
||||||
{ id: 7, name: 'Libros', icon: '📚', color: '#8B4513' },
|
6: { name: 'Películas', icon: '🎬', color: '#F5C518' },
|
||||||
{ id: 8, name: 'Historia-Cultura', icon: '🏛️', color: '#6B5B95' },
|
7: { name: 'Libros', icon: '📚', color: '#8B4513' },
|
||||||
]
|
8: { name: 'Historia', icon: '🏛️', color: '#6B5B95' },
|
||||||
|
}
|
||||||
|
|
||||||
export default function Game() {
|
export default function Game() {
|
||||||
useParams<{ roomCode: string }>()
|
useParams<{ roomCode: string }>()
|
||||||
@@ -32,7 +33,7 @@ export default function Game() {
|
|||||||
|
|
||||||
const [answer, setAnswer] = useState('')
|
const [answer, setAnswer] = useState('')
|
||||||
const [timeLeft, setTimeLeft] = useState(0)
|
const [timeLeft, setTimeLeft] = useState(0)
|
||||||
const [showingQuestion, setShowingQuestion] = useState(false)
|
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
|
||||||
|
|
||||||
// Redirect if game finished
|
// Redirect if game finished
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,9 +42,16 @@ export default function Game() {
|
|||||||
}
|
}
|
||||||
}, [room?.status, room?.code, navigate])
|
}, [room?.status, room?.code, navigate])
|
||||||
|
|
||||||
|
// Play sound when question is revealed
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentQuestion) {
|
||||||
|
play('question_reveal')
|
||||||
|
}
|
||||||
|
}, [currentQuestion, play])
|
||||||
|
|
||||||
// Timer logic with sound effects
|
// Timer logic with sound effects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentQuestion || !showingQuestion) return
|
if (!currentQuestion) return
|
||||||
|
|
||||||
setTimeLeft(currentQuestion.time_seconds)
|
setTimeLeft(currentQuestion.time_seconds)
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -52,11 +60,11 @@ export default function Game() {
|
|||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
// Play urgent sound when time is running low (5 seconds or less)
|
if (prev <= 4 && prev > 1) {
|
||||||
if (prev <= 6 && prev > 1) {
|
play('countdown')
|
||||||
|
} else if (prev <= 6 && prev > 4) {
|
||||||
play('timer_urgent')
|
play('timer_urgent')
|
||||||
} else if (prev > 6) {
|
} else if (prev > 6) {
|
||||||
// Play tick sound for normal countdown
|
|
||||||
play('timer_tick')
|
play('timer_tick')
|
||||||
}
|
}
|
||||||
return prev - 1
|
return prev - 1
|
||||||
@@ -64,12 +72,25 @@ export default function Game() {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
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) {
|
if (!room) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
|
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
|
||||||
<p style={styles.textSecondary}>Cargando...</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -85,164 +106,379 @@ export default function Game() {
|
|||||||
if (!amICurrentPlayer || question.answered) return
|
if (!amICurrentPlayer || question.answered) return
|
||||||
play('select')
|
play('select')
|
||||||
selectQuestion(question.id, categoryId)
|
selectQuestion(question.id, categoryId)
|
||||||
setShowingQuestion(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmitAnswer = () => {
|
const handleSubmitAnswer = () => {
|
||||||
if (!currentQuestion || !answer.trim()) return
|
if (!currentQuestion || !answer.trim()) return
|
||||||
submitAnswer(answer, currentQuestion as unknown as Record<string, unknown>, room.can_steal)
|
submitAnswer(answer, currentQuestion as unknown as Record<string, unknown>, room.can_steal)
|
||||||
setAnswer('')
|
setAnswer('')
|
||||||
setShowingQuestion(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStealDecision = (attempt: boolean) => {
|
const handleStealDecision = (attempt: boolean) => {
|
||||||
if (!currentQuestion) return
|
if (!currentQuestion) return
|
||||||
if (attempt) {
|
if (!attempt) {
|
||||||
setShowingQuestion(true)
|
|
||||||
} else {
|
|
||||||
stealDecision(false, currentQuestion.id)
|
stealDecision(false, currentQuestion.id)
|
||||||
}
|
}
|
||||||
setShowStealPrompt(false)
|
setShowStealPrompt(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler for sending team messages
|
|
||||||
const handleSendTeamMessage = (message: string) => {
|
const handleSendTeamMessage = (message: string) => {
|
||||||
if (room && playerName && myTeam) {
|
if (room && playerName && myTeam) {
|
||||||
sendTeamMessage(message, room.code, myTeam, playerName)
|
sendTeamMessage(message, room.code, myTeam, playerName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if the game is active (playing status)
|
|
||||||
const isGameActive = room.status === 'playing'
|
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 (
|
return (
|
||||||
<div className="min-h-screen p-4" style={styles.bgPrimary}>
|
<div className="min-h-screen p-2 md:p-4 overflow-hidden" style={styles.bgPrimary}>
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Scoreboard */}
|
{/* Header with Room Code */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="text-center mb-4">
|
||||||
<div
|
<motion.h1
|
||||||
className="text-center px-6 py-2 rounded-lg"
|
initial={{ y: -20, opacity: 0 }}
|
||||||
style={{
|
animate={{ y: 0, opacity: 1 }}
|
||||||
backgroundColor: config.colors.primary + '20',
|
className="text-2xl md:text-3xl font-bold tracking-wider"
|
||||||
border: `2px solid ${config.colors.primary}`,
|
style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="text-sm" style={styles.textSecondary}>Equipo A</div>
|
TRIVY
|
||||||
<div className="text-3xl font-bold" style={{ color: config.colors.primary }}>
|
</motion.h1>
|
||||||
{room.scores.A}
|
<div className="text-xs opacity-60" style={{ color: config.colors.textMuted }}>
|
||||||
</div>
|
Sala: {room.code}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Game Board */}
|
{/* Scoreboard */}
|
||||||
<div className="grid grid-cols-8 gap-2 mb-6">
|
<div className="flex justify-between items-stretch gap-4 mb-4">
|
||||||
{/* Category Headers */}
|
{/* Team A Score */}
|
||||||
{categories.map((cat) => (
|
<motion.div
|
||||||
<div
|
initial={{ x: -50, opacity: 0 }}
|
||||||
key={cat.id}
|
animate={{ x: 0, opacity: 1 }}
|
||||||
className="text-center p-2 rounded-t-lg"
|
className={`flex-1 text-center p-3 md:p-4 rounded-xl relative overflow-hidden ${
|
||||||
style={{ backgroundColor: cat.color }}
|
room.current_team === 'A' ? 'ring-2 ring-offset-2' : ''
|
||||||
>
|
}`}
|
||||||
<div className="text-2xl">{cat.icon}</div>
|
style={{
|
||||||
<div className="text-xs text-white font-bold truncate">{cat.name}</div>
|
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>
|
</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 */}
|
{/* Questions Grid */}
|
||||||
{[1, 2, 3, 4, 5].map((difficulty) =>
|
{[1, 2, 3, 4, 5].map((difficulty, rowIndex) => (
|
||||||
categories.map((cat) => {
|
<div
|
||||||
const questions = room.board[String(cat.id)] || []
|
key={difficulty}
|
||||||
const question = questions.find(q => q.difficulty === difficulty)
|
className="grid"
|
||||||
const isAnswered = question?.answered
|
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 (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={`${cat.id}-${difficulty}`}
|
key={cellId}
|
||||||
whileHover={!isAnswered && amICurrentPlayer ? { scale: 1.05 } : {}}
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
whileTap={!isAnswered && amICurrentPlayer ? { scale: 0.95 } : {}}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
onClick={() => question && handleSelectQuestion(question, cat.id)}
|
transition={{ delay: (rowIndex * 8 + colIndex) * 0.02 + 0.3 }}
|
||||||
disabled={isAnswered || !amICurrentPlayer}
|
whileHover={canSelect ? {
|
||||||
className={`p-4 rounded transition-all ${
|
scale: 1.08,
|
||||||
isAnswered ? 'opacity-30' : amICurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
|
zIndex: 10,
|
||||||
}`}
|
boxShadow: `0 0 25px ${cat.color}80`
|
||||||
style={{
|
} : {}}
|
||||||
backgroundColor: isAnswered ? config.colors.bg : cat.color + '40',
|
whileTap={canSelect ? { scale: 0.95 } : {}}
|
||||||
border: `2px solid ${cat.color}`,
|
onClick={() => question && handleSelectQuestion(question, cat.id)}
|
||||||
}}
|
onMouseEnter={() => handleCellHover(cellId, !!canSelect)}
|
||||||
>
|
onMouseLeave={() => setHoveredCell(null)}
|
||||||
<span className="text-xl font-bold" style={{ color: config.colors.text }}>
|
disabled={isAnswered || !amICurrentPlayer}
|
||||||
{difficulty * 100}
|
className={`relative aspect-[4/3] md:aspect-square flex items-center justify-center transition-all duration-200 ${
|
||||||
</span>
|
isAnswered
|
||||||
</motion.button>
|
? 'cursor-default'
|
||||||
)
|
: canSelect
|
||||||
})
|
? 'cursor-pointer'
|
||||||
)}
|
: 'cursor-not-allowed'
|
||||||
</div>
|
}`}
|
||||||
|
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 */}
|
{/* Question Modal */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showingQuestion && currentQuestion && (
|
{currentQuestion && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
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"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.9, y: 20 }}
|
initial={{ scale: 0.8, y: 50, rotateX: -15 }}
|
||||||
animate={{ scale: 1, y: 0 }}
|
animate={{ scale: 1, y: 0, rotateX: 0 }}
|
||||||
exit={{ scale: 0.9, y: 20 }}
|
exit={{ scale: 0.8, y: 50, opacity: 0 }}
|
||||||
className="w-full max-w-lg p-6 rounded-lg"
|
transition={{ type: "spring", damping: 20 }}
|
||||||
|
className="w-full max-w-2xl p-6 md:p-8 rounded-2xl relative overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: config.colors.bg,
|
background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
|
||||||
border: `3px solid ${config.colors.primary}`,
|
border: `4px solid ${config.colors.primary}`,
|
||||||
|
boxShadow: `0 0 60px ${config.colors.primary}40, inset 0 0 60px ${config.colors.primary}10`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Timer */}
|
{/* Timer Bar */}
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="absolute top-0 left-0 right-0 h-2 bg-black/30 overflow-hidden">
|
||||||
<span className="text-sm" style={styles.textSecondary}>
|
<motion.div
|
||||||
{currentQuestion.points} puntos
|
initial={{ width: '100%' }}
|
||||||
</span>
|
animate={{ width: `${timerProgress}%` }}
|
||||||
<div
|
transition={{ duration: 0.5 }}
|
||||||
className={`text-2xl font-bold ${timeLeft <= 5 ? 'text-red-500 animate-pulse' : ''}`}
|
className="h-full"
|
||||||
style={{ color: timeLeft > 5 ? config.colors.primary : undefined }}
|
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)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{timeLeft}s
|
${currentQuestion.points}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Question */}
|
{/* Question */}
|
||||||
<p className="text-xl mb-6 text-center" style={{ color: config.colors.text }}>
|
<motion.p
|
||||||
{currentQuestion.question_text || 'Pregunta de ejemplo: ¿En qué año se lanzó la NES?'}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
</p>
|
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 */}
|
{/* Answer Input */}
|
||||||
{amICurrentPlayer && (
|
{amICurrentPlayer && (
|
||||||
<div className="space-y-4">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={answer}
|
value={answer}
|
||||||
@@ -250,30 +486,49 @@ export default function Game() {
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()}
|
||||||
placeholder="Escribe tu respuesta..."
|
placeholder="Escribe tu respuesta..."
|
||||||
autoFocus
|
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={{
|
style={{
|
||||||
border: `2px solid ${config.colors.primary}`,
|
border: `3px solid ${config.colors.primary}50`,
|
||||||
color: config.colors.text,
|
color: config.colors.text,
|
||||||
}}
|
'--tw-ring-color': config.colors.primary,
|
||||||
|
} as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
<button
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
onClick={handleSubmitAnswer}
|
onClick={handleSubmitAnswer}
|
||||||
disabled={!answer.trim()}
|
disabled={!answer.trim()}
|
||||||
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105 disabled:opacity-50"
|
className="w-full py-4 rounded-xl font-bold text-xl transition-all disabled:opacity-30"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: config.colors.primary,
|
background: `linear-gradient(135deg, ${config.colors.primary} 0%, ${config.colors.accent} 100%)`,
|
||||||
color: config.colors.bg,
|
color: config.colors.bg,
|
||||||
|
boxShadow: answer.trim() ? `0 5px 30px ${config.colors.primary}50` : 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Responder
|
RESPONDER
|
||||||
</button>
|
</motion.button>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!amICurrentPlayer && (
|
{!amICurrentPlayer && (
|
||||||
<p className="text-center" style={styles.textSecondary}>
|
<motion.div
|
||||||
Esperando respuesta de {currentPlayer?.name}...
|
initial={{ opacity: 0 }}
|
||||||
</p>
|
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>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -287,68 +542,82 @@ export default function Game() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
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"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.9 }}
|
initial={{ scale: 0.5, rotate: -5 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
className="p-6 rounded-lg text-center"
|
exit={{ scale: 0.5, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", damping: 15 }}
|
||||||
|
className="p-8 rounded-2xl text-center max-w-md"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: config.colors.bg,
|
background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
|
||||||
border: `3px solid ${config.colors.accent}`,
|
border: `4px solid ${config.colors.accent}`,
|
||||||
|
boxShadow: `0 0 60px ${config.colors.accent}60`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold mb-4" style={{ color: config.colors.accent }}>
|
<motion.div
|
||||||
¡Oportunidad de Robo!
|
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>
|
</h3>
|
||||||
<p className="mb-6" style={styles.textSecondary}>
|
<p className="mb-2" style={{ color: config.colors.textMuted }}>
|
||||||
El equipo contrario falló. ¿Quieres intentar robar los puntos?
|
El equipo contrario falló
|
||||||
<br />
|
</p>
|
||||||
<span className="text-red-500">Advertencia: Si fallas, perderás puntos</span>
|
<p className="mb-6 text-red-400 text-sm">
|
||||||
|
⚠️ Si fallas, perderás puntos
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4 justify-center">
|
<div className="flex gap-4 justify-center">
|
||||||
<button
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={() => handleStealDecision(true)}
|
onClick={() => handleStealDecision(true)}
|
||||||
className="px-6 py-3 rounded-lg font-bold"
|
className="px-8 py-4 rounded-xl font-bold text-lg"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: config.colors.accent,
|
background: `linear-gradient(135deg, ${config.colors.accent} 0%, #FF6B6B 100%)`,
|
||||||
color: config.colors.bg,
|
color: '#FFF',
|
||||||
|
boxShadow: `0 5px 30px ${config.colors.accent}50`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
¡Robar!
|
¡ROBAR!
|
||||||
</button>
|
</motion.button>
|
||||||
<button
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={() => handleStealDecision(false)}
|
onClick={() => handleStealDecision(false)}
|
||||||
className="px-6 py-3 rounded-lg font-bold"
|
className="px-8 py-4 rounded-xl font-bold text-lg"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
color: config.colors.text,
|
color: config.colors.text,
|
||||||
border: `2px solid ${config.colors.text}`,
|
border: `2px solid ${config.colors.text}50`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pasar
|
Pasar
|
||||||
</button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Emoji Reactions Bar - Fixed at bottom */}
|
{/* Emoji Reactions Bar */}
|
||||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
|
||||||
<EmojiReactions />
|
<EmojiReactions />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sound Control - Fixed at top right */}
|
{/* Sound Control */}
|
||||||
<div className="fixed top-4 right-4 z-30">
|
<div className="fixed top-4 right-4 z-30">
|
||||||
<SoundControl compact popupPosition="bottom" />
|
<SoundControl compact />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reaction Overlay - Full screen overlay for floating reactions */}
|
|
||||||
<ReactionOverlay />
|
<ReactionOverlay />
|
||||||
|
|
||||||
{/* Team Chat - Only visible during the game */}
|
|
||||||
{isGameActive && (
|
{isGameActive && (
|
||||||
<TeamChat
|
<TeamChat
|
||||||
roomCode={room.code}
|
roomCode={room.code}
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
|
|||||||
|
|
||||||
class SocketService {
|
class SocketService {
|
||||||
private socket: Socket | null = null
|
private socket: Socket | null = null
|
||||||
private listeners: Map<string, Set<(data: unknown) => void>> = new Map()
|
private initialized = false
|
||||||
|
|
||||||
connect(): Socket {
|
connect(): Socket {
|
||||||
if (!this.socket) {
|
if (!this.socket) {
|
||||||
|
console.log('Creating new socket connection to:', SOCKET_URL)
|
||||||
this.socket = io(SOCKET_URL, {
|
this.socket = io(SOCKET_URL, {
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
autoConnect: true,
|
autoConnect: true,
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionAttempts: 5,
|
reconnectionAttempts: 10,
|
||||||
reconnectionDelay: 1000,
|
reconnectionDelay: 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -24,9 +25,12 @@ class SocketService {
|
|||||||
console.log('Socket disconnected:', reason)
|
console.log('Socket disconnected:', reason)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.socket.on('error', (error) => {
|
this.socket.on('connect_error', (error) => {
|
||||||
console.error('Socket error:', error)
|
console.error('Socket connection error:', error)
|
||||||
})
|
})
|
||||||
|
} else if (!this.socket.connected) {
|
||||||
|
console.log('Reconnecting socket...')
|
||||||
|
this.socket.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.socket
|
return this.socket
|
||||||
@@ -36,25 +40,35 @@ class SocketService {
|
|||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.disconnect()
|
this.socket.disconnect()
|
||||||
this.socket = null
|
this.socket = null
|
||||||
|
this.initialized = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: string, callback: (data: unknown) => void): void {
|
on(event: string, callback: (...args: unknown[]) => void): void {
|
||||||
if (!this.listeners.has(event)) {
|
const socket = this.connect()
|
||||||
this.listeners.set(event, new Set())
|
// Remove existing listener to prevent duplicates
|
||||||
}
|
socket.off(event, callback)
|
||||||
this.listeners.get(event)!.add(callback)
|
socket.on(event, callback)
|
||||||
|
|
||||||
this.socket?.on(event, callback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
off(event: string, callback: (data: unknown) => void): void {
|
off(event: string, callback?: (...args: unknown[]) => void): void {
|
||||||
this.listeners.get(event)?.delete(callback)
|
if (callback) {
|
||||||
this.socket?.off(event, callback)
|
this.socket?.off(event, callback)
|
||||||
|
} else {
|
||||||
|
this.socket?.off(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(event: string, data?: unknown): void {
|
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 {
|
get connected(): boolean {
|
||||||
@@ -64,6 +78,15 @@ class SocketService {
|
|||||||
get id(): string | undefined {
|
get id(): string | undefined {
|
||||||
return this.socket?.id
|
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()
|
export const socketService = new SocketService()
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export type SoundEffect =
|
|||||||
| 'victory'
|
| 'victory'
|
||||||
| 'defeat'
|
| 'defeat'
|
||||||
| 'select'
|
| 'select'
|
||||||
|
| 'game_start'
|
||||||
|
| 'player_join'
|
||||||
|
| 'question_reveal'
|
||||||
|
| 'hover'
|
||||||
|
| 'countdown'
|
||||||
|
|
||||||
interface SoundState {
|
interface SoundState {
|
||||||
volume: number
|
volume: number
|
||||||
@@ -56,6 +61,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
|||||||
victory: '/sounds/drrr/victory.mp3',
|
victory: '/sounds/drrr/victory.mp3',
|
||||||
defeat: '/sounds/drrr/defeat.mp3',
|
defeat: '/sounds/drrr/defeat.mp3',
|
||||||
select: '/sounds/drrr/select.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: {
|
retro: {
|
||||||
correct: '/sounds/retro/correct.mp3',
|
correct: '/sounds/retro/correct.mp3',
|
||||||
@@ -66,6 +76,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
|||||||
victory: '/sounds/retro/victory.mp3',
|
victory: '/sounds/retro/victory.mp3',
|
||||||
defeat: '/sounds/retro/defeat.mp3',
|
defeat: '/sounds/retro/defeat.mp3',
|
||||||
select: '/sounds/retro/select.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: {
|
minimal: {
|
||||||
correct: '/sounds/minimal/correct.mp3',
|
correct: '/sounds/minimal/correct.mp3',
|
||||||
@@ -76,6 +91,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
|||||||
victory: '/sounds/minimal/victory.mp3',
|
victory: '/sounds/minimal/victory.mp3',
|
||||||
defeat: '/sounds/minimal/defeat.mp3',
|
defeat: '/sounds/minimal/defeat.mp3',
|
||||||
select: '/sounds/minimal/select.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: {
|
rgb: {
|
||||||
correct: '/sounds/rgb/correct.mp3',
|
correct: '/sounds/rgb/correct.mp3',
|
||||||
@@ -86,6 +106,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
|||||||
victory: '/sounds/rgb/victory.mp3',
|
victory: '/sounds/rgb/victory.mp3',
|
||||||
defeat: '/sounds/rgb/defeat.mp3',
|
defeat: '/sounds/rgb/defeat.mp3',
|
||||||
select: '/sounds/rgb/select.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: {
|
anime: {
|
||||||
correct: '/sounds/anime/correct.mp3',
|
correct: '/sounds/anime/correct.mp3',
|
||||||
@@ -96,6 +121,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
|
|||||||
victory: '/sounds/anime/victory.mp3',
|
victory: '/sounds/anime/victory.mp3',
|
||||||
defeat: '/sounds/anime/defeat.mp3',
|
defeat: '/sounds/anime/defeat.mp3',
|
||||||
select: '/sounds/anime/select.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<SoundEffect, { frequency: number; dura
|
|||||||
victory: { frequency: 523, duration: 0.5, type: 'sine' },
|
victory: { frequency: 523, duration: 0.5, type: 'sine' },
|
||||||
defeat: { frequency: 196, duration: 0.5, type: 'sine' },
|
defeat: { frequency: 196, duration: 0.5, type: 'sine' },
|
||||||
select: { frequency: 600, duration: 0.08, type: 'sine' },
|
select: { frequency: 600, duration: 0.08, type: 'sine' },
|
||||||
|
game_start: { frequency: 440, duration: 0.4, type: 'sine' },
|
||||||
|
player_join: { frequency: 520, duration: 0.12, type: 'sine' },
|
||||||
|
question_reveal: { frequency: 700, duration: 0.2, type: 'triangle' },
|
||||||
|
hover: { frequency: 400, duration: 0.03, type: 'sine' },
|
||||||
|
countdown: { frequency: 600, duration: 0.15, type: 'square' },
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user