orbit/js/utils/Music.js

145 lines
6.2 KiB
JavaScript

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.<planets.png frame>' (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.<key>` — 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
},
});
}
}