Add combat soundtrack that swaps in when combat goes live
- Introduce `music.combat` playlist (same shuffled contract as `game`) with its own per-context volume override (`volumes.combat`). - Add `GameScene.setCombatMusic(active)` wired to the `_setCombatBar` seam: combat live stops the deep-space shuffle and starts the combat one; combat over swaps back unless the scene is asleep on a world's surface. - Track sleep/wake with `_asleep` so the wake handler resumes the correct context (combat if still active, otherwise deep-space). - Generalize `startMusicShuffleOn` to take a `context` parameter for independent volume resolution. - Update preload to queue combat tracks under their derived keys and extend tests for combat shuffle, context swap, re-fire, asleep behavior, and data contract.
This commit is contained in:
parent
93a844f07b
commit
27a9a59c03
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for the mix — the DEFAULT volume for ALL music (menu, game, surface), 0..1. volumes = per-CONTEXT overrides on top of it (see _volumes_comment). menu = the loop that plays while the main menu is up. game = the SHUFFLED soundtrack for the main game scene (open space) — a plain file list, so adding a track is adding one line; one track plays at its natural length and when it ends the next is picked at random (never the same track twice in a row). frames = the loop that plays on a world's surface — keyed by the planets.png SHEET FRAME (the same key the landing videos use, data/landing.json: frames 0..2 are the terran worlds, 3..5 the gas giants), so a world hums the track matching the art it is drawn with. The surface loop starts the moment the landing clip starts and stops the moment the takeoff clip ends (or the scene leaves any other way). A frame with no entry (or a null entry) is silent. intro-01.mp3 is parked for the intro sequence that hasn't been built yet.",
|
||||
"_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for the mix — the DEFAULT volume for ALL music (menu, game, surface), 0..1. volumes = per-CONTEXT overrides on top of it (see _volumes_comment). menu = the loop that plays while the main menu is up. game = the SHUFFLED soundtrack for the main game scene (open space) — a plain file list, so adding a track is adding one line; one track plays at its natural length and when it ends the next is picked at random (never the same track twice in a row). combat = the SHUFFLED COMBAT soundtrack — the SAME playlist shape as game (a plain file list), swapped in the moment combat goes live and back to game the moment it ends (GameScene.setCombatMusic, on the onActiveChange seam where the combat bar flips; one shuffle per scene, so the swap is a clean restart of the voice). frames = the loop that plays on a world's surface — keyed by the planets.png SHEET FRAME (the same key the landing videos use, data/landing.json: frames 0..2 are the terran worlds, 3..5 the gas giants), so a world hums the track matching the art it is drawn with. The surface loop starts the moment the landing clip starts and stops the moment the takeoff clip ends (or the scene leaves any other way). A frame with no entry (or a null entry) is silent. intro-01.mp3 is parked for the intro sequence that hasn't been built yet.",
|
||||
"enabled": true,
|
||||
"volume": 0.6,
|
||||
"_volumes_comment": "volumes = per-CONTEXT overrides on top of volume (volume stays the default for everything else): 'menu' = the main-menu loop, 'game' = the deep-space shuffle, 'frames' = the world surface loops, 'stationFrames' = the station surface loops. A NUMBER applies to that whole context; an OBJECT goes one level deeper, so one frame/variant can have its own volume (e.g. \"frames\": { \"0\": 1.0 }). A context with no entry plays at volume. The menu ships at 1.0 — full volume — on purpose.",
|
||||
"_volumes_comment": "volumes = per-CONTEXT overrides on top of volume (volume stays the default for everything else): 'menu' = the main-menu loop, 'game' = the deep-space shuffle, 'combat' = the combat shuffle, 'frames' = the world surface loops, 'stationFrames' = the station surface loops. A NUMBER applies to that whole context; an OBJECT goes one level deeper, so one frame/variant can have its own volume (e.g. \"frames\": { \"0\": 1.0 }). A context with no entry plays at volume. The menu ships at 1.0 — full volume — on purpose.",
|
||||
"volumes": {
|
||||
"menu": 1.0
|
||||
},
|
||||
|
|
@ -11,6 +11,10 @@
|
|||
"assets/music/deepspace-01.mp3",
|
||||
"assets/music/deepspace-02.mp3"
|
||||
],
|
||||
"combat": [
|
||||
"assets/music/combat-01.mp3",
|
||||
"assets/music/combat-02.mp3"
|
||||
],
|
||||
"frames": {
|
||||
"0": "assets/music/terran-01.mp3",
|
||||
"1": "assets/music/terran-02.mp3",
|
||||
|
|
|
|||
|
|
@ -230,11 +230,13 @@ const makeScene = (o = {}) => {
|
|||
check("gameTrackKey('terran-02.mp3') → music_terran_02", gameTrackKey('terran-02.mp3') === 'music_terran_02');
|
||||
}
|
||||
|
||||
// 13) data/music.json contract: `game` is a list, pointing at real files.
|
||||
// 13) data/music.json contract: `game` and `combat` are lists, pointing at real files.
|
||||
{
|
||||
const ok = Array.isArray(MUSIC.game) && MUSIC.game.length >= 1 &&
|
||||
MUSIC.game.every((p) => typeof p === 'string' && fs.existsSync(join(__dirname, '..', p)));
|
||||
check('music.json: game is a non-empty list of files that exist', ok);
|
||||
MUSIC.game.every((p) => typeof p === 'string' && fs.existsSync(join(__dirname, '..', p))) &&
|
||||
Array.isArray(MUSIC.combat) && MUSIC.combat.length >= 1 &&
|
||||
MUSIC.combat.every((p) => typeof p === 'string' && fs.existsSync(join(__dirname, '..', p)));
|
||||
check('music.json: game and combat are non-empty lists of files that exist', ok);
|
||||
}
|
||||
|
||||
/** A shuffle-capable stub scene: sound manager + cache + a time.addEvent
|
||||
|
|
@ -340,14 +342,84 @@ const makeShuffleScene = (o = {}) => {
|
|||
check('no sound manager → silent, no throw', !threw && noMgr.plays.length === 0);
|
||||
}
|
||||
|
||||
// 17) GameScene.preload queues every game-track file under its derived key.
|
||||
// 17) GameScene.preload queues every game-track AND combat-track file under its derived key.
|
||||
{
|
||||
const s = Object.create(GameScene.prototype);
|
||||
s.queued = [];
|
||||
s.load = { audio: (k, u) => s.queued.push([k, u]), spritesheet: () => {} };
|
||||
GameScene.prototype.preload.call(s);
|
||||
const ok = MUSIC.game.every((f) => s.queued.some(([k, u]) => k === gameTrackKey(f) && u === f));
|
||||
check('preload queues each music.game file under its derived key', ok);
|
||||
const ok = MUSIC.game.every((f) => s.queued.some(([k, u]) => k === gameTrackKey(f) && u === f)) &&
|
||||
MUSIC.combat.every((f) => s.queued.some(([k, u]) => k === gameTrackKey(f) && u === f));
|
||||
check('preload queues each music.game + music.combat file under its derived key', ok);
|
||||
}
|
||||
|
||||
// 17b) The COMBAT shuffle: the same voice at the combat context's volume
|
||||
// (startMusicShuffleOn's context parameter — music.volumes.combat
|
||||
// can override the global mix without touching 'game').
|
||||
{
|
||||
const files = MUSIC.combat;
|
||||
const keys = files.map(gameTrackKey);
|
||||
const base = { enabled: true, volume: 0.6, menu: 'assets/music/mainmenu.mp3', game: ['assets/music/a.mp3'], combat: ['assets/music/c.mp3', 'assets/music/d.mp3'] };
|
||||
config.init({ music: { ...base } });
|
||||
const noOverride = makeShuffleScene();
|
||||
startMusicShuffleOn(noOverride, base.combat, 'combat');
|
||||
check('the combat shuffle plays at the global mix without an override',
|
||||
noOverride.plays.length === 1 && noOverride.plays[0].opts.volume === 0.6);
|
||||
stopMusicShuffleOn(noOverride);
|
||||
|
||||
config.init({ music: { ...base, volumes: { game: 0.5, combat: 0.9 } } });
|
||||
const sh = makeShuffleScene();
|
||||
startMusicShuffleOn(sh, base.combat, 'combat');
|
||||
check('volumes.combat overrides ONLY the combat context',
|
||||
sh.plays.length === 1 && sh.plays[0].opts.volume === 0.9 &&
|
||||
musicVolumeFor('game') === 0.5);
|
||||
stopMusicShuffleOn(sh);
|
||||
|
||||
// The swap is a clean restart: starting one context stops the other's track.
|
||||
const swap = makeShuffleScene();
|
||||
startMusicShuffleOn(swap, base.game);
|
||||
startMusicShuffleOn(swap, base.combat, 'combat');
|
||||
check('a context swap stops the previous track and plays ONE combat track',
|
||||
swap.stops.length >= 1 && swap.plays.length === 2 &&
|
||||
keys.includes(swap.plays[1].key) === false &&
|
||||
base.combat.map(gameTrackKey).includes(swap.plays[1].key) &&
|
||||
[gameTrackKey(base.game[0]), ...base.combat.map(gameTrackKey)]
|
||||
.filter((k) => swap.sound.isPlaying(k)).length === 1);
|
||||
stopMusicShuffleOn(swap);
|
||||
config.init({ music: { ...MUSIC }, landing: { ...LANDING } }); // back to the shipped data
|
||||
}
|
||||
|
||||
// 17c) GameScene.setCombatMusic: the onActiveChange seam — combat live
|
||||
// swaps the deep-space shuffle for the combat one; combat over
|
||||
// swaps back (unless the scene is asleep, which owns the silence).
|
||||
{
|
||||
const scene = Object.assign(Object.create(GameScene.prototype), makeShuffleScene());
|
||||
scene._asleep = false;
|
||||
scene.setCombatMusic(true);
|
||||
const combatKeys = MUSIC.combat.map(gameTrackKey);
|
||||
check('setCombatMusic(true) plays ONE combat track at the combat volume',
|
||||
scene.plays.length === 1 && combatKeys.includes(scene.plays[0].key) &&
|
||||
scene.plays[0].opts.volume === musicVolumeFor('combat'));
|
||||
const first = scene.plays[0].key;
|
||||
scene.setCombatMusic(true); // a re-fire (combat re-armed): clean restart, no stack
|
||||
check('... a re-fire stops the old combat track and keeps exactly ONE playing',
|
||||
scene.stops.includes(first) && scene.plays.length === 2 &&
|
||||
combatKeys.filter((k) => scene.sound.isPlaying(k)).length === 1);
|
||||
scene.setCombatMusic(false);
|
||||
const gameKeys = MUSIC.game.map(gameTrackKey);
|
||||
check('setCombatMusic(false) stops the combat track and resumes the deep-space shuffle',
|
||||
scene.stops.includes(scene.plays[1].key) &&
|
||||
scene.plays.length === 3 &&
|
||||
gameKeys.includes(scene.plays[2].key) &&
|
||||
scene.plays[2].opts.volume === musicVolumeFor('game'));
|
||||
// Asleep (on a world's surface): ending combat must NOT restart the hum.
|
||||
const asleep = Object.assign(Object.create(GameScene.prototype), makeShuffleScene());
|
||||
asleep._asleep = true;
|
||||
asleep.setCombatMusic(true);
|
||||
asleep.setCombatMusic(false);
|
||||
check('asleep, a combat end keeps the silence (the wake handler resumes)',
|
||||
asleep.plays.length === 1); // only the combat start played; no deep-space resume
|
||||
stopMusicShuffleOn(asleep);
|
||||
}
|
||||
|
||||
// 18) Per-context volume overrides (data/music.json → volumes): a context
|
||||
|
|
@ -409,7 +481,7 @@ const makeShuffleScene = (o = {}) => {
|
|||
check('music.json: volumes is absent or a context→number (or per-frame) map',
|
||||
vols == null || (typeof vols === 'object' && Object.values(vols).every((v) => wellFormed(v))));
|
||||
check('music.json: every context resolves to a finite volume',
|
||||
['menu', 'game', 'frames.0', 'frames.11', 'stationFrames.0']
|
||||
['menu', 'game', 'combat', 'frames.0', 'frames.11', 'stationFrames.0']
|
||||
.every((c) => Number.isFinite(musicVolumeFor(c))));
|
||||
check('music.json: the shipped menu override is full volume', musicVolumeFor('menu') === 1.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1603,6 +1603,16 @@ contract is COMPLETE, every leg is checked, the reward claims once.
|
|||
1 s scene-clock tick advances it (js/utils/Music.js →
|
||||
startMusicShuffleOn/stopMusicShuffleOn, files queue under their
|
||||
filename-derived key: deepspace-01.mp3 → music_deepspace_01);
|
||||
when COMBAT goes live the COMBAT soundtrack (music.combat — the
|
||||
SAME shuffled-voice contract: combat-01/02 for now, adding a
|
||||
track = adding a line) swaps in and the deep-space one stops —
|
||||
the flip rides the onActiveChange seam (GameScene.setCombatMusic,
|
||||
called from _setCombatBar, the same moment the combat bar comes
|
||||
up), and they swap back the moment the fight ends (asleep = on a
|
||||
world's surface: the surface owns the silence, the wake handler
|
||||
resumes the right context); startMusicShuffleOn takes the context
|
||||
so music.volumes.combat can override the combat shuffle's volume
|
||||
independently (shipped at the global mix, like game);
|
||||
on a world's surface the track for that world's
|
||||
planets.png frame (terran / gas-giant families — the SAME key as
|
||||
the landing videos) runs from the start of the landing clip to the
|
||||
|
|
@ -1610,7 +1620,7 @@ contract is COMPLETE, every leg is checked, the reward claims once.
|
|||
(looped play + `stopByKey` + one-loop-per-track guard;
|
||||
`music.enabled` / `music.volume` (the ONE knob for the mix) +
|
||||
per-CONTEXT volume overrides in `music.volumes` — menu / game /
|
||||
frames / stationFrames, a number per context or a per-frame object,
|
||||
combat / frames / stationFrames, a number per context or a per-frame object,
|
||||
the menu ships at 1.0 for full volume), stops hooked on the scene's
|
||||
`shutdown` EVENT (v4 quirk — see the list above), so the hum dies
|
||||
on Take Off AND on Return to Main Menu, and the game soundtrack
|
||||
|
|
|
|||
|
|
@ -382,6 +382,14 @@ export class GameScene extends Phaser.Scene {
|
|||
if (config.get('music.enabled', true) && Array.isArray(gameTracks)) {
|
||||
for (const f of gameTracks) this.load.audio(gameTrackKey(f), f);
|
||||
}
|
||||
|
||||
// The COMBAT soundtrack (data/music.json → combat) — the shuffled
|
||||
// playlist that swaps in the moment the fight goes live (setCombatMusic,
|
||||
// on the onActiveChange seam). Same derived-key rule (music_combat_01…).
|
||||
const combatTracks = config.get('music.combat', []);
|
||||
if (config.get('music.enabled', true) && Array.isArray(combatTracks)) {
|
||||
for (const f of combatTracks) this.load.audio(gameTrackKey(f), f);
|
||||
}
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -407,13 +415,17 @@ export class GameScene extends Phaser.Scene {
|
|||
this.playTimeMs = 0;
|
||||
|
||||
// The deep-space soundtrack — the data/music.json (game) playlist,
|
||||
// shuffled: one track at a time, next picked when one ends.
|
||||
// shuffled: one track at a time, next picked when one ends. The COMBAT
|
||||
// soundtrack (→ combat) swaps in on the onActiveChange seam (see
|
||||
// setCombatMusic): the deep-space one stops, the combat one starts, and
|
||||
// they swap back the moment the fight ends.
|
||||
// LANDING does not transition us — it LAUNCHES SurfaceScene on top
|
||||
// and SLEEPS this scene (v4: `launch` doesn't freeze the caller, and
|
||||
// a sleeping scene never emits 'shutdown') — so the soundtrack and
|
||||
// the mining hum stop on 'sleep' and come back on 'wake' (Take Off
|
||||
// wakes the scene). 'shutdown' still covers return-to-menu and game
|
||||
// destroy (the shutdown() method stays as the destroy safety net).
|
||||
this._asleep = false; // surface state (the sleep/wake events flip it) — setCombatMusic's seam
|
||||
this.startGameMusic();
|
||||
this.events.once('shutdown', () => {
|
||||
this.stopGameMusic();
|
||||
|
|
@ -422,12 +434,18 @@ export class GameScene extends Phaser.Scene {
|
|||
this.levelMarquee?.destroy(); // the marquee can't outlive its scene
|
||||
});
|
||||
this.events.on('sleep', () => {
|
||||
this.stopGameMusic(); // no space hum on a world's surface
|
||||
this._asleep = true; // on a world's surface — the surface owns the silence
|
||||
this.stopGameMusic(); // no space hum on a world's surface (combat or deep-space)
|
||||
this.setMiningLoop(false); // …nor the mining hum
|
||||
this.engineFx?.sleep(); // no ghost exhaust waiting on the pad
|
||||
});
|
||||
this.events.on('wake', () => {
|
||||
this.startGameMusic(); // back in space — the shuffle starts fresh
|
||||
this._asleep = false;
|
||||
// Back in space — resume the RIGHT context: the combat shuffle if the
|
||||
// fight is still live (landing is denied mid-fight, so this is the
|
||||
// defensive branch), the deep-space hum otherwise.
|
||||
if (this.combat?.active) this.startCombatMusic();
|
||||
else this.startGameMusic(); // the shuffle starts fresh
|
||||
if (this.mining?.state === 'mining') this.setMiningLoop(true); // the beam is still live
|
||||
});
|
||||
|
||||
|
|
@ -2056,6 +2074,7 @@ export class GameScene extends Phaser.Scene {
|
|||
_setCombatBar(active) {
|
||||
if (this.actionBar) this.actionBar.setVisible(!active);
|
||||
if (this.combatBar) this.combatBar.setVisible(active);
|
||||
this.setCombatMusic(active); // the soundtrack swap (combat ↔ deep-space)
|
||||
if (active) this.comms?.trigger('combatStart');
|
||||
else this.comms?.trigger('combatEnd');
|
||||
}
|
||||
|
|
@ -4129,6 +4148,40 @@ export class GameScene extends Phaser.Scene {
|
|||
stopMusicShuffleOn(this);
|
||||
}
|
||||
|
||||
/** The COMBAT soundtrack (data/music.json → combat): the shuffled
|
||||
* combat playlist — one track at a time, the next picked at random
|
||||
* when one ends (never the same twice in a row). Plays at the combat
|
||||
* context's volume (music.volumes.combat can override the global mix).
|
||||
* Clean to re-fire — the scene-level stop goes first, so the swap in
|
||||
* setCombatMusic never stacks two shuffles. */
|
||||
startCombatMusic() {
|
||||
startMusicShuffleOn(this, config.get('music.combat', []), 'combat');
|
||||
}
|
||||
|
||||
stopCombatMusic() {
|
||||
stopMusicShuffleOn(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* The soundtrack's combat switch (the onActiveChange seam — the same
|
||||
* moment the combat bar swaps in): the fight goes live → the deep-space
|
||||
* hum stops and the combat shuffle starts; the fight ends → the combat
|
||||
* shuffle stops and the deep-space hum resumes — unless the scene is
|
||||
* asleep (on a world's surface: the surface owns the silence, and the
|
||||
* wake handler resumes the right context). Clean to re-fire on either
|
||||
* side (one shuffle per scene).
|
||||
* @param {boolean} active combat live / over (CombatSystem's flip)
|
||||
*/
|
||||
setCombatMusic(active) {
|
||||
if (active) {
|
||||
this.stopGameMusic();
|
||||
this.startCombatMusic();
|
||||
} else {
|
||||
this.stopCombatMusic();
|
||||
if (!this._asleep) this.startGameMusic();
|
||||
}
|
||||
}
|
||||
|
||||
/** The "new object" moment: a rim ping at the world + a HUD toast. */
|
||||
celebrateDiscovery(o) {
|
||||
const neon = themeColor('neon', 0x00e5ff);
|
||||
|
|
|
|||
|
|
@ -117,12 +117,16 @@ export function stopMusicShuffleOn(scene) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Start the shuffle. `files` = the data/music.json playlist ('game' or
|
||||
* 'combat' — a plain file LIST); `context` = the music CONTEXT the
|
||||
* tracks play at (the spec minus the 'music.' prefix — 'game' by
|
||||
* default; pass 'combat' for the combat playlist, so
|
||||
* music.volumes.combat can override it independently). 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) {
|
||||
export function startMusicShuffleOn(scene, files, context = 'game') {
|
||||
stopMusicShuffleOn(scene); // clean slate before a (re)start
|
||||
if (!scene) return;
|
||||
if (!config.get('music.enabled', true)) return;
|
||||
|
|
@ -148,7 +152,7 @@ export function startMusicShuffleOn(scene, files) {
|
|||
}
|
||||
const file = pool[Math.floor(Math.random() * pool.length)];
|
||||
st.current = file;
|
||||
snd.play(gameTrackKey(file), { loop: false, volume: musicVolumeFor('game') });
|
||||
snd.play(gameTrackKey(file), { loop: false, volume: musicVolumeFor(context) });
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue