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)] () => 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(() => { useEffect(() => {
console.log('[CelebrationOverlay] Celebration triggered:', { console.log('[CelebrationOverlay] Celebration rendered:', {
type: celebration.type, type: celebration.type,
musicAvailable: !!music, startTime: celebration.startTime,
isPlaying: music?.isPlaying,
isInitialized: music?.isInitialized,
isMuted: music?.isMuted,
}) })
if (music?.isPlaying) { }, [celebration.type, celebration.startTime])
console.log('[CelebrationOverlay] Calling music.playCelebration...')
music.playCelebration(celebration.type)
} else {
console.log('[CelebrationOverlay] Music not playing, skipping celebration sound')
}
}, [music, celebration.type])
// Handle confetti completion // Handle confetti completion
const handleConfettiComplete = useCallback(() => { const handleConfettiComplete = useCallback(() => {

View File

@ -3,7 +3,7 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { css } from '@styled/css' import { css } from '@styled/css'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' 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 { useViewerId } from '@/lib/arcade/game-sdk'
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
import { import {
@ -519,6 +519,30 @@ export function GameInfoPanel({
// Exponential curve for more dramatic pickup (x^3 gives steep curve at the end) // Exponential curve for more dramatic pickup (x^3 gives steep curve at the end)
const exponentialIntensity = Math.pow(tracerIntensity, 2.5) 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({ const tracerSpring = useSpring({
// Size multiplier: 1.5 (big) → 0.3 (laser-focused) // Size multiplier: 1.5 (big) → 0.3 (laser-focused)
sizeScale: 1.5 - exponentialIntensity * 1.2, sizeScale: 1.5 - exponentialIntensity * 1.2,
@ -531,6 +555,14 @@ export function GameInfoPanel({
// Speed multiplier for duration calculation: 1 (slow) → 200 (blazing fast) // Speed multiplier for duration calculation: 1 (slow) → 200 (blazing fast)
// Using exponential curve: 1 → 5 → 40 → 200 // Using exponential curve: 1 → 5 → 40 → 200
speedMultiplier: 1 + exponentialIntensity * 199, 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 }, config: { tension: 180, friction: 18 },
}) })
@ -1017,13 +1049,25 @@ export function GameInfoPanel({
</radialGradient> </radialGradient>
</defs> </defs>
{/* Region fill and stroke */} {/* Region fill and stroke - heats up as letters are typed */}
<path <animated.path
id="region-outline-path" id="region-outline-path"
d={displayRegionPath} d={displayRegionPath}
fill={isDark ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.35)'} fill={to(
stroke={isDark ? '#3b82f6' : '#2563eb'} [
strokeWidth={2} 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" vectorEffect="non-scaling-stroke"
/> />
@ -1104,11 +1148,7 @@ export function GameInfoPanel({
{Array.from({ length: sparkCount }, (_, i) => { {Array.from({ length: sparkCount }, (_, i) => {
const startOffset = i / sparkCount // Distribute evenly around the path const startOffset = i / sparkCount // Distribute evenly around the path
return ( return (
<circle <circle key={`spark-${pathIdx}-${i}`} r={tracerSize * 0.15} fill="#ffeb3b">
key={`spark-${pathIdx}-${i}`}
r={tracerSize * 0.15}
fill="#ffeb3b"
>
<animateMotion <animateMotion
dur={`${tracerDuration}s`} dur={`${tracerDuration}s`}
repeatCount="indefinite" repeatCount="indefinite"
@ -1133,6 +1173,7 @@ export function GameInfoPanel({
})()} })()}
{/* Animated puzzle piece silhouette - flies from center to map position */} {/* Animated puzzle piece silhouette - flies from center to map position */}
{/* Uses gold styling to match celebration on the map */}
{puzzlePieceShape && isPuzzlePieceAnimating && ( {puzzlePieceShape && isPuzzlePieceAnimating && (
<animated.svg <animated.svg
data-element="puzzle-piece-silhouette" data-element="puzzle-piece-silhouette"
@ -1151,9 +1192,9 @@ export function GameInfoPanel({
> >
<path <path
d={puzzlePieceShape.path} d={puzzlePieceShape.path}
fill={isDark ? 'rgba(59, 130, 246, 0.8)' : 'rgba(59, 130, 246, 0.6)'} fill="rgba(255, 215, 0, 0.7)"
stroke={isDark ? '#3b82f6' : '#2563eb'} stroke="rgba(255, 180, 0, 1)"
strokeWidth={2} strokeWidth={3}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
</animated.svg> </animated.svg>
@ -1192,9 +1233,23 @@ export function GameInfoPanel({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: { base: '1', sm: '2' }, 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={{ 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 && ( {displayFlagEmoji && (

View File

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