From 3b9d6b0fdf17518cfe6325105bdd9f7996185344 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sat, 29 Nov 2025 17:45:49 -0600 Subject: [PATCH] feat(know-your-world): add celebration animations for found regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add celebratory feedback when kids find regions: - Gold flash effect on the found region (main map + magnifier) - Confetti burst from region center - Sound effects using Web Audio API (no audio files) - Three celebration types based on search behavior: - Lightning: Fast direct find (< 3 sec) - quick sparkle - Standard: Normal discovery - two-note chime - Hard-earned: Perseverance (> 20 sec, wandering) - triumphant arpeggio + encouraging message Uses search metrics from hot/cold feedback (path distance, direction reversals, near-misses) to classify celebration type. Game waits for celebration to complete before advancing to next region. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../arcade-games/know-your-world/Provider.tsx | 31 ++- .../components/CelebrationOverlay.tsx | 172 ++++++++++++ .../know-your-world/components/Confetti.tsx | 202 ++++++++++++++ .../components/MapRenderer.tsx | 255 +++++++++++++++--- .../hooks/useCelebrationSound.ts | 151 +++++++++++ .../hooks/useHotColdFeedback.ts | 105 +++++++- .../know-your-world/utils/celebration.ts | 90 +++++++ 7 files changed, 967 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/arcade-games/know-your-world/components/CelebrationOverlay.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/components/Confetti.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/hooks/useCelebrationSound.ts create mode 100644 apps/web/src/arcade-games/know-your-world/utils/celebration.ts diff --git a/apps/web/src/arcade-games/know-your-world/Provider.tsx b/apps/web/src/arcade-games/know-your-world/Provider.tsx index 7c891446..13b34584 100644 --- a/apps/web/src/arcade-games/know-your-world/Provider.tsx +++ b/apps/web/src/arcade-games/know-your-world/Provider.tsx @@ -1,6 +1,6 @@ 'use client' -import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { buildPlayerMetadata, useArcadeSession, @@ -49,6 +49,16 @@ export interface ControlsState { onAutoHintToggle: () => void } +// Celebration state for correct region finds +export type CelebrationType = 'lightning' | 'standard' | 'hard-earned' + +export interface CelebrationState { + regionId: string + regionName: string + type: CelebrationType + startTime: number +} + const defaultControlsState: ControlsState = { isPointerLocked: false, fakeCursorPosition: null, @@ -125,6 +135,11 @@ interface KnowYourWorldContextValue { isInTakeover: boolean setIsInTakeover: React.Dispatch> + // Celebration state for correct region finds + celebration: CelebrationState | null + setCelebration: React.Dispatch> + promptStartTime: React.MutableRefObject + // Shared container ref for pointer lock button detection sharedContainerRef: React.RefObject } @@ -153,6 +168,10 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode // Learning mode takeover state (set by GameInfoPanel, read by MapRenderer) const [isInTakeover, setIsInTakeover] = useState(false) + // Celebration state for correct region finds + const [celebration, setCelebration] = useState(null) + const promptStartTime = useRef(Date.now()) + // Shared container ref for pointer lock button detection const sharedContainerRef = useRef(null) @@ -233,6 +252,13 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode applyMove: (state) => state, // Server handles all state updates }) + // Update promptStartTime when currentPrompt changes (for celebration timing) + useEffect(() => { + if (state.currentPrompt) { + promptStartTime.current = Date.now() + } + }, [state.currentPrompt]) + // Pass through cursor updates with the provided player ID and userId const sendCursorUpdate = useCallback( ( @@ -545,6 +571,9 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode setControlsState, isInTakeover, setIsInTakeover, + celebration, + setCelebration, + promptStartTime, sharedContainerRef, }} > diff --git a/apps/web/src/arcade-games/know-your-world/components/CelebrationOverlay.tsx b/apps/web/src/arcade-games/know-your-world/components/CelebrationOverlay.tsx new file mode 100644 index 00000000..c1171a24 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/CelebrationOverlay.tsx @@ -0,0 +1,172 @@ +/** + * Celebration Overlay Component + * + * Orchestrates the celebration sequence when a region is found: + * - Plays sound effect + * - Shows confetti + * - Shows encouraging text (for hard-earned) + * - Notifies when complete to advance game + */ + +'use client' + +import { css } from '@styled/css' +import { useEffect, useState, useCallback } from 'react' +import type { CelebrationState } from '../Provider' +import { ConfettiBurst } from './Confetti' +import { useCelebrationSound } from '../hooks/useCelebrationSound' +import { CELEBRATION_TIMING } from '../utils/celebration' + +interface CelebrationOverlayProps { + celebration: CelebrationState + regionCenter: { x: number; y: number } + onComplete: () => void + reducedMotion?: boolean +} + +// Encouraging messages for hard-earned finds +const HARD_EARNED_MESSAGES = [ + 'You found it!', + 'Great perseverance!', + 'Never gave up!', + 'You did it!', + 'Amazing effort!', +] + +export function CelebrationOverlay({ + celebration, + regionCenter, + onComplete, + reducedMotion = false, +}: CelebrationOverlayProps) { + const [confettiComplete, setConfettiComplete] = useState(false) + const { playCelebration } = useCelebrationSound() + const timing = CELEBRATION_TIMING[celebration.type] + + // Pick a random message for hard-earned + const [message] = useState( + () => HARD_EARNED_MESSAGES[Math.floor(Math.random() * HARD_EARNED_MESSAGES.length)] + ) + + // Play sound on mount + useEffect(() => { + playCelebration(celebration.type) + }, [playCelebration, celebration.type]) + + // Handle confetti completion + const handleConfettiComplete = useCallback(() => { + setConfettiComplete(true) + onComplete() + }, [onComplete]) + + // For reduced motion, just show a brief message then complete + useEffect(() => { + if (reducedMotion) { + const timer = setTimeout(() => { + onComplete() + }, 500) // Brief delay for reduced motion + return () => clearTimeout(timer) + } + }, [reducedMotion, onComplete]) + + // Reduced motion: simple notification only + if (reducedMotion) { + return ( +
+
+ Found! +
+
+ ) + } + + return ( +
+ + + {/* Confetti burst from region center */} + {!confettiComplete && ( + + )} + + {/* Encouraging text for hard-earned finds */} + {celebration.type === 'hard-earned' && ( +
+ {message} +
+ )} +
+ ) +} diff --git a/apps/web/src/arcade-games/know-your-world/components/Confetti.tsx b/apps/web/src/arcade-games/know-your-world/components/Confetti.tsx new file mode 100644 index 00000000..a2aa5312 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/Confetti.tsx @@ -0,0 +1,202 @@ +/** + * Confetti Component + * + * CSS-animated confetti particles for celebration effects. + * Uses pure CSS animations for performance - no canvas or heavy libraries. + */ + +import { css } from '@styled/css' +import { useEffect, useMemo, useState } from 'react' +import type { CelebrationType } from '../Provider' +import { CONFETTI_CONFIG, CELEBRATION_TIMING } from '../utils/celebration' + +interface ConfettiProps { + type: CelebrationType + origin: { x: number; y: number } + onComplete: () => void +} + +interface Particle { + id: number + x: number + y: number + color: string + size: number + angle: number // Direction in degrees + distance: number // How far to travel + rotation: number // Initial rotation + rotationSpeed: number // Rotation during animation + delay: number // Stagger start +} + +// Generate random particles based on config +function generateParticles(type: CelebrationType, origin: { x: number; y: number }): Particle[] { + const config = CONFETTI_CONFIG[type] + const particles: Particle[] = [] + + for (let i = 0; i < config.count; i++) { + // Random angle within spread, centered upward (-90 deg) + const spreadRad = (config.spread * Math.PI) / 180 + const baseAngle = -Math.PI / 2 // Upward + const angle = baseAngle + (Math.random() - 0.5) * spreadRad + + particles.push({ + id: i, + x: origin.x, + y: origin.y, + color: config.colors[Math.floor(Math.random() * config.colors.length)], + size: 6 + Math.random() * 6, // 6-12px + angle: (angle * 180) / Math.PI, + distance: 80 + Math.random() * 120, // 80-200px + rotation: Math.random() * 360, + rotationSpeed: (Math.random() - 0.5) * 720, // -360 to +360 deg + delay: Math.random() * 50, // 0-50ms stagger + }) + } + + return particles +} + +export function Confetti({ type, origin, onComplete }: ConfettiProps) { + const [isComplete, setIsComplete] = useState(false) + const particles = useMemo(() => generateParticles(type, origin), [type, origin]) + const timing = CELEBRATION_TIMING[type] + + // Call onComplete when animation finishes + useEffect(() => { + const timer = setTimeout(() => { + setIsComplete(true) + onComplete() + }, timing.confettiDuration) + + return () => clearTimeout(timer) + }, [timing.confettiDuration, onComplete]) + + if (isComplete) return null + + return ( +
+ + {particles.map((particle) => ( +
+ ))} +
+ ) +} + +// Alternative implementation with proper burst effect +export function ConfettiBurst({ type, origin, onComplete }: ConfettiProps) { + const [isComplete, setIsComplete] = useState(false) + const particles = useMemo(() => generateParticles(type, origin), [type, origin]) + const timing = CELEBRATION_TIMING[type] + + useEffect(() => { + const timer = setTimeout(() => { + setIsComplete(true) + onComplete() + }, timing.confettiDuration) + + return () => clearTimeout(timer) + }, [timing.confettiDuration, onComplete]) + + if (isComplete) return null + + return ( +
+ + {particles.map((particle) => { + const offsetX = Math.cos((particle.angle * Math.PI) / 180) * particle.distance + const offsetY = Math.sin((particle.angle * Math.PI) / 180) * particle.distance + + return ( +
+ ) + })} +
+ ) +} diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index 439e523b..25fe6b15 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -46,6 +46,8 @@ import { calculateScreenPixelRatio, isAboveThreshold, } from '../utils/screenPixelRatio' +import { classifyCelebration, CELEBRATION_TIMING } from '../utils/celebration' +import { CelebrationOverlay } from './CelebrationOverlay' import { DevCropTool } from './DevCropTool' // Debug flag: show technical info in magnifier (dev only) @@ -355,7 +357,14 @@ export function MapRenderer({ mapName, }: MapRendererProps) { // Get context for sharing state with GameInfoPanel - const { setControlsState, sharedContainerRef, isInTakeover } = useKnowYourWorld() + const { + setControlsState, + sharedContainerRef, + isInTakeover, + celebration, + setCelebration, + promptStartTime, + } = useKnowYourWorld() // Extract force tuning parameters with defaults const { showArrows = false, @@ -501,6 +510,10 @@ export function MapRenderer({ // Hint animation state const [hintFlashProgress, setHintFlashProgress] = useState(0) // 0-1 pulsing value const [isHintAnimating, setIsHintAnimating] = useState(false) // Track if animation in progress + + // Celebration animation state + const [celebrationFlashProgress, setCelebrationFlashProgress] = useState(0) // 0-1 pulsing value + const pendingCelebrationClick = useRef<{ regionId: string; regionName: string } | null>(null) // Saved button position to prevent jumping during zoom animation const [savedButtonPosition, setSavedButtonPosition] = useState<{ top: number @@ -709,6 +722,7 @@ export function MapRenderer({ checkPosition: checkHotCold, reset: resetHotCold, lastFeedbackType: hotColdFeedbackType, + getSearchMetrics, } = useHotColdFeedback({ enabled: assistanceAllowsHotCold && hotColdEnabled && hasFinePointer, targetRegionId: currentPrompt, @@ -847,11 +861,11 @@ export function MapRenderer({ // Use the same detection logic as hover tracking (50px detection box) const { detectedRegions, regionUnderCursor } = detectRegions(cursorX, cursorY) - if (regionUnderCursor) { + if (regionUnderCursor && !celebration) { // Find the region data to get the name const region = mapData.regions.find((r) => r.id === regionUnderCursor) if (region) { - onRegionClick(regionUnderCursor, region.name) + handleRegionClickWithCelebration(regionUnderCursor, region.name) } } } @@ -1183,6 +1197,122 @@ export function MapRenderer({ } }, [hintActive?.timestamp]) // Re-run when timestamp changes + // Celebration animation effect - gold flash and confetti when region found + useEffect(() => { + if (!celebration) { + setCelebrationFlashProgress(0) + return + } + + // Track if this effect has been cleaned up + let isCancelled = false + let animationFrameId: number | null = null + + // Animation: pulsing gold flash during celebration + const timing = CELEBRATION_TIMING[celebration.type] + const duration = timing.totalDuration + const pulses = celebration.type === 'lightning' ? 2 : celebration.type === 'standard' ? 3 : 4 + const startTime = Date.now() + + const animate = () => { + if (isCancelled) return + + const elapsed = Date.now() - startTime + const progress = Math.min(elapsed / duration, 1) + + // Create pulsing effect: sin wave for smooth on/off + const pulseProgress = Math.sin(progress * Math.PI * pulses * 2) * 0.5 + 0.5 + setCelebrationFlashProgress(pulseProgress) + + if (progress < 1) { + animationFrameId = requestAnimationFrame(animate) + } + } + + animationFrameId = requestAnimationFrame(animate) + + // Cleanup + return () => { + isCancelled = true + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + } + } + }, [celebration?.startTime]) // Re-run when celebration starts + + // Handle celebration completion - call the actual click after animation + const handleCelebrationComplete = useCallback(() => { + const pending = pendingCelebrationClick.current + if (pending) { + // Clear celebration state first + setCelebration(null) + setCelebrationFlashProgress(0) + // Then fire the actual click + onRegionClick(pending.regionId, pending.regionName) + pendingCelebrationClick.current = null + } + }, [setCelebration, onRegionClick]) + + // Wrapper function to intercept clicks and trigger celebration for correct regions + const handleRegionClickWithCelebration = useCallback( + (regionId: string, regionName: string) => { + // If we're already celebrating, ignore clicks + if (celebration) return + + // Check if this is the correct region + if (regionId === currentPrompt) { + // Correct! Start celebration + const metrics = getSearchMetrics(promptStartTime.current) + const celebrationType = classifyCelebration(metrics) + + // Store pending click for after celebration + pendingCelebrationClick.current = { regionId, regionName } + + // Start celebration + setCelebration({ + regionId, + regionName, + type: celebrationType, + startTime: Date.now(), + }) + } else { + // Wrong region - handle immediately + onRegionClick(regionId, regionName) + } + }, + [celebration, currentPrompt, getSearchMetrics, promptStartTime, setCelebration, onRegionClick] + ) + + // Get center of celebrating region for confetti origin + const getCelebrationRegionCenter = useCallback((): { x: number; y: number } => { + if (!celebration || !svgRef.current || !containerRef.current) { + return { x: window.innerWidth / 2, y: window.innerHeight / 2 } + } + + const region = mapData.regions.find((r) => r.id === celebration.regionId) + if (!region) { + return { x: window.innerWidth / 2, y: window.innerHeight / 2 } + } + + // Convert SVG coordinates to screen coordinates + const svgRect = svgRef.current.getBoundingClientRect() + const containerRect = containerRef.current.getBoundingClientRect() + const viewBoxParts = displayViewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxW = viewBoxParts[2] || 1000 + const viewBoxH = viewBoxParts[3] || 500 + const viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH) + const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX + const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY + + // Get absolute screen position + const screenX = containerRect.left + (region.center[0] - viewBoxX) * viewport.scale + svgOffsetX + const screenY = containerRect.top + (region.center[1] - viewBoxY) * viewport.scale + svgOffsetY + + return { x: screenX, y: screenY } + }, [celebration, mapData.regions, displayViewBox]) + // Keyboard shortcuts - Shift for magnifier, H for hint useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -2234,16 +2364,23 @@ export function MapRenderer({ // Run region detection at the tap position const { regionUnderCursor } = detectRegions(tapContainerX, tapContainerY) - if (regionUnderCursor) { + if (regionUnderCursor && !celebration) { const region = mapData.regions.find((r) => r.id === regionUnderCursor) if (region) { - onRegionClick(regionUnderCursor, region.name) + handleRegionClickWithCelebration(regionUnderCursor, region.name) } } } } }, - [detectRegions, mapData.regions, onRegionClick, displayViewBox, zoomSpring] + [ + detectRegions, + mapData.regions, + handleRegionClickWithCelebration, + celebration, + displayViewBox, + zoomSpring, + ] ) return ( @@ -2330,29 +2467,33 @@ export function MapRenderer({ const playerId = !isExcluded && isFound ? getPlayerWhoFoundRegion(region.id) : null const isBeingRevealed = giveUpReveal?.regionId === region.id const isBeingHinted = hintActive?.regionId === region.id + const isCelebrating = celebration?.regionId === region.id // Special styling for excluded regions (grayed out, pre-labeled) - // Bright gold flash for give up reveal with high contrast - // Cyan flash for hint - const fill = isBeingRevealed - ? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` // Brighter gold, higher base opacity - : isExcluded - ? isDark - ? '#374151' // gray-700 - : '#d1d5db' // gray-300 - : isFound && playerId - ? `url(#player-pattern-${playerId})` - : getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark) + // Bright gold flash for give up reveal, celebration, and hint + const fill = isCelebrating + ? `rgba(255, 215, 0, ${0.7 + celebrationFlashProgress * 0.3})` // Bright gold celebration flash + : isBeingRevealed + ? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` // Brighter gold, higher base opacity + : isExcluded + ? isDark + ? '#374151' // gray-700 + : '#d1d5db' // gray-300 + : isFound && playerId + ? `url(#player-pattern-${playerId})` + : getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark) // During give-up animation, dim all non-revealed regions const dimmedOpacity = isGiveUpAnimating && !isBeingRevealed ? 0.25 : 1 - // Revealed region gets a prominent stroke + // Revealed/celebrating region gets a prominent stroke // Unfound regions get thicker borders for better visibility against sea - const stroke = isBeingRevealed - ? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})` // Orange stroke for contrast - : getRegionStroke(isFound, isDark) - const strokeWidth = isBeingRevealed ? 3 : isFound ? 1 : 1.5 + const stroke = isCelebrating + ? `rgba(255, 180, 0, ${0.8 + celebrationFlashProgress * 0.2})` // Gold stroke for celebration + : isBeingRevealed + ? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})` // Orange stroke for contrast + : getRegionStroke(isFound, isDark) + const strokeWidth = isCelebrating ? 4 : isBeingRevealed ? 3 : isFound ? 1 : 1.5 // Check if a network cursor is hovering over this region const networkHover = networkHoveredRegions[region.id] @@ -2395,6 +2536,18 @@ export function MapRenderer({ pointerEvents="none" /> )} + {/* Glow effect for celebration - bright gold pulsing */} + {isCelebrating && ( + + )} {/* Network hover border (crisp outline in player color) */} {networkHover && !isBeingRevealed && ( !isExcluded && !pointerLocked && setHoveredRegion(region.id)} onMouseLeave={() => !pointerLocked && setHoveredRegion(null)} onClick={() => { - if (!isExcluded) { - onRegionClick(region.id, region.name) + if (!isExcluded && !celebration) { + handleRegionClickWithCelebration(region.id, region.name) } - }} // Disable clicks on excluded regions + }} // Disable clicks on excluded regions and during celebration style={{ cursor: isExcluded ? 'default' : 'pointer', transition: 'all 0.2s ease', @@ -2758,7 +2911,9 @@ export function MapRenderer({ cursor: 'pointer', zIndex: 20, }} - onClick={() => onRegionClick(label.regionId, label.regionName)} + onClick={() => + !celebration && handleRegionClickWithCelebration(label.regionId, label.regionName) + } onMouseEnter={() => setHoveredRegion(label.regionId)} onMouseLeave={() => setHoveredRegion(null)} > @@ -3111,23 +3266,28 @@ export function MapRenderer({ const isFound = regionsFound.includes(region.id) const playerId = isFound ? getPlayerWhoFoundRegion(region.id) : null const isBeingRevealed = giveUpReveal?.regionId === region.id + const isCelebrating = celebration?.regionId === region.id - // Bright gold flash for give up reveal in magnifier too - const fill = isBeingRevealed - ? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` - : isFound && playerId - ? `url(#player-pattern-${playerId})` - : getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark) + // Bright gold flash for celebration and give up reveal in magnifier too + const fill = isCelebrating + ? `rgba(255, 215, 0, ${0.7 + celebrationFlashProgress * 0.3})` + : isBeingRevealed + ? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` + : isFound && playerId + ? `url(#player-pattern-${playerId})` + : getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark) // During give-up animation, dim all non-revealed regions const dimmedOpacity = isGiveUpAnimating && !isBeingRevealed ? 0.25 : 1 - // Revealed region gets a prominent stroke + // Revealed/celebrating region gets a prominent stroke // Unfound regions get thicker borders for better visibility against sea - const stroke = isBeingRevealed - ? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})` - : getRegionStroke(isFound, isDark) - const strokeWidth = isBeingRevealed ? 2 : isFound ? 0.5 : 1 + const stroke = isCelebrating + ? `rgba(255, 180, 0, ${0.8 + celebrationFlashProgress * 0.2})` + : isBeingRevealed + ? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})` + : getRegionStroke(isFound, isDark) + const strokeWidth = isCelebrating ? 3 : isBeingRevealed ? 2 : isFound ? 0.5 : 1 return ( @@ -3142,6 +3302,17 @@ export function MapRenderer({ style={{ filter: 'blur(2px)' }} /> )} + {/* Glow effect for celebrating region in magnifier */} + {isCelebrating && ( + + )} ) })()} + + {/* Celebration overlay - shows confetti and sound when region is found */} + {celebration && ( + + )}
) } diff --git a/apps/web/src/arcade-games/know-your-world/hooks/useCelebrationSound.ts b/apps/web/src/arcade-games/know-your-world/hooks/useCelebrationSound.ts new file mode 100644 index 00000000..31a0ed60 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/hooks/useCelebrationSound.ts @@ -0,0 +1,151 @@ +/** + * Celebration Sound Hook + * + * Uses Web Audio API to generate celebration sounds without audio files. + * Three distinct sounds for different celebration types: + * - Lightning: Quick sparkle (high pitch, fast) + * - Standard: Pleasant two-note chime + * - Hard-earned: Triumphant ascending arpeggio + */ + +import { useCallback, useRef } from 'react' +import type { CelebrationType } from '../Provider' + +// Musical frequencies (Hz) - based on C major scale +const NOTES = { + C4: 261.63, + E4: 329.63, + G4: 392.0, + C5: 523.25, + E5: 659.25, + G5: 784.0, +} + +export function useCelebrationSound() { + const audioContextRef = useRef(null) + + // Get or create audio context (lazy initialization) + const getAudioContext = useCallback(() => { + if (!audioContextRef.current) { + try { + audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)() + } catch { + console.warn('Web Audio API not supported') + return null + } + } + + // Resume if suspended (browsers require user interaction first) + if (audioContextRef.current.state === 'suspended') { + audioContextRef.current.resume() + } + + return audioContextRef.current + }, []) + + // Play a single note with envelope + const playNote = useCallback( + ( + ctx: AudioContext, + frequency: number, + startTime: number, + duration: number, + volume: number = 0.3 + ) => { + const oscillator = ctx.createOscillator() + const gainNode = ctx.createGain() + + oscillator.type = 'sine' + oscillator.frequency.setValueAtTime(frequency, startTime) + + // Envelope: quick attack, sustain, smooth release + gainNode.gain.setValueAtTime(0, startTime) + gainNode.gain.linearRampToValueAtTime(volume, startTime + 0.02) // Attack + gainNode.gain.setValueAtTime(volume, startTime + duration * 0.7) // Sustain + gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + duration) // Release + + oscillator.connect(gainNode) + gainNode.connect(ctx.destination) + + oscillator.start(startTime) + oscillator.stop(startTime + duration) + }, + [] + ) + + // Lightning: Quick sparkle sound + const playLightning = useCallback(() => { + const ctx = getAudioContext() + if (!ctx) return + + const now = ctx.currentTime + + // Quick ascending glissando + const osc = ctx.createOscillator() + const gain = ctx.createGain() + + osc.type = 'sine' + osc.frequency.setValueAtTime(800, now) + osc.frequency.exponentialRampToValueAtTime(2000, now + 0.1) + osc.frequency.exponentialRampToValueAtTime(1600, now + 0.15) + + gain.gain.setValueAtTime(0.25, now) + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2) + + osc.connect(gain) + gain.connect(ctx.destination) + + osc.start(now) + osc.stop(now + 0.2) + }, [getAudioContext]) + + // Standard: Two-note chime (C-E) + const playStandard = useCallback(() => { + const ctx = getAudioContext() + if (!ctx) return + + const now = ctx.currentTime + + // C4 then E4 chord + playNote(ctx, NOTES.C5, now, 0.3, 0.25) + playNote(ctx, NOTES.E5, now + 0.08, 0.35, 0.2) + }, [getAudioContext, playNote]) + + // Hard-earned: Triumphant C-E-G arpeggio + const playHardEarned = useCallback(() => { + const ctx = getAudioContext() + if (!ctx) return + + const now = ctx.currentTime + + // Ascending C-E-G arpeggio with final chord + playNote(ctx, NOTES.C4, now, 0.25, 0.2) + playNote(ctx, NOTES.E4, now + 0.1, 0.25, 0.2) + playNote(ctx, NOTES.G4, now + 0.2, 0.25, 0.2) + + // Final chord (C-E-G together) + playNote(ctx, NOTES.C5, now + 0.35, 0.4, 0.15) + playNote(ctx, NOTES.E5, now + 0.35, 0.4, 0.15) + playNote(ctx, NOTES.G5, now + 0.35, 0.4, 0.15) + }, [getAudioContext, playNote]) + + // Play celebration sound based on type + const playCelebration = useCallback( + (type: CelebrationType) => { + switch (type) { + case 'lightning': + playLightning() + break + case 'standard': + playStandard() + break + case 'hard-earned': + playHardEarned() + break + } + }, + [playLightning, playStandard, playHardEarned] + ) + + return { playCelebration } +} diff --git a/apps/web/src/arcade-games/know-your-world/hooks/useHotColdFeedback.ts b/apps/web/src/arcade-games/know-your-world/hooks/useHotColdFeedback.ts index ddf73048..6082324f 100644 --- a/apps/web/src/arcade-games/know-your-world/hooks/useHotColdFeedback.ts +++ b/apps/web/src/arcade-games/know-your-world/hooks/useHotColdFeedback.ts @@ -86,6 +86,26 @@ interface UseHotColdFeedbackParams { regions: RegionWithCenter[] // All regions with their centers } +// Search metrics for celebration classification +export interface SearchMetrics { + // Time + timeToFind: number // ms from prompt start to now + + // Distance traveled + totalCursorDistance: number // Total pixels cursor moved + straightLineDistance: number // Direct path would have been + searchEfficiency: number // straight / total (1.0 = perfect, <0.3 = searched hard) + + // Direction changes + directionReversals: number // How many times changed direction toward/away + + // Near misses + nearMissCount: number // Times got within CLOSE threshold then moved away + + // Zone transitions + zoneTransitions: number // warming→cooling→warming transitions +} + interface CheckPositionParams { cursorPosition: { x: number; y: number } targetCenter: { x: number; y: number } | null @@ -504,5 +524,88 @@ export function useHotColdFeedback({ } }, []) - return { checkPosition, reset, isSpeaking, lastFeedbackType } + // Get search metrics for celebration classification + const getSearchMetrics = useCallback((promptStartTime: number): SearchMetrics => { + const state = stateRef.current + const entries = getRecentEntries(state, HISTORY_LENGTH).filter( + (e): e is PathEntry => e !== null + ) + + const now = performance.now() + const timeToFind = Date.now() - promptStartTime + + // If no history, return defaults + if (entries.length < 2) { + return { + timeToFind, + totalCursorDistance: 0, + straightLineDistance: 0, + searchEfficiency: 1, // Assume perfect if no data + directionReversals: 0, + nearMissCount: 0, + zoneTransitions: 0, + } + } + + // Calculate total cursor distance (sum of distances between consecutive points) + let totalCursorDistance = 0 + for (let i = 1; i < entries.length; i++) { + const dx = entries[i].x - entries[i - 1].x + const dy = entries[i].y - entries[i - 1].y + totalCursorDistance += Math.sqrt(dx * dx + dy * dy) + } + + // Straight line distance from first point to last + const firstEntry = entries[0] + const lastEntry = entries[entries.length - 1] + const straightLineDx = lastEntry.x - firstEntry.x + const straightLineDy = lastEntry.y - firstEntry.y + const straightLineDistance = Math.sqrt( + straightLineDx * straightLineDx + straightLineDy * straightLineDy + ) + + // Search efficiency: straight / total (higher = more direct) + const searchEfficiency = + totalCursorDistance > 0 ? Math.min(1, straightLineDistance / totalCursorDistance) : 1 + + // Count direction reversals (changes in whether getting closer or farther) + let directionReversals = 0 + let lastSign = 0 + for (let i = 1; i < entries.length; i++) { + const delta = entries[i].distance - entries[i - 1].distance + const sign = Math.sign(delta) + if (lastSign !== 0 && sign !== 0 && sign !== lastSign) { + directionReversals++ + } + if (sign !== 0) lastSign = sign + } + + // Count near misses (got within CLOSE threshold then moved away) + let nearMissCount = 0 + let wasClose = false + for (const entry of entries) { + const isClose = entry.distance < CLOSE + if (wasClose && !isClose) { + nearMissCount++ + } + wasClose = isClose + } + + // Count zone transitions (changes between warming/neutral/cooling) + // We'd need to track zones in history, but we can estimate from direction reversals + // For simplicity, zone transitions ≈ direction reversals / 2 + const zoneTransitions = Math.floor(directionReversals / 2) + + return { + timeToFind, + totalCursorDistance, + straightLineDistance, + searchEfficiency, + directionReversals, + nearMissCount, + zoneTransitions, + } + }, []) + + return { checkPosition, reset, isSpeaking, lastFeedbackType, getSearchMetrics } } diff --git a/apps/web/src/arcade-games/know-your-world/utils/celebration.ts b/apps/web/src/arcade-games/know-your-world/utils/celebration.ts new file mode 100644 index 00000000..8ec2a08d --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/utils/celebration.ts @@ -0,0 +1,90 @@ +/** + * Celebration Classification Utility + * + * Determines the type of celebration based on how the user found the region. + * Uses search metrics from the hot/cold feedback system to classify: + * - Lightning: Fast and direct find + * - Standard: Normal discovery + * - Hard-earned: Extensive searching, shows perseverance + */ + +import type { SearchMetrics } from '../hooks/useHotColdFeedback' +import type { CelebrationType } from '../Provider' + +// Thresholds for classification +const LIGHTNING_TIME_MS = 3000 // Under 3 seconds +const LIGHTNING_EFFICIENCY = 0.7 // Very direct path + +const HARD_EARNED_TIME_MS = 20000 // Over 20 seconds +const HARD_EARNED_EFFICIENCY = 0.3 // Wandered a lot +const HARD_EARNED_REVERSALS = 10 // Many direction changes +const HARD_EARNED_NEAR_MISSES = 2 // Got close multiple times + +/** + * Classify the celebration type based on search metrics. + * + * @param metrics - Search metrics from useHotColdFeedback + * @returns The celebration type: 'lightning', 'standard', or 'hard-earned' + */ +export function classifyCelebration(metrics: SearchMetrics): CelebrationType { + // Lightning: Fast and direct + // Kid knew exactly where to look - reward the speed! + if (metrics.timeToFind < LIGHTNING_TIME_MS && metrics.searchEfficiency > LIGHTNING_EFFICIENCY) { + return 'lightning' + } + + // Hard-earned: Any of these indicate real effort + // Kid really worked for it - acknowledge the perseverance! + if ( + metrics.timeToFind > HARD_EARNED_TIME_MS || + metrics.searchEfficiency < HARD_EARNED_EFFICIENCY || + metrics.directionReversals > HARD_EARNED_REVERSALS || + metrics.nearMissCount > HARD_EARNED_NEAR_MISSES + ) { + return 'hard-earned' + } + + // Standard: Normal discovery + return 'standard' +} + +// Celebration timing configuration +export const CELEBRATION_TIMING = { + lightning: { + flashDuration: 400, + confettiDuration: 600, + soundDuration: 200, + totalDuration: 600, + }, + standard: { + flashDuration: 600, + confettiDuration: 1000, + soundDuration: 400, + totalDuration: 1000, + }, + 'hard-earned': { + flashDuration: 800, + confettiDuration: 1500, + soundDuration: 600, + totalDuration: 1500, + }, +} as const + +// Confetti configuration per celebration type +export const CONFETTI_CONFIG = { + lightning: { + count: 12, + spread: 60, + colors: ['#fbbf24', '#fcd34d', '#fef3c7'], // Gold sparkles + }, + standard: { + count: 20, + spread: 90, + colors: ['#fbbf24', '#22c55e', '#3b82f6', '#f472b6'], // Colorful mix + }, + 'hard-earned': { + count: 35, + spread: 120, + colors: ['#fbbf24', '#22c55e', '#8b5cf6', '#ec4899', '#f97316'], // Big party! + }, +} as const