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'
|
'use client'
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
buildPlayerMetadata,
|
buildPlayerMetadata,
|
||||||
useArcadeSession,
|
useArcadeSession,
|
||||||
|
|
@ -49,6 +49,16 @@ export interface ControlsState {
|
||||||
onAutoHintToggle: () => void
|
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 = {
|
const defaultControlsState: ControlsState = {
|
||||||
isPointerLocked: false,
|
isPointerLocked: false,
|
||||||
fakeCursorPosition: null,
|
fakeCursorPosition: null,
|
||||||
|
|
@ -125,6 +135,11 @@ interface KnowYourWorldContextValue {
|
||||||
isInTakeover: boolean
|
isInTakeover: boolean
|
||||||
setIsInTakeover: React.Dispatch<React.SetStateAction<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
|
// Shared container ref for pointer lock button detection
|
||||||
sharedContainerRef: React.RefObject<HTMLDivElement>
|
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)
|
// Learning mode takeover state (set by GameInfoPanel, read by MapRenderer)
|
||||||
const [isInTakeover, setIsInTakeover] = useState(false)
|
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
|
// Shared container ref for pointer lock button detection
|
||||||
const sharedContainerRef = useRef<HTMLDivElement>(null)
|
const sharedContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
|
@ -233,6 +252,13 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||||
applyMove: (state) => state, // Server handles all state updates
|
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
|
// Pass through cursor updates with the provided player ID and userId
|
||||||
const sendCursorUpdate = useCallback(
|
const sendCursorUpdate = useCallback(
|
||||||
(
|
(
|
||||||
|
|
@ -545,6 +571,9 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||||
setControlsState,
|
setControlsState,
|
||||||
isInTakeover,
|
isInTakeover,
|
||||||
setIsInTakeover,
|
setIsInTakeover,
|
||||||
|
celebration,
|
||||||
|
setCelebration,
|
||||||
|
promptStartTime,
|
||||||
sharedContainerRef,
|
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,
|
calculateScreenPixelRatio,
|
||||||
isAboveThreshold,
|
isAboveThreshold,
|
||||||
} from '../utils/screenPixelRatio'
|
} from '../utils/screenPixelRatio'
|
||||||
|
import { classifyCelebration, CELEBRATION_TIMING } from '../utils/celebration'
|
||||||
|
import { CelebrationOverlay } from './CelebrationOverlay'
|
||||||
import { DevCropTool } from './DevCropTool'
|
import { DevCropTool } from './DevCropTool'
|
||||||
|
|
||||||
// Debug flag: show technical info in magnifier (dev only)
|
// Debug flag: show technical info in magnifier (dev only)
|
||||||
|
|
@ -355,7 +357,14 @@ export function MapRenderer({
|
||||||
mapName,
|
mapName,
|
||||||
}: MapRendererProps) {
|
}: MapRendererProps) {
|
||||||
// Get context for sharing state with GameInfoPanel
|
// 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
|
// Extract force tuning parameters with defaults
|
||||||
const {
|
const {
|
||||||
showArrows = false,
|
showArrows = false,
|
||||||
|
|
@ -501,6 +510,10 @@ export function MapRenderer({
|
||||||
// Hint animation state
|
// Hint animation state
|
||||||
const [hintFlashProgress, setHintFlashProgress] = useState(0) // 0-1 pulsing value
|
const [hintFlashProgress, setHintFlashProgress] = useState(0) // 0-1 pulsing value
|
||||||
const [isHintAnimating, setIsHintAnimating] = useState(false) // Track if animation in progress
|
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
|
// Saved button position to prevent jumping during zoom animation
|
||||||
const [savedButtonPosition, setSavedButtonPosition] = useState<{
|
const [savedButtonPosition, setSavedButtonPosition] = useState<{
|
||||||
top: number
|
top: number
|
||||||
|
|
@ -709,6 +722,7 @@ export function MapRenderer({
|
||||||
checkPosition: checkHotCold,
|
checkPosition: checkHotCold,
|
||||||
reset: resetHotCold,
|
reset: resetHotCold,
|
||||||
lastFeedbackType: hotColdFeedbackType,
|
lastFeedbackType: hotColdFeedbackType,
|
||||||
|
getSearchMetrics,
|
||||||
} = useHotColdFeedback({
|
} = useHotColdFeedback({
|
||||||
enabled: assistanceAllowsHotCold && hotColdEnabled && hasFinePointer,
|
enabled: assistanceAllowsHotCold && hotColdEnabled && hasFinePointer,
|
||||||
targetRegionId: currentPrompt,
|
targetRegionId: currentPrompt,
|
||||||
|
|
@ -847,11 +861,11 @@ export function MapRenderer({
|
||||||
// Use the same detection logic as hover tracking (50px detection box)
|
// Use the same detection logic as hover tracking (50px detection box)
|
||||||
const { detectedRegions, regionUnderCursor } = detectRegions(cursorX, cursorY)
|
const { detectedRegions, regionUnderCursor } = detectRegions(cursorX, cursorY)
|
||||||
|
|
||||||
if (regionUnderCursor) {
|
if (regionUnderCursor && !celebration) {
|
||||||
// Find the region data to get the name
|
// Find the region data to get the name
|
||||||
const region = mapData.regions.find((r) => r.id === regionUnderCursor)
|
const region = mapData.regions.find((r) => r.id === regionUnderCursor)
|
||||||
if (region) {
|
if (region) {
|
||||||
onRegionClick(regionUnderCursor, region.name)
|
handleRegionClickWithCelebration(regionUnderCursor, region.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1183,6 +1197,122 @@ export function MapRenderer({
|
||||||
}
|
}
|
||||||
}, [hintActive?.timestamp]) // Re-run when timestamp changes
|
}, [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
|
// Keyboard shortcuts - Shift for magnifier, H for hint
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|
@ -2234,16 +2364,23 @@ export function MapRenderer({
|
||||||
// Run region detection at the tap position
|
// Run region detection at the tap position
|
||||||
const { regionUnderCursor } = detectRegions(tapContainerX, tapContainerY)
|
const { regionUnderCursor } = detectRegions(tapContainerX, tapContainerY)
|
||||||
|
|
||||||
if (regionUnderCursor) {
|
if (regionUnderCursor && !celebration) {
|
||||||
const region = mapData.regions.find((r) => r.id === regionUnderCursor)
|
const region = mapData.regions.find((r) => r.id === regionUnderCursor)
|
||||||
if (region) {
|
if (region) {
|
||||||
onRegionClick(regionUnderCursor, region.name)
|
handleRegionClickWithCelebration(regionUnderCursor, region.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[detectRegions, mapData.regions, onRegionClick, displayViewBox, zoomSpring]
|
[
|
||||||
|
detectRegions,
|
||||||
|
mapData.regions,
|
||||||
|
handleRegionClickWithCelebration,
|
||||||
|
celebration,
|
||||||
|
displayViewBox,
|
||||||
|
zoomSpring,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -2330,29 +2467,33 @@ export function MapRenderer({
|
||||||
const playerId = !isExcluded && isFound ? getPlayerWhoFoundRegion(region.id) : null
|
const playerId = !isExcluded && isFound ? getPlayerWhoFoundRegion(region.id) : null
|
||||||
const isBeingRevealed = giveUpReveal?.regionId === region.id
|
const isBeingRevealed = giveUpReveal?.regionId === region.id
|
||||||
const isBeingHinted = hintActive?.regionId === region.id
|
const isBeingHinted = hintActive?.regionId === region.id
|
||||||
|
const isCelebrating = celebration?.regionId === region.id
|
||||||
|
|
||||||
// Special styling for excluded regions (grayed out, pre-labeled)
|
// Special styling for excluded regions (grayed out, pre-labeled)
|
||||||
// Bright gold flash for give up reveal with high contrast
|
// Bright gold flash for give up reveal, celebration, and hint
|
||||||
// Cyan flash for hint
|
const fill = isCelebrating
|
||||||
const fill = isBeingRevealed
|
? `rgba(255, 215, 0, ${0.7 + celebrationFlashProgress * 0.3})` // Bright gold celebration flash
|
||||||
? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` // Brighter gold, higher base opacity
|
: isBeingRevealed
|
||||||
: isExcluded
|
? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` // Brighter gold, higher base opacity
|
||||||
? isDark
|
: isExcluded
|
||||||
? '#374151' // gray-700
|
? isDark
|
||||||
: '#d1d5db' // gray-300
|
? '#374151' // gray-700
|
||||||
: isFound && playerId
|
: '#d1d5db' // gray-300
|
||||||
? `url(#player-pattern-${playerId})`
|
: isFound && playerId
|
||||||
: getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark)
|
? `url(#player-pattern-${playerId})`
|
||||||
|
: getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark)
|
||||||
|
|
||||||
// During give-up animation, dim all non-revealed regions
|
// During give-up animation, dim all non-revealed regions
|
||||||
const dimmedOpacity = isGiveUpAnimating && !isBeingRevealed ? 0.25 : 1
|
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
|
// Unfound regions get thicker borders for better visibility against sea
|
||||||
const stroke = isBeingRevealed
|
const stroke = isCelebrating
|
||||||
? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})` // Orange stroke for contrast
|
? `rgba(255, 180, 0, ${0.8 + celebrationFlashProgress * 0.2})` // Gold stroke for celebration
|
||||||
: getRegionStroke(isFound, isDark)
|
: isBeingRevealed
|
||||||
const strokeWidth = isBeingRevealed ? 3 : isFound ? 1 : 1.5
|
? `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
|
// Check if a network cursor is hovering over this region
|
||||||
const networkHover = networkHoveredRegions[region.id]
|
const networkHover = networkHoveredRegions[region.id]
|
||||||
|
|
@ -2395,6 +2536,18 @@ export function MapRenderer({
|
||||||
pointerEvents="none"
|
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) */}
|
{/* Network hover border (crisp outline in player color) */}
|
||||||
{networkHover && !isBeingRevealed && (
|
{networkHover && !isBeingRevealed && (
|
||||||
<path
|
<path
|
||||||
|
|
@ -2422,10 +2575,10 @@ export function MapRenderer({
|
||||||
onMouseEnter={() => !isExcluded && !pointerLocked && setHoveredRegion(region.id)}
|
onMouseEnter={() => !isExcluded && !pointerLocked && setHoveredRegion(region.id)}
|
||||||
onMouseLeave={() => !pointerLocked && setHoveredRegion(null)}
|
onMouseLeave={() => !pointerLocked && setHoveredRegion(null)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isExcluded) {
|
if (!isExcluded && !celebration) {
|
||||||
onRegionClick(region.id, region.name)
|
handleRegionClickWithCelebration(region.id, region.name)
|
||||||
}
|
}
|
||||||
}} // Disable clicks on excluded regions
|
}} // Disable clicks on excluded regions and during celebration
|
||||||
style={{
|
style={{
|
||||||
cursor: isExcluded ? 'default' : 'pointer',
|
cursor: isExcluded ? 'default' : 'pointer',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
|
|
@ -2758,7 +2911,9 @@ export function MapRenderer({
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
}}
|
}}
|
||||||
onClick={() => onRegionClick(label.regionId, label.regionName)}
|
onClick={() =>
|
||||||
|
!celebration && handleRegionClickWithCelebration(label.regionId, label.regionName)
|
||||||
|
}
|
||||||
onMouseEnter={() => setHoveredRegion(label.regionId)}
|
onMouseEnter={() => setHoveredRegion(label.regionId)}
|
||||||
onMouseLeave={() => setHoveredRegion(null)}
|
onMouseLeave={() => setHoveredRegion(null)}
|
||||||
>
|
>
|
||||||
|
|
@ -3111,23 +3266,28 @@ export function MapRenderer({
|
||||||
const isFound = regionsFound.includes(region.id)
|
const isFound = regionsFound.includes(region.id)
|
||||||
const playerId = isFound ? getPlayerWhoFoundRegion(region.id) : null
|
const playerId = isFound ? getPlayerWhoFoundRegion(region.id) : null
|
||||||
const isBeingRevealed = giveUpReveal?.regionId === region.id
|
const isBeingRevealed = giveUpReveal?.regionId === region.id
|
||||||
|
const isCelebrating = celebration?.regionId === region.id
|
||||||
|
|
||||||
// Bright gold flash for give up reveal in magnifier too
|
// Bright gold flash for celebration and give up reveal in magnifier too
|
||||||
const fill = isBeingRevealed
|
const fill = isCelebrating
|
||||||
? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})`
|
? `rgba(255, 215, 0, ${0.7 + celebrationFlashProgress * 0.3})`
|
||||||
: isFound && playerId
|
: isBeingRevealed
|
||||||
? `url(#player-pattern-${playerId})`
|
? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})`
|
||||||
: getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark)
|
: isFound && playerId
|
||||||
|
? `url(#player-pattern-${playerId})`
|
||||||
|
: getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark)
|
||||||
|
|
||||||
// During give-up animation, dim all non-revealed regions
|
// During give-up animation, dim all non-revealed regions
|
||||||
const dimmedOpacity = isGiveUpAnimating && !isBeingRevealed ? 0.25 : 1
|
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
|
// Unfound regions get thicker borders for better visibility against sea
|
||||||
const stroke = isBeingRevealed
|
const stroke = isCelebrating
|
||||||
? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})`
|
? `rgba(255, 180, 0, ${0.8 + celebrationFlashProgress * 0.2})`
|
||||||
: getRegionStroke(isFound, isDark)
|
: isBeingRevealed
|
||||||
const strokeWidth = isBeingRevealed ? 2 : isFound ? 0.5 : 1
|
? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})`
|
||||||
|
: getRegionStroke(isFound, isDark)
|
||||||
|
const strokeWidth = isCelebrating ? 3 : isBeingRevealed ? 2 : isFound ? 0.5 : 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={`mag-${region.id}`} style={{ opacity: dimmedOpacity }}>
|
<g key={`mag-${region.id}`} style={{ opacity: dimmedOpacity }}>
|
||||||
|
|
@ -3142,6 +3302,17 @@ export function MapRenderer({
|
||||||
style={{ filter: 'blur(2px)' }}
|
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
|
<path
|
||||||
d={region.path}
|
d={region.path}
|
||||||
fill={fill}
|
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>
|
</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
|
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 {
|
interface CheckPositionParams {
|
||||||
cursorPosition: { x: number; y: number }
|
cursorPosition: { x: number; y: number }
|
||||||
targetCenter: { x: number; y: number } | null
|
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