import { config } from '../config/Config.js'; /** * The shared MUSIC voice (data/music.json) — the looping sibling of the * SFX voice (js/utils/Sfx.js). * * `spec` is a dotted config path to a file — 'music.menu' or * 'music.frames.' (the same key the landing videos * use). The asset is queued under the derived cache key — the spec, * flattened: `music_menu`, `music_frames_0`… (one rule, no lookup * table — the same spirit as sfx.json's "the key IS the play name"). * * Same guard rails as the SFX voice: the master switch (music.enabled), * the sound manager (absent in headless), and the audio cache — v4 * (Giedi) quirks: `sound.play()` THROWS on a key that never loaded, * `cache.hasAudio()` doesn't exist (check `cache.audio.has()`), and the * manager stops BY KEY (`stopByKey`, there is no `sound.stop()`). * Music is always played LOOPING at music.volume, and one loop per * track: a scene restart must not stack a second copy. */ /** The cache key a music spec queues under (music_menu, music_frames_3…). * The spec is `music.` — the cache key is the key inside * data/music.json, flattened and prefixed (the sfx.json convention). */ export function musicKey(spec) { const key = String(spec).replace(/^music\./, ''); return 'music_' + key.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, ''); } /** Start a music loop (config spec → file). No-ops per the guard rails above. */ export function playMusicOn(scene, spec) { if (!scene || !config.get('music.enabled', true)) return; const snd = scene.sound; if (!snd || typeof snd.play !== 'function') return; const key = musicKey(spec); if (typeof snd.isPlaying === 'function' && snd.isPlaying(key)) return; // one loop per track if (scene.cache && scene.cache.audio && typeof scene.cache.audio.has === 'function' && !scene.cache.audio.has(key)) return; snd.play(key, { loop: true, volume: config.get('music.volume', 0.6) }); } /** Stop a music loop (config spec → key). A no-op when it isn't playing. */ export function stopMusicOn(scene, spec) { const snd = scene && scene.sound; if (!snd || typeof snd.stopByKey !== 'function') return; snd.stopByKey(musicKey(spec)); } /** Is a music loop playing right now? (false when unsure). */ export function musicPlayingOn(scene, spec) { const snd = scene && scene.sound; if (!snd || typeof snd.isPlaying !== 'function') return false; return snd.isPlaying(musicKey(spec)) === true; } // --------------------------------------------------------------------------- // SHUFFLE playlist — the GameScene deep-space soundtrack // --------------------------------------------------------------------------- // // data/music.json's `game` is a plain file LIST (adding a track = adding a // line). Files queue under a key derived from the FILENAME: // assets/music/deepspace-01.mp3 → music_deepspace_01. // // Behaviour: one track at a time, played at its NATURAL length (loop OFF); // when a track ends, the next is picked at random — never the same track // twice in a row. The v4 (Giedi) build has no sound-manager 'complete' // event (checked the vendor lib), so progression rides a 1 s scene-clock // tick: once the current track is no longer playing (ended naturally, or // was stopped) the next one goes. Same guard rails as the loop voice: // the master switch, the sound manager, and the audio cache. /** Per-scene shuffle state — one shuffle at a time (a restart is clean). */ const shuffles = new WeakMap(); // scene → { list, current, tick } /** The cache key a playlist file queues under (music_deepspace_01…). */ export function gameTrackKey(file) { const name = String(file).replace(/.*[\\/]/, '').replace(/\.[a-z0-9]+$/i, ''); return 'music_' + name.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, ''); } /** Stop the shuffle: the current track AND its progression timer. */ export function stopMusicShuffleOn(scene) { if (!scene) return; // WeakMap keys must be objects const st = shuffles.get(scene); if (!st) return; shuffles.delete(scene); if (st.tick && typeof st.tick.remove === 'function') st.tick.remove(); const snd = scene && scene.sound; if (st.current && snd && typeof snd.stopByKey === 'function') { snd.stopByKey(gameTrackKey(st.current)); } st.current = null; st.tick = null; } /** * Start the shuffle. `files` = the data/music.json `game` list. No-ops * per the guard rails above; a scene with no usable (cached) tracks stays * silent without throwing. Re-calling restarts cleanly — the old track is * stopped and its timer removed first, so nothing stacks. */ export function startMusicShuffleOn(scene, files) { stopMusicShuffleOn(scene); // clean slate before a (re)start if (!scene) return; if (!config.get('music.enabled', true)) return; const snd = scene.sound; if (!snd || typeof snd.play !== 'function') return; const list = Array.isArray(files) ? files.filter((f) => typeof f === 'string' && f.length > 0) : []; if (list.length === 0) return; const st = { list, current: null, tick: null }; shuffles.set(scene, st); const inCache = (file) => scene.cache && scene.cache.audio && typeof scene.cache.audio.has === 'function' && scene.cache.audio.has(gameTrackKey(file)); const playNext = (avoidFile) => { let pool = st.list.filter(inCache); if (pool.length === 0) return false; if (pool.length > 1) { const fresh = pool.filter((f) => f !== avoidFile); if (fresh.length > 0) pool = fresh; // no immediate repeats } const file = pool[Math.floor(Math.random() * pool.length)]; st.current = file; snd.play(gameTrackKey(file), { loop: false, volume: config.get('music.volume', 0.6) }); return true; }; if (!playNext(null)) { stopMusicShuffleOn(scene); return; } // nothing usable → silent if (scene.time && typeof scene.time.addEvent === 'function') { st.tick = scene.time.addEvent({ delay: 1000, loop: true, callback: () => { if (shuffles.get(scene) !== st) return; // torn down / restarted if (typeof snd.isPlaying === 'function' && snd.isPlaying(gameTrackKey(st.current))) return; if (!playNext(st.current)) stopMusicShuffleOn(scene); // nothing left to play }, }); } }