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:
parent
1e6153ee8b
commit
a8c6b84855
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue