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:
parent
9f6b425daf
commit
3b9d6b0fdf
|
|
@ -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,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue