diff --git a/apps/web/src/arcade-games/know-your-world/.claude/CELEBRATION_PLAN.md b/apps/web/src/arcade-games/know-your-world/.claude/CELEBRATION_PLAN.md new file mode 100644 index 00000000..01dcff65 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/.claude/CELEBRATION_PLAN.md @@ -0,0 +1,424 @@ +# Region Found Celebration Plan + +## Problem + +When a user correctly clicks on a region, the game immediately transitions to the next region with no celebration. Kids deserve positive reinforcement for correct answers! + +## Current Flow + +``` +User clicks region → onRegionClick(id, name) + → Provider.clickRegion() + → Validator: isCorrect? + → Add to regionsFound + → Set next currentPrompt + → State updates + → MapRenderer re-renders with new prompt +``` + +The transition is instant - no pause, no celebration, no feedback. + +## User Requirements + +1. ✅ Gold flash effect on found region +2. ✅ Confetti burst +3. ✅ Responsive to HOW they found it: + - **Fast find** → Quick, snappy celebration (reward speed) + - **Hard-earned find** → Bigger, more satisfying celebration (acknowledge effort) +4. ✅ Sound effect (NOT speech synthesis - that's for hot/cold) +5. ✅ Block advancement until celebration completes (map gets cluttered) +6. ❌ No streaks for now + +--- + +## Celebration Types + +### 1. Lightning Find ⚡ (< 3 seconds) +Kid knew exactly where to look - reward the speed! +- **Flash**: Quick, bright gold pulse (400ms) +- **Confetti**: Fast, sparkly burst (small particles, quick fade) +- **Sound**: Quick "ding!" or sparkle +- **Duration**: ~800ms total + +### 2. Standard Find ✨ (3-15 seconds, direct path) +Normal discovery - celebrate appropriately +- **Flash**: Smooth gold pulse (600ms) +- **Confetti**: Medium burst with gravity fall +- **Sound**: Pleasant chime +- **Duration**: ~1.2 seconds total + +### 3. Hard-Earned Find 💪 (searched extensively) +Kid really worked for it - acknowledge the effort! +- **Flash**: Warm, satisfying glow (800ms) +- **Confetti**: Big celebration! More particles, longer duration +- **Sound**: Triumphant fanfare/chord +- **Duration**: ~1.8 seconds total +- **Extra**: Maybe show encouraging text "You found it!" + +--- + +## Detecting Celebration Type + +### Data Available from Hot/Cold System + +The `useHotColdFeedback` hook already tracks: +```typescript +interface PathEntry { + x: number + y: number + timestamp: number + distance: number // Distance to target at this point +} + +// In HotColdState: +history: PathEntry[] // Circular buffer of recent positions +totalDistanceChange: number // Cumulative movement toward/away from target +minDistanceSinceLastFeedback // Got close then moved away? +``` + +### Metrics for Effort Detection + +```typescript +interface SearchMetrics { + // Time + timeToFind: number // ms from prompt start to correct click + + // Distance traveled + totalCursorDistance: number // Total pixels cursor moved (from history) + 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 + overshotCount: number // Times passed the target + + // Zone transitions + zoneTransitions: number // warming→cooling→warming transitions +} +``` + +### Classification Logic + +```typescript +function classifyCelebration(metrics: SearchMetrics): 'lightning' | 'standard' | 'hard-earned' { + // Lightning: Fast and direct + if (metrics.timeToFind < 3000 && metrics.searchEfficiency > 0.7) { + return 'lightning' + } + + // Hard-earned: Any of these indicate real effort + if ( + metrics.timeToFind > 20000 || // Took a while + metrics.searchEfficiency < 0.3 || // Wandered a lot + metrics.directionReversals > 10 || // Lots of back-and-forth + metrics.nearMissCount > 2 || // Got close multiple times + metrics.overshotCount > 1 // Passed it more than once + ) { + return 'hard-earned' + } + + return 'standard' +} +``` + +--- + +## New Flow with Celebration + +``` +User clicks region → onRegionClick(id, name) + → MapRenderer intercepts (is correct?) + → YES: Start celebration + - Calculate celebration type from search metrics + - Show flash + confetti + sound + - Block further clicks + - After animation complete: + → Call actual clickRegion() + → Advance to next prompt + → NO: Normal wrong-answer handling +``` + +### Key Change: Delay State Update + +Instead of immediately calling `clickRegion` and advancing, we: +1. Detect correct click locally in MapRenderer +2. Start celebration animation +3. Block input during celebration +4. Only AFTER celebration completes, send the click to the server/validator + +This ensures the map doesn't clutter with the next prompt while celebrating. + +--- + +## State Additions + +### Provider Context (client-side only) + +```typescript +// Add to Provider.tsx context +interface CelebrationState { + regionId: string + regionName: string + type: 'lightning' | 'standard' | 'hard-earned' + startTime: number +} + +const [celebration, setCelebration] = useState(null) +``` + +### Expose Search Metrics from Hot/Cold Hook + +```typescript +// Add to useHotColdFeedback return value +export function useHotColdFeedback(...) { + // ... existing code ... + + // New: expose metrics for celebration classification + const getSearchMetrics = useCallback((): SearchMetrics => { + const state = stateRef.current + const entries = getRecentEntries(state, HISTORY_LENGTH) + .filter((e): e is PathEntry => e !== null) + + // Calculate metrics from history... + return { + timeToFind: /* time since prompt started */, + totalCursorDistance: /* sum of distances between consecutive points */, + straightLineDistance: /* first point to target */, + searchEfficiency: /* straight / total */, + directionReversals: /* count sign changes in distance delta */, + nearMissCount: /* count entries with distance < CLOSE */, + overshotCount: /* from existing detection */, + zoneTransitions: /* count zone changes */, + } + }, []) + + return { + checkPosition, + reset, + isSpeaking, + lastFeedbackType, + getSearchMetrics, // NEW + } +} +``` + +--- + +## Sound Effects + +### Options for Sound Generation + +1. **Web Audio API** - Generate tones programmatically (no files needed) +2. **Audio files** - Pre-recorded sounds (more polished, adds to bundle) +3. **Tone.js library** - Rich audio synthesis (adds dependency) + +**Recommendation**: Web Audio API for MVP (no deps, small) + +### Sound Design + +```typescript +// useCelebrationSound.ts +const audioContext = new (window.AudioContext || window.webkitAudioContext)() + +function playLightningSound() { + // Quick sparkle: high frequency, fast decay + const osc = audioContext.createOscillator() + const gain = audioContext.createGain() + + osc.type = 'sine' + osc.frequency.setValueAtTime(1200, audioContext.currentTime) + osc.frequency.exponentialRampToValueAtTime(2400, audioContext.currentTime + 0.1) + + gain.gain.setValueAtTime(0.3, audioContext.currentTime) + gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2) + + osc.connect(gain).connect(audioContext.destination) + osc.start() + osc.stop(audioContext.currentTime + 0.2) +} + +function playStandardSound() { + // Pleasant chime: medium frequency, gentle decay + // Two-note chord for warmth +} + +function playHardEarnedSound() { + // Triumphant: ascending notes, longer sustain + // Maybe C-E-G chord arpeggio +} +``` + +--- + +## Visual Components + +### 1. Gold Flash Effect + +```typescript +// In MapRenderer, for the found region path +const isFlashing = celebration?.regionId === region.id + +// Animated fill using react-spring +const flashSpring = useSpring({ + glow: isFlashing ? 1 : 0, + config: { + duration: celebration?.type === 'lightning' ? 400 + : celebration?.type === 'hard-earned' ? 800 + : 600 + }, +}) + +// SVG path style +style={{ + fill: flashSpring.glow.to(g => + g > 0 ? `rgba(251, 191, 36, ${0.5 + g * 0.5})` : normalFill + ), + filter: flashSpring.glow.to(g => + g > 0 ? `drop-shadow(0 0 ${g * 15}px rgba(251, 191, 36, 0.8))` : 'none' + ), +}} +``` + +### 2. Confetti Component + +```typescript +// components/Confetti.tsx +interface ConfettiProps { + type: 'lightning' | 'standard' | 'hard-earned' + origin: { x: number; y: number } // Screen coordinates + onComplete: () => void +} + +const CONFETTI_CONFIG = { + lightning: { count: 12, duration: 600, spread: 60 }, + standard: { count: 20, duration: 1000, spread: 90 }, + 'hard-earned': { count: 35, duration: 1500, spread: 120 }, +} + +function Confetti({ type, origin, onComplete }: ConfettiProps) { + const config = CONFETTI_CONFIG[type] + + // Generate particles with random directions, colors, sizes + // Use CSS animations for performance + // Call onComplete when last particle finishes +} +``` + +### 3. Celebration Overlay + +```typescript +// components/CelebrationOverlay.tsx +interface CelebrationOverlayProps { + celebration: CelebrationState + regionCenter: { x: number; y: number } + onComplete: () => void +} + +function CelebrationOverlay({ celebration, regionCenter, onComplete }: CelebrationOverlayProps) { + const [phase, setPhase] = useState<'active' | 'complete'>('active') + + // Track when all animations complete + const handleConfettiComplete = useCallback(() => { + setPhase('complete') + onComplete() + }, [onComplete]) + + return ( +
+ + + {celebration.type === 'hard-earned' && ( + + )} +
+ ) +} +``` + +--- + +## Files to Create/Modify + +### New Files +1. **`hooks/useCelebrationSound.ts`** - Web Audio API sound effects +2. **`hooks/useSearchMetrics.ts`** - Extract metrics from hot/cold history +3. **`components/Confetti.tsx`** - CSS confetti particles +4. **`components/CelebrationOverlay.tsx`** - Orchestrates celebration + +### Modified Files +1. **`Provider.tsx`** - Add celebration state to context +2. **`hooks/useHotColdFeedback.ts`** - Expose `getSearchMetrics()` method +3. **`MapRenderer.tsx`** - Intercept correct clicks, trigger celebration, delay advancement + +--- + +## Implementation Order + +### Phase 1: Infrastructure +1. Add `celebration` state to Provider context +2. Add `promptStartTime` tracking (when each region prompt begins) +3. Modify `useHotColdFeedback` to expose `getSearchMetrics()` + +### Phase 2: Classification +4. Create `useSearchMetrics` hook to calculate metrics +5. Implement `classifyCelebration()` function +6. Test metric calculation with various search patterns + +### Phase 3: Visuals +7. Create `Confetti` component with CSS animations +8. Add gold flash effect to MapRenderer (react-spring) +9. Create `CelebrationOverlay` to orchestrate + +### Phase 4: Audio +10. Create `useCelebrationSound` hook with Web Audio API +11. Implement three sound types (lightning, standard, hard-earned) +12. Wire up sounds to celebration types + +### Phase 5: Integration +13. Intercept correct clicks in MapRenderer +14. Block advancement during celebration +15. Call `clickRegion` only after celebration completes + +### Phase 6: Polish +16. Add `prefers-reduced-motion` support +17. Test on mobile (performance, touch) +18. Fine-tune timing and particle counts + +--- + +## Accessibility + +1. **Reduced Motion**: `@media (prefers-reduced-motion: reduce)` + - Skip confetti entirely + - Use simple color change instead of flash + - Sound still plays (audio is separate preference) + +2. **Sound Preferences**: Respect system mute / browser audio block + - Don't crash if AudioContext is blocked + - Degrade gracefully + +--- + +## Performance Budget + +- Confetti particles: Max 35 (hard-earned), CSS-animated +- Flash effect: Single react-spring animation +- Sound: Single Web Audio oscillator (or two for chords) +- No canvas, no heavy libraries + +--- + +## Timing Summary + +| Type | Flash | Confetti | Sound | Total Block | +|------|-------|----------|-------|-------------| +| Lightning ⚡ | 400ms | 600ms | 200ms | 600ms | +| Standard ✨ | 600ms | 1000ms | 400ms | 1000ms | +| Hard-earned 💪 | 800ms | 1500ms | 600ms | 1500ms | diff --git a/apps/web/src/arcade-games/know-your-world/maps.ts b/apps/web/src/arcade-games/know-your-world/maps.ts index 24c0b3dc..028629d5 100644 --- a/apps/web/src/arcade-games/know-your-world/maps.ts +++ b/apps/web/src/arcade-games/know-your-world/maps.ts @@ -1633,6 +1633,274 @@ export function shouldIncludeRegion(regionId: string, includeSizes: RegionSize[] return includeSizes.includes(category) } +/** + * Parse SVG path into subpaths (separated by M commands, closed by Z) + * Returns array of point arrays, one per subpath + */ +function parsePathToSubpaths(pathString: string): Array> { + const subpaths: Array> = [] + let currentSubpath: Array<[number, number]> = [] + let currentX = 0 + let currentY = 0 + let subpathStartX = 0 + let subpathStartY = 0 + + const commandRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g + let match + + while ((match = commandRegex.exec(pathString)) !== null) { + const command = match[1] + const params = + match[2] + .trim() + .match(/-?\d+\.?\d*/g) + ?.map(Number) || [] + + switch (command) { + case 'M': + if (currentSubpath.length > 0) subpaths.push(currentSubpath) + currentSubpath = [] + for (let i = 0; i < params.length - 1; i += 2) { + currentX = params[i] + currentY = params[i + 1] + if (i === 0) { + subpathStartX = currentX + subpathStartY = currentY + } + currentSubpath.push([currentX, currentY]) + } + break + case 'm': + if (currentSubpath.length > 0) subpaths.push(currentSubpath) + currentSubpath = [] + for (let i = 0; i < params.length - 1; i += 2) { + currentX += params[i] + currentY += params[i + 1] + if (i === 0) { + subpathStartX = currentX + subpathStartY = currentY + } + currentSubpath.push([currentX, currentY]) + } + break + case 'L': + for (let i = 0; i < params.length - 1; i += 2) { + currentX = params[i] + currentY = params[i + 1] + currentSubpath.push([currentX, currentY]) + } + break + case 'l': + for (let i = 0; i < params.length - 1; i += 2) { + currentX += params[i] + currentY += params[i + 1] + currentSubpath.push([currentX, currentY]) + } + break + case 'H': + for (const x of params) { + currentX = x + currentSubpath.push([currentX, currentY]) + } + break + case 'h': + for (const dx of params) { + currentX += dx + currentSubpath.push([currentX, currentY]) + } + break + case 'V': + for (const y of params) { + currentY = y + currentSubpath.push([currentX, currentY]) + } + break + case 'v': + for (const dy of params) { + currentY += dy + currentSubpath.push([currentX, currentY]) + } + break + case 'C': + for (let i = 0; i < params.length - 5; i += 6) { + currentX = params[i + 4] + currentY = params[i + 5] + currentSubpath.push([currentX, currentY]) + } + break + case 'c': + for (let i = 0; i < params.length - 5; i += 6) { + currentX += params[i + 4] + currentY += params[i + 5] + currentSubpath.push([currentX, currentY]) + } + break + case 'S': + for (let i = 0; i < params.length - 3; i += 4) { + currentX = params[i + 2] + currentY = params[i + 3] + currentSubpath.push([currentX, currentY]) + } + break + case 's': + for (let i = 0; i < params.length - 3; i += 4) { + currentX += params[i + 2] + currentY += params[i + 3] + currentSubpath.push([currentX, currentY]) + } + break + case 'Q': + for (let i = 0; i < params.length - 3; i += 4) { + currentX = params[i + 2] + currentY = params[i + 3] + currentSubpath.push([currentX, currentY]) + } + break + case 'q': + for (let i = 0; i < params.length - 3; i += 4) { + currentX += params[i + 2] + currentY += params[i + 3] + currentSubpath.push([currentX, currentY]) + } + break + case 'T': + for (let i = 0; i < params.length - 1; i += 2) { + currentX = params[i] + currentY = params[i + 1] + currentSubpath.push([currentX, currentY]) + } + break + case 't': + for (let i = 0; i < params.length - 1; i += 2) { + currentX += params[i] + currentY += params[i + 1] + currentSubpath.push([currentX, currentY]) + } + break + case 'A': + for (let i = 0; i < params.length - 6; i += 7) { + currentX = params[i + 5] + currentY = params[i + 6] + currentSubpath.push([currentX, currentY]) + } + break + case 'a': + for (let i = 0; i < params.length - 6; i += 7) { + currentX += params[i + 5] + currentY += params[i + 6] + currentSubpath.push([currentX, currentY]) + } + break + case 'Z': + case 'z': + currentX = subpathStartX + currentY = subpathStartY + if (currentSubpath.length > 0) { + subpaths.push(currentSubpath) + currentSubpath = [] + } + break + } + } + + if (currentSubpath.length > 0) subpaths.push(currentSubpath) + return subpaths +} + +/** + * Calculate polygon area using shoelace formula + */ +function calculatePolygonArea(points: Array<[number, number]>): number { + if (points.length < 3) return 0 + let area = 0 + for (let i = 0; i < points.length; i++) { + const j = (i + 1) % points.length + area += points[i][0] * points[j][1] + area -= points[j][0] * points[i][1] + } + return Math.abs(area) / 2 +} + +/** + * Calculate centroid of a polygon using shoelace formula + */ +function calculatePolygonCentroid(points: Array<[number, number]>): [number, number] { + if (points.length === 0) return [0, 0] + if (points.length < 3) { + const avgX = points.reduce((s, p) => s + p[0], 0) / points.length + const avgY = points.reduce((s, p) => s + p[1], 0) / points.length + return [avgX, avgY] + } + + let signedArea = 0 + let cx = 0 + let cy = 0 + + for (let i = 0; i < points.length; i++) { + const j = (i + 1) % points.length + const cross = points[i][0] * points[j][1] - points[j][0] * points[i][1] + signedArea += cross + cx += (points[i][0] + points[j][0]) * cross + cy += (points[i][1] + points[j][1]) * cross + } + + signedArea /= 2 + if (Math.abs(signedArea) < 0.0001) { + const avgX = points.reduce((s, p) => s + p[0], 0) / points.length + const avgY = points.reduce((s, p) => s + p[1], 0) / points.length + return [avgX, avgY] + } + + return [cx / (6 * signedArea), cy / (6 * signedArea)] +} + +/** + * Get the bounding box center of the largest closed subpath in an SVG path. + * Uses bounding box center (not geometric centroid) for more visually predictable results + * on elongated shapes like Norway or Chile. + * Falls back to overall path bounding box center if subpath parsing fails. + */ +export function getLargestSubpathCentroid(pathString: string): { x: number; y: number } | null { + const subpaths = parsePathToSubpaths(pathString) + + // If no subpaths found, return null to signal fallback should be used + if (subpaths.length === 0) { + return null + } + + // Find largest subpath by area + let largestSubpath = subpaths[0] + let largestArea = calculatePolygonArea(largestSubpath) + + for (let i = 1; i < subpaths.length; i++) { + const area = calculatePolygonArea(subpaths[i]) + if (area > largestArea) { + largestArea = area + largestSubpath = subpaths[i] + } + } + + // If largest subpath is empty or has no area, return null + if (largestSubpath.length === 0 || largestArea === 0) { + return null + } + + // Calculate bounding box center of largest subpath + let minX = largestSubpath[0][0] + let maxX = largestSubpath[0][0] + let minY = largestSubpath[0][1] + let maxY = largestSubpath[0][1] + + for (const [x, y] of largestSubpath) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + + return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 } +} + /** * Calculate the centroid (center of mass) of an SVG path * Properly parses SVG path commands to extract endpoint coordinates only