feat(know-your-world): improve takeover UI and fix celebration sound bug

UI Improvements:
- Add frosted glass backdrop + enhanced text shadow for region name contrast
- Heat effect on region outline: blue → purple → orange → gold as letters typed
- Puzzle piece now uses gold styling to match map celebration
- Consistent gold styling from takeover through to map celebration

Bug Fix:
- Fix celebration sound playing 30+ times layered on top of each other
- Remove duplicate sound trigger from CelebrationOverlay (MusicContext handles it)
- Use celebration.startTime as stable identifier to prevent re-triggers
- Track last played celebration in ref to ensure single playback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-01 10:14:16 -06:00
parent 1e6153ee8b
commit a8c6b84855
3 changed files with 95 additions and 38 deletions

View File

@ -48,22 +48,14 @@ export function CelebrationOverlay({
() => HARD_EARNED_MESSAGES[Math.floor(Math.random() * HARD_EARNED_MESSAGES.length)]
)
// Play celebration sound via Strudel (if music is available and playing)
// NOTE: Celebration sound is handled by MusicContext (via the celebration prop)
// We don't play it here to avoid duplicate sounds
useEffect(() => {
console.log('[CelebrationOverlay] Celebration triggered:', {
console.log('[CelebrationOverlay] Celebration rendered:', {
type: celebration.type,
musicAvailable: !!music,
isPlaying: music?.isPlaying,
isInitialized: music?.isInitialized,
isMuted: music?.isMuted,
startTime: celebration.startTime,
})
if (music?.isPlaying) {
console.log('[CelebrationOverlay] Calling music.playCelebration...')
music.playCelebration(celebration.type)
} else {
console.log('[CelebrationOverlay] Music not playing, skipping celebration sound')
}
}, [music, celebration.type])
}, [celebration.type, celebration.startTime])
// Handle confetti completion
const handleConfettiComplete = useCallback(() => {

View File

@ -3,7 +3,7 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { css } from '@styled/css'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useSpring, animated } from '@react-spring/web'
import { useSpring, animated, to } from '@react-spring/web'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { useTheme } from '@/contexts/ThemeContext'
import {
@ -519,6 +519,30 @@ export function GameInfoPanel({
// Exponential curve for more dramatic pickup (x^3 gives steep curve at the end)
const exponentialIntensity = Math.pow(tracerIntensity, 2.5)
// Heating color interpolation: blue → purple → orange → gold
// Gold at the end matches the celebration styling on the map
const heatStops = [
{ t: 0, r: 59, g: 130, b: 246 }, // Cool blue
{ t: 0.33, r: 168, g: 85, b: 247 }, // Purple (warming)
{ t: 0.66, r: 249, g: 115, b: 22 }, // Orange (hot)
{ t: 1, r: 255, g: 215, b: 0 }, // Gold (matches celebration)
]
// Find which segment we're in and interpolate
const t = tracerIntensity
let heatColors = { r: 59, g: 130, b: 246 }
for (let i = 0; i < heatStops.length - 1; i++) {
if (t >= heatStops[i].t && t <= heatStops[i + 1].t) {
const segmentT = (t - heatStops[i].t) / (heatStops[i + 1].t - heatStops[i].t)
heatColors = {
r: heatStops[i].r + segmentT * (heatStops[i + 1].r - heatStops[i].r),
g: heatStops[i].g + segmentT * (heatStops[i + 1].g - heatStops[i].g),
b: heatStops[i].b + segmentT * (heatStops[i + 1].b - heatStops[i].b),
}
break
}
}
const tracerSpring = useSpring({
// Size multiplier: 1.5 (big) → 0.3 (laser-focused)
sizeScale: 1.5 - exponentialIntensity * 1.2,
@ -531,6 +555,14 @@ export function GameInfoPanel({
// Speed multiplier for duration calculation: 1 (slow) → 200 (blazing fast)
// Using exponential curve: 1 → 5 → 40 → 200
speedMultiplier: 1 + exponentialIntensity * 199,
// Heating border colors (RGB components for smooth interpolation)
heatR: heatColors.r,
heatG: heatColors.g,
heatB: heatColors.b,
// Stroke width increases as it heats up: 2 → 4
strokeWidth: 2 + tracerIntensity * 2,
// Fill opacity increases slightly as it heats up
fillOpacity: 0.3 + tracerIntensity * 0.2,
config: { tension: 180, friction: 18 },
})
@ -1017,13 +1049,25 @@ export function GameInfoPanel({
</radialGradient>
</defs>
{/* Region fill and stroke */}
<path
{/* Region fill and stroke - heats up as letters are typed */}
<animated.path
id="region-outline-path"
d={displayRegionPath}
fill={isDark ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.35)'}
stroke={isDark ? '#3b82f6' : '#2563eb'}
strokeWidth={2}
fill={to(
[
tracerSpring.heatR,
tracerSpring.heatG,
tracerSpring.heatB,
tracerSpring.fillOpacity,
],
(r, g, b, opacity) =>
`rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${opacity})`
)}
stroke={to(
[tracerSpring.heatR, tracerSpring.heatG, tracerSpring.heatB],
(r, g, b) => `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`
)}
strokeWidth={tracerSpring.strokeWidth}
vectorEffect="non-scaling-stroke"
/>
@ -1104,11 +1148,7 @@ export function GameInfoPanel({
{Array.from({ length: sparkCount }, (_, i) => {
const startOffset = i / sparkCount // Distribute evenly around the path
return (
<circle
key={`spark-${pathIdx}-${i}`}
r={tracerSize * 0.15}
fill="#ffeb3b"
>
<circle key={`spark-${pathIdx}-${i}`} r={tracerSize * 0.15} fill="#ffeb3b">
<animateMotion
dur={`${tracerDuration}s`}
repeatCount="indefinite"
@ -1133,6 +1173,7 @@ export function GameInfoPanel({
})()}
{/* Animated puzzle piece silhouette - flies from center to map position */}
{/* Uses gold styling to match celebration on the map */}
{puzzlePieceShape && isPuzzlePieceAnimating && (
<animated.svg
data-element="puzzle-piece-silhouette"
@ -1151,9 +1192,9 @@ export function GameInfoPanel({
>
<path
d={puzzlePieceShape.path}
fill={isDark ? 'rgba(59, 130, 246, 0.8)' : 'rgba(59, 130, 246, 0.6)'}
stroke={isDark ? '#3b82f6' : '#2563eb'}
strokeWidth={2}
fill="rgba(255, 215, 0, 0.7)"
stroke="rgba(255, 180, 0, 1)"
strokeWidth={3}
vectorEffect="non-scaling-stroke"
/>
</animated.svg>
@ -1192,9 +1233,23 @@ export function GameInfoPanel({
alignItems: 'center',
justifyContent: 'center',
gap: { base: '1', sm: '2' },
// Frosted glass backdrop for better contrast
background: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.5)',
backdropFilter: 'blur(8px)',
padding: '8px 16px',
borderRadius: '8px',
})}
style={{
textShadow: isDark ? '0 2px 4px rgba(0,0,0,0.5)' : '0 2px 4px rgba(0,0,0,0.2)',
// Enhanced multi-layer text shadow for halo effect
textShadow: isDark
? `0 0 8px rgba(0,0,0,0.9),
0 0 16px rgba(0,0,0,0.6),
0 2px 4px rgba(0,0,0,0.9),
-1px -1px 0 rgba(0,0,0,0.6),
1px -1px 0 rgba(0,0,0,0.6),
-1px 1px 0 rgba(0,0,0,0.6),
1px 1px 0 rgba(0,0,0,0.6)`
: '0 2px 4px rgba(0,0,0,0.2)',
}}
>
{displayFlagEmoji && (

View File

@ -66,7 +66,7 @@ interface MusicProviderProps {
/** Current hot/cold feedback type */
hotColdFeedback?: FeedbackType | null
/** Current celebration state */
celebration?: { type: CelebrationType } | null
celebration?: { type: CelebrationType; startTime: number } | null
}
/**
@ -277,6 +277,7 @@ export function MusicProvider({
// Web Audio context for standalone celebrations (when Strudel music is off)
const audioContextRef = useRef<AudioContext | null>(null)
const lastCelebrationStartTimeRef = useRef<number | null>(null)
// Play a Web Audio celebration (reliable one-shot sounds)
const playWebAudioCelebration = useCallback((type: CelebrationType) => {
@ -406,17 +407,26 @@ export function MusicProvider({
[engine, currentPresetId, clearCelebrationTimer, playWebAudioCelebration]
)
// React to celebration prop changes
// React to celebration prop changes - use startTime as stable identifier
// to prevent duplicate plays from object reference changes
const celebrationStartTime = celebration?.startTime
const celebrationType = celebration?.type
useEffect(() => {
console.log('[MusicContext] celebration prop effect:', {
celebrationType: celebration?.type,
celebration,
})
if (celebration?.type) {
console.log('[MusicContext] Triggering celebration from prop change')
playCelebration(celebration.type)
// Guard: only play if we have a new celebration (different startTime)
if (
celebrationType &&
celebrationStartTime &&
celebrationStartTime !== lastCelebrationStartTimeRef.current
) {
console.log('[MusicContext] celebration prop effect - NEW celebration:', {
celebrationType,
celebrationStartTime,
lastStartTime: lastCelebrationStartTimeRef.current,
})
lastCelebrationStartTimeRef.current = celebrationStartTime
playCelebration(celebrationType)
}
}, [celebration, playCelebration])
}, [celebrationStartTime, celebrationType, playCelebration])
// Cleanup on unmount
useEffect(() => {