Sound System: - Add soundStore with volume/mute persistence - Add useSound hook with Web Audio API fallback - Add SoundControl component for in-game volume adjustment - Play sounds for correct/incorrect, steal, timer, victory/defeat Team Chat: - Add TeamChat component with collapsible panel - Add team_message WebSocket event (team-only visibility) - Store up to 50 messages per session Emoji Reactions: - Add EmojiReactions bar with 8 emojis - Add ReactionOverlay with floating animations (Framer Motion) - Add rate limiting (1 reaction per 3 seconds) - Broadcast reactions to all players in room Admin Monitor: - Add Monitor page showing active rooms from Redis - Display player counts, team composition, status - Add ability to close problematic rooms Admin Settings: - Add Settings page for game configuration - Configure points/times by difficulty, steal penalty, max players - Store config in JSON file with service helpers CSV Import/Export: - Add export endpoint with optional filters - Add import endpoint with validation and error reporting - Add UI buttons and import result modal in Questions page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
import { useEffect, useRef, useMemo } from 'react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { useGameStore } from '../../stores/gameStore'
|
|
import { useThemeStyles } from '../../themes/ThemeProvider'
|
|
|
|
const REACTION_DURATION_MS = 2000 // Auto-remove after 2 seconds
|
|
|
|
export default function ReactionOverlay() {
|
|
const { reactions, removeReaction } = useGameStore()
|
|
const { config } = useThemeStyles()
|
|
const timeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
|
|
|
// Auto-remove reactions after duration
|
|
useEffect(() => {
|
|
reactions.forEach((reaction) => {
|
|
// Only set timeout if we haven't already set one for this reaction
|
|
if (!timeoutsRef.current.has(reaction.id)) {
|
|
const timeout = setTimeout(() => {
|
|
removeReaction(reaction.id)
|
|
timeoutsRef.current.delete(reaction.id)
|
|
}, REACTION_DURATION_MS)
|
|
timeoutsRef.current.set(reaction.id, timeout)
|
|
}
|
|
})
|
|
|
|
// Cleanup removed reactions
|
|
return () => {
|
|
timeoutsRef.current.forEach((timeout) => clearTimeout(timeout))
|
|
}
|
|
}, [reactions, removeReaction])
|
|
|
|
// Pre-calculate random positions for each reaction to avoid recalculation on re-render
|
|
const reactionPositions = useMemo(() => {
|
|
const positions = new Map<string, number>()
|
|
reactions.forEach((reaction) => {
|
|
if (!positions.has(reaction.id)) {
|
|
positions.set(reaction.id, 10 + Math.random() * 80)
|
|
}
|
|
})
|
|
return positions
|
|
}, [reactions])
|
|
|
|
return (
|
|
<div className="fixed inset-0 pointer-events-none overflow-hidden z-40">
|
|
<AnimatePresence>
|
|
{reactions.map((reaction) => {
|
|
// Use pre-calculated random horizontal position (10% to 90% of screen width)
|
|
const randomX = reactionPositions.get(reaction.id) || 50
|
|
|
|
return (
|
|
<motion.div
|
|
key={reaction.id}
|
|
initial={{
|
|
opacity: 0,
|
|
y: '100vh',
|
|
x: `${randomX}vw`,
|
|
scale: 0.5,
|
|
}}
|
|
animate={{
|
|
opacity: 1,
|
|
y: '20vh',
|
|
scale: 1,
|
|
}}
|
|
exit={{
|
|
opacity: 0,
|
|
y: '-10vh',
|
|
scale: 0.8,
|
|
}}
|
|
transition={{
|
|
duration: REACTION_DURATION_MS / 1000,
|
|
ease: 'easeOut',
|
|
}}
|
|
className="absolute flex flex-col items-center"
|
|
style={{
|
|
transform: `translateX(-50%)`,
|
|
}}
|
|
>
|
|
{/* Emoji */}
|
|
<motion.span
|
|
className="text-5xl"
|
|
animate={{
|
|
scale: [1, 1.2, 1],
|
|
}}
|
|
transition={{
|
|
duration: 0.3,
|
|
repeat: 2,
|
|
repeatType: 'reverse',
|
|
}}
|
|
>
|
|
{reaction.emoji}
|
|
</motion.span>
|
|
|
|
{/* Player name badge */}
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.1 }}
|
|
className="mt-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
|
|
style={{
|
|
backgroundColor:
|
|
reaction.team === 'A'
|
|
? `${config.colors.primary}CC`
|
|
: `${config.colors.secondary}CC`,
|
|
color: config.colors.bg,
|
|
backdropFilter: 'blur(4px)',
|
|
}}
|
|
>
|
|
{reaction.player_name}
|
|
</motion.div>
|
|
</motion.div>
|
|
)
|
|
})}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|