diff --git a/kaggle_environments/envs/open_spiel_env/games/go/visualizer/v2/src/components/SoundEffects.tsx b/kaggle_environments/envs/open_spiel_env/games/go/visualizer/v2/src/components/SoundEffects.tsx index b18ae0b37..9dd18f897 100644 --- a/kaggle_environments/envs/open_spiel_env/games/go/visualizer/v2/src/components/SoundEffects.tsx +++ b/kaggle_environments/envs/open_spiel_env/games/go/visualizer/v2/src/components/SoundEffects.tsx @@ -1,18 +1,16 @@ import { useEffect, useRef } from 'react'; -import { Howl } from 'howler'; import useGameStore from '../stores/useGameStore'; import usePreferences from '../stores/usePreferences'; +import useAudio from '../hooks/useAudio'; import placeSoundUrl from '../assets/audio/go-stone-placing.mp3'; import captureSoundUrl from '../assets/audio/go-stone-removal.mp3'; -const placeSound = new Howl({ src: [placeSoundUrl], volume: 0.5 }); -const captureSound = new Howl({ src: [captureSoundUrl], volume: 0.5 }); - const THROTTLE_MS = 150; export default function SoundEffects() { const game = useGameStore((state) => state.game); const soundEnabled = usePreferences((state) => state.soundEnabled); + const sounds = useAudio({ place: placeSoundUrl, capture: captureSoundUrl }); const prevRef = useRef({ move: 0, captures: 0 }); const lastPlayedRef = useRef(0); @@ -29,14 +27,13 @@ export default function SoundEffects() { if (!soundEnabled || (!placed && !captured)) return; - // Prevent audio spam when scrubbing quickly (e.g. holding arrow keys) const now = performance.now(); if (now - lastPlayedRef.current < THROTTLE_MS) return; lastPlayedRef.current = now; - if (placed) placeSound.play(); - if (captured) captureSound.play(); - }, [game, soundEnabled]); + if (placed) sounds.place.play(); + if (captured) sounds.capture.play(); + }, [game, soundEnabled, sounds]); return null; } diff --git a/kaggle_environments/envs/open_spiel_env/games/go/visualizer/v2/src/hooks/useAudio.ts b/kaggle_environments/envs/open_spiel_env/games/go/visualizer/v2/src/hooks/useAudio.ts new file mode 100644 index 000000000..695f83b4a --- /dev/null +++ b/kaggle_environments/envs/open_spiel_env/games/go/visualizer/v2/src/hooks/useAudio.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from 'react'; +import { Howl, Howler } from 'howler'; + +type SoundConfig = { src: string; volume?: number }; +type SoundMap = Record; +type Sounds = Record; + +function createSounds(map: SoundMap): Sounds { + const entries = Object.entries(map).map(([key, value]) => { + const config = typeof value === 'string' ? { src: value } : value; + return [key, new Howl({ src: [config.src], volume: config.volume ?? 0.5 })]; + }); + return Object.fromEntries(entries) as Sounds; +} + +export default function useAudio(map: SoundMap): Sounds { + const mapRef = useRef(map); + const [sounds, setSounds] = useState(() => createSounds(map)); + + useEffect(() => { + function cleanup() { + window.removeEventListener('pointerdown', resume); + window.removeEventListener('keydown', resume); + } + + function resume() { + Howler.unload(); + setSounds(createSounds(mapRef.current)); + cleanup(); + } + + const handleVisibilityChange = () => { + if (document.visibilityState !== 'visible') return; + const state = Howler.ctx?.state as string; + if (state !== 'interrupted' && state !== 'suspended') return; + + window.addEventListener('pointerdown', resume); + window.addEventListener('keydown', resume); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + cleanup(); + }; + }, []); + + return sounds; +}