feat(know-your-world): add celebration animations for found regions

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-29 17:45:49 -06:00
parent 9f6b425daf
commit 3b9d6b0fdf
7 changed files with 967 additions and 39 deletions

View File

@ -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<React.SetStateAction<boolean>>
// Celebration state for correct region finds
celebration: CelebrationState | null
setCelebration: React.Dispatch<React.SetStateAction<CelebrationState | null>>
promptStartTime: React.MutableRefObject<number>
// Shared container ref for pointer lock button detection
sharedContainerRef: React.RefObject<HTMLDivElement>
}
@ -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<CelebrationState | null>(null)
const promptStartTime = useRef<number>(Date.now())
// Shared container ref for pointer lock button detection
const sharedContainerRef = useRef<HTMLDivElement>(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,
}}
>

View File

@ -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 (
<div
data-component="celebration-overlay-reduced"
className={css({
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<div
className={css({
bg: 'rgba(34, 197, 94, 0.9)',
color: 'white',
px: 6,
py: 3,
borderRadius: 'xl',
fontSize: 'xl',
fontWeight: 'bold',
boxShadow: 'lg',
})}
>
Found!
</div>
</div>
)
}
return (
<div
data-component="celebration-overlay"
className={css({
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 10000,
})}
>
<style>
{`
@keyframes textAppear {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.5);
}
20% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
30% {
transform: translate(-50%, -50%) scale(1);
}
80% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
}
`}
</style>
{/* Confetti burst from region center */}
{!confettiComplete && (
<ConfettiBurst
type={celebration.type}
origin={regionCenter}
onComplete={handleConfettiComplete}
/>
)}
{/* Encouraging text for hard-earned finds */}
{celebration.type === 'hard-earned' && (
<div
className={css({
position: 'absolute',
left: '50%',
top: '40%',
transform: 'translate(-50%, -50%)',
fontSize: '2xl',
fontWeight: 'bold',
color: 'white',
textShadow: '0 2px 8px rgba(0,0,0,0.5), 0 0 20px rgba(251, 191, 36, 0.5)',
whiteSpace: 'nowrap',
})}
style={{
animation: `textAppear ${timing.totalDuration}ms ease-out forwards`,
}}
>
{message}
</div>
)}
</div>
)
}

View File

@ -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 (
<div
data-component="confetti"
className={css({
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 10000,
overflow: 'hidden',
})}
>
<style>
{`
@keyframes confettiFallback {
0% {
transform: translateY(0) rotate(0deg) scale(1);
opacity: 1;
}
40% {
transform: translateY(-80px) rotate(180deg) scale(1);
opacity: 1;
}
100% {
transform: translateY(30px) rotate(360deg) scale(0.3);
opacity: 0;
}
}
`}
</style>
{particles.map((particle) => (
<div
key={particle.id}
className={css({
position: 'absolute',
borderRadius: '2px',
})}
style={{
left: particle.x,
top: particle.y,
width: particle.size,
height: particle.size * 0.6,
backgroundColor: particle.color,
animation: `confettiFallback ${timing.confettiDuration}ms ease-out forwards`,
animationDelay: `${particle.delay}ms`,
transform: `rotate(${particle.rotation}deg)`,
}}
/>
))}
</div>
)
}
// 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 (
<div
data-component="confetti-burst"
className={css({
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 10000,
overflow: 'hidden',
})}
>
<style>
{`
@keyframes confettiMotion {
0% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
50% {
opacity: 1;
}
100% {
transform: translate(var(--offset-x), calc(var(--offset-y) + 60px)) rotate(360deg) scale(0.3);
opacity: 0;
}
}
`}
</style>
{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 (
<div
key={particle.id}
className={css({
position: 'absolute',
borderRadius: '2px',
})}
style={
{
left: particle.x,
top: particle.y,
width: particle.size,
height: particle.size * 0.6,
backgroundColor: particle.color,
'--offset-x': `${offsetX}px`,
'--offset-y': `${offsetY}px`,
animation: `confettiMotion ${timing.confettiDuration}ms ease-out ${particle.delay}ms forwards`,
} as React.CSSProperties
}
/>
)
})}
</div>
)
}

View File

@ -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 && (
<path
d={region.path}
fill={`rgba(255, 215, 0, ${0.2 + celebrationFlashProgress * 0.4})`}
stroke={`rgba(255, 215, 0, ${0.4 + celebrationFlashProgress * 0.6})`}
strokeWidth={10}
vectorEffect="non-scaling-stroke"
style={{ filter: 'blur(6px)' }}
pointerEvents="none"
/>
)}
{/* Network hover border (crisp outline in player color) */}
{networkHover && !isBeingRevealed && (
<path
@ -2422,10 +2575,10 @@ export function MapRenderer({
onMouseEnter={() => !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 (
<g key={`mag-${region.id}`} style={{ opacity: dimmedOpacity }}>
@ -3142,6 +3302,17 @@ export function MapRenderer({
style={{ filter: 'blur(2px)' }}
/>
)}
{/* Glow effect for celebrating region in magnifier */}
{isCelebrating && (
<path
d={region.path}
fill={`rgba(255, 215, 0, ${0.2 + celebrationFlashProgress * 0.4})`}
stroke={`rgba(255, 215, 0, ${0.4 + celebrationFlashProgress * 0.6})`}
strokeWidth={8}
vectorEffect="non-scaling-stroke"
style={{ filter: 'blur(4px)' }}
/>
)}
<path
d={region.path}
fill={fill}
@ -4301,6 +4472,16 @@ export function MapRenderer({
</>
)
})()}
{/* Celebration overlay - shows confetti and sound when region is found */}
{celebration && (
<CelebrationOverlay
celebration={celebration}
regionCenter={getCelebrationRegionCenter()}
onComplete={handleCelebrationComplete}
reducedMotion={false}
/>
)}
</div>
)
}

View File

@ -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<AudioContext | null>(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 }
}

View File

@ -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 }
}

View File

@ -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