Add music system, UI SFX, and ship base stats

- New shared audio voices: js/utils/Sfx.js (playSfxOn/stopSfxOn/sfxPlayingOn)
  and js/utils/Music.js (loop voice + shuffle playlist), both with v4 guard
  rails (cache.audio.has, stopByKey, isPlaying) so missing assets never throw.
- data/music.json: menu loop, shuffled deep-space soundtrack for GameScene,
  and per-world surface loops keyed by planets.png frame; new mp3 assets in
  assets/music/.
- data/sfx.json: add ui_hover/ui_click/ui_window/ui_close/mining_loop and a
  dedicated scan track; wire UI components (buttons, panels, popups, save
  vault) to the shared voice via scene.playSfx seam.
- GameScene: start/stop the shuffle on create/shutdown, stop both soundtrack
  and mining hum on 'sleep' (landing launch+sleep) and resume on 'wake';
  mining hum loops while beam is live and stops quietly on 'stopped'.
- SurfaceScene: preload and loop the world's track from landing through
  takeoff; fix landing frame hand-off to pass sheetFrame instead of the
  texture Frame object.
- MenuScene: preload SFX + menu music, start the menu hum in create, stop it
  on the 'shutdown' event (v4 never calls shutdown() on transition).
- data/ship.json → stats: hullIntegrity/shields/cargoHold/mineralStorage,
  exposed as ship.stats for future combat/trading/mining systems.
- Tests: dev/sfx.test.mjs and dev/music.test.mjs cover the shared voices,
  guards, and data contracts; ship-behavior and system-hud tests extended
  for base stats and the mining hum lifecycle.
This commit is contained in:
Brian Fertig 2026-09-05 10:10:18 -06:00
parent cf999a14fa
commit 9ca0fa55a8
38 changed files with 1078 additions and 26 deletions

View File

@ -91,7 +91,7 @@ orbit/
│ ├── game.json # dimensions, colors, starfield, …
│ ├── menu.json # menu text, colors, button layout, seed panel
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
│ ├── ship.json # ship feel: thrust, drag, maxSpeed, …
│ ├── ship.json # the ship: art, feel (thrust, drag, maxSpeed…), base stats (hull, shields, cargo, minerals)
│ ├── planets.json # home world + system layout + solid-disc rules
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
@ -164,6 +164,8 @@ node dev/tether.test.mjs # tether range math: union, clamp, visible arc
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest
node dev/decode.test.mjs # the shared decode scramble (menu seed + system dossier)
node dev/system-hud.test.mjs # real GameScene dossier: layout + staggered decode to final report
node dev/sfx.test.mjs # the shared SFX voice: guards + sfx.json keys/files
node dev/music.test.mjs # the shared music voice: guards + music.json contract
```
`dev/test-game.html` boots straight into the GameScene (no menu click),

BIN
assets/fx/mining-01.mp3 Normal file

Binary file not shown.

BIN
assets/fx/mining-02.mp3 Normal file

Binary file not shown.

BIN
assets/fx/scan-01.mp3 Normal file

Binary file not shown.

BIN
assets/fx/ui-close.mp3 Normal file

Binary file not shown.

BIN
assets/fx/ui-window.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/music/intro-01.mp3 Normal file

Binary file not shown.

BIN
assets/music/mainmenu.mp3 Normal file

Binary file not shown.

BIN
assets/music/terran-01.mp3 Normal file

Binary file not shown.

BIN
assets/music/terran-02.mp3 Normal file

Binary file not shown.

View File

@ -18,6 +18,7 @@
"actionbar.json",
"landing.json",
"sfx.json",
"music.json",
"scan.json",
"signalCompass.json",
"save.json"

View File

@ -2,7 +2,7 @@
"title": "ORBIT",
"titleFontSize": 118,
"titleLetterSpacing": 12,
"subtitle": "A PROCEDURAL SPACE RPG",
"subtitle": "THE GALAXY AWAITS",
"subtitleFontSize": 20,
"subtitleLetterSpacing": 8,
"buttonFontSize": 26,

18
data/music.json Normal file
View File

@ -0,0 +1,18 @@
{
"_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for ALL music (menu, game, surface), 0..1. 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.",
"enabled": true,
"volume": 0.6,
"menu": "assets/music/mainmenu.mp3",
"game": [
"assets/music/deepspace-01.mp3",
"assets/music/deepspace-02.mp3"
],
"frames": {
"0": "assets/music/terran-01.mp3",
"1": "assets/music/terran-02.mp3",
"2": "assets/music/terran-01.mp3",
"3": "assets/music/gasgiant-02.mp3",
"4": "assets/music/gasgiant-02.mp3",
"5": "assets/music/gasgiant-02.mp3"
}
}

View File

@ -1,10 +1,15 @@
{
"_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = the dossier typing in; deconstruct = the dossier deconstructing; discovery = a new object coming into view; mining = the mining arm powering up (the 'Extending Mining Arm...' reach); scan = the DEEP SCAN pulse going out (the command deck's SCAN button).",
"_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = text typing in (the dossier, menus decoding in); deconstruct = text UNDECODING (the dossier collapsing) — reserved for the text only, never for closing UI; discovery = a new object coming into view; mining = the mining arm powering up (the 'Extending Mining Arm...' reach); mining_loop = the hum that LOOPS while the mining beam is live (starts when the arm finishes extending, stops when the sequence ends — stopping mining itself plays no sound); scan = the DEEP SCAN pulse going out (the command deck's SCAN button); ui_hover = the tick on HOVER of a clickable item (menu/deck buttons, compass name tags, panel buttons, save slots); ui_click = the tick on CLICK of a clickable item (the SCAN slot plays its own sonar ping instead of ui_click); ui_window = the whoosh when a window OPENS (the mining pop-up, the comms/landing panel); ui_close = the tick when a UI element is CANCELLED/CLOSED (the menu sub-bar, the save vault, the confirm dialog). Convention: the key here IS the play name — playSfx('<key>') plays the asset queued as 'sfx_<key>'.",
"enabled": true,
"volume": 0.55,
"construct": "assets/fx/type-construct.mp3",
"deconstruct": "assets/fx/type-deconstruct.mp3",
"discovery": "assets/fx/discovery.mp3",
"mining": "assets/fx/system-scan.mp3",
"scan": "assets/fx/system-scan.mp3"
"mining_loop": "assets/fx/mining-01.mp3",
"scan": "assets/fx/scan-01.mp3",
"ui_hover": "assets/fx/ui-hover.mp3",
"ui_click": "assets/fx/ui-click.mp3",
"ui_window": "assets/fx/ui-window.mp3",
"ui_close": "assets/fx/ui-close.mp3"
}

View File

@ -15,5 +15,12 @@
"rotSpeed": 10,
"brakeDistance": 260,
"arriveRadius": 8,
"arriveSpeed": 50
"arriveSpeed": 50,
"stats": {
"_comment": "Base stats — the ship's starting condition, before any upgrades (builds/research layer deltas on top). hullIntegrity = the hull's max health (damage it can take); shields = damage absorbed in front of the hull; cargoHold = goods capacity; mineralStorage = minerals capacity. Exposed as ship.stats (js/entities/Ship.js); combat/trading/mining systems will read these as capacities.",
"hullIntegrity": 100,
"shields": 0,
"cargoHold": 100,
"mineralStorage": 250
}
}

349
dev/music.test.mjs Normal file
View File

@ -0,0 +1,349 @@
/**
* Music voice test (dev tool, run with Node no browser needed):
*
* node dev/music.test.mjs
*
* Exercises the shared music voice (js/utils/Music.js) with a stub scene:
* the guard rails (master switch off, missing sound manager, asset not in
* the audio cache, ONE loop per track), the loop + volume config, the
* spec cache-key rule (music_frames_3), the SurfaceScene preload +
* setSurfaceMusic seam, and the data/music.json contract (menu + every
* planets.png frame entry points at a file that exists), and the
* GameScene SHUFFLE playlist (gameTrackKey, start/stop, advancing when a
* track ends, no repeats, guards).
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
// --- Stub just enough of Phaser for the module-level class declarations ----
// (js/vendor/phaser.js throws unless window.Phaser exists — same shim the
// other Node tests use; the surface scene only needs the base classes.)
const ClassStub = class {};
globalThis.window = {
Phaser: {
Scene: ClassStub,
GameObjects: { Sprite: ClassStub, Container: ClassStub },
Physics: { Arcade: { Sprite: ClassStub } },
Geom: { Rectangle: class {} },
},
};
const __dirname = dirname(fileURLToPath(import.meta.url));
const MUSIC = JSON.parse(fs.readFileSync(join(__dirname, '../data/music.json'), 'utf8'));
const LANDING = JSON.parse(fs.readFileSync(join(__dirname, '../data/landing.json'), 'utf8'));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
config.init({ music: { ...MUSIC }, landing: { ...LANDING } });
const { playMusicOn, stopMusicOn, musicPlayingOn, musicKey, gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } = await import(
pathToFileURL(join(__dirname, '../js/utils/Music.js')).href
);
const { SurfaceScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/SurfaceScene.js')).href);
const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
/** A stub scene: sound manager + audio cache, recording play/stop calls. */
const makeScene = (o = {}) => {
const plays = [];
const stops = [];
let playing = false;
return {
plays,
stops,
sound:
o.sound === false
? null
: {
play: (key, opts) => {
plays.push({ key, opts });
playing = true;
},
stopByKey: (key) => {
stops.push(key);
playing = false;
},
isPlaying: (key) => playing && key === plays[plays.length - 1]?.key,
},
cache: o.noCache
? undefined
: { audio: o.noAudio ? undefined : { has: (k) => (o.inCache === false ? false : true) } },
};
};
// 1) spec → cache key: the spec flattened, music_ prefixed.
{
check("musicKey('music.menu') → music_menu", musicKey('music.menu') === 'music_menu');
check("musicKey('music.frames.3') → music_frames_3", musicKey('music.frames.3') === 'music_frames_3');
}
// 2) Happy path: loops the music_ key at music.volume.
{
const scene = makeScene();
playMusicOn(scene, 'music.menu');
check('plays music_menu, looping, at music.volume',
scene.plays.length === 1 &&
scene.plays[0].key === 'music_menu' &&
scene.plays[0].opts.loop === true &&
scene.plays[0].opts.volume === MUSIC.volume);
}
// 3) One loop per track: a scene restart must not stack a second copy.
{
const scene = makeScene();
playMusicOn(scene, 'music.menu');
playMusicOn(scene, 'music.menu');
check('a second start (scene restart) does not double the loop', scene.plays.length === 1);
}
// 4) Master switch off → silent, no throw.
{
const scene = makeScene();
config.init({ music: { ...MUSIC, enabled: false }, landing: { ...LANDING } });
playMusicOn(scene, 'music.menu');
check('music.enabled=false → nothing plays', scene.plays.length === 0);
config.init({ music: { ...MUSIC }, landing: { ...LANDING } });
}
// 5) No sound manager (headless) → silent, no throw.
{
const scene = makeScene({ sound: false });
playMusicOn(scene, 'music.menu');
check('no sound manager → nothing plays', scene.plays.length === 0);
}
// 6) Asset not in the audio cache → silent, no throw (v4 play() would throw).
{
const scene = makeScene({ inCache: false });
playMusicOn(scene, 'music.frames.3');
check('asset missing from cache → nothing plays', scene.plays.length === 0);
}
// 7) stopMusicOn stops the music_ key (v4 has no sound.stop()); no-throw without a manager.
{
const scene = makeScene();
playMusicOn(scene, 'music.frames.1');
stopMusicOn(scene, 'music.frames.1');
check('stopMusicOn stops the music_ key', scene.stops.length === 1 && scene.stops[0] === 'music_frames_1');
const noMgr = makeScene({ sound: false });
let threw = false;
try {
stopMusicOn(noMgr, 'music.menu');
} catch {
threw = true;
}
check('stopMusicOn with no sound manager → no throw', !threw);
}
// 8) musicPlayingOn mirrors the manager's isPlaying (false when unsure).
{
const scene = makeScene();
check('musicPlayingOn is false before anything plays', musicPlayingOn(scene, 'music.menu') === false);
playMusicOn(scene, 'music.menu');
check('musicPlayingOn is true while it plays', musicPlayingOn(scene, 'music.menu') === true);
stopMusicOn(scene, 'music.menu');
check('musicPlayingOn is false after stop', musicPlayingOn(scene, 'music.menu') === false);
}
// 9) data/music.json contract: the menu + every planets.png frame (0..5)
// point at files that exist.
{
const ok =
typeof MUSIC.menu === 'string' && fs.existsSync(join(__dirname, '..', MUSIC.menu)) &&
[0, 1, 2, 3, 4, 5].every((f) => {
const p = MUSIC.frames?.[String(f)];
return typeof p === 'string' && fs.existsSync(join(__dirname, '..', p));
});
check('music.json: menu + frames 0..5 point at files that exist', ok);
}
// 10) SurfaceScene.preload: the world's track, queued under the derived key.
{
const s = Object.create(SurfaceScene.prototype);
s.planetFrame = 1;
s.musicSpec = 'music.frames.1';
s.queued = [];
s.load = { audio: (k, u) => s.queued.push([k, u]), video: () => {} };
SurfaceScene.prototype.preload.call(s);
check('preload queues the frame\'s track under the derived key',
s.queued.some(([k, u]) => k === 'music_frames_1' && u === MUSIC.frames['1']));
// A frame with no track is silent (nothing queued, no crash).
const s2 = Object.create(SurfaceScene.prototype);
s2.planetFrame = 9; // no such frame
s2.musicSpec = 'music.frames.9';
s2.queued = [];
s2.load = { audio: (k, u) => s2.queued.push([k, u]), video: () => {} };
SurfaceScene.prototype.preload.call(s2);
check('a frame with no track queues no music', s2.queued.length === 0);
}
// 11) SurfaceScene.setSurfaceMusic: one loop across re-fires, stops on demand.
{
let playing = false;
const plays = [];
const stops = [];
const scene = {
musicSpec: 'music.frames.3',
plays,
stops,
sound: {
play: (key, opts) => {
plays.push({ key, opts });
playing = true;
},
isPlaying: (key) => playing && key === 'music_frames_3',
stopByKey: (key) => {
stops.push(key);
playing = false;
},
},
cache: { audio: { has: () => true } },
};
SurfaceScene.prototype.setSurfaceMusic.call(scene, true);
check('setSurfaceMusic(true) starts the world\'s loop (looping, at music.volume)',
plays.length === 1 &&
plays[0].key === 'music_frames_3' &&
plays[0].opts.loop === true &&
plays[0].opts.volume === MUSIC.volume);
SurfaceScene.prototype.setSurfaceMusic.call(scene, true);
check('... a re-fire (scene restart) keeps ONE loop', plays.length === 1);
SurfaceScene.prototype.setSurfaceMusic.call(scene, false);
check('setSurfaceMusic(false) stops the loop', stops.length === 1 && stops[0] === 'music_frames_3' && !playing);
}
// 12) gameTrackKey: the playlist FILENAME → the cache key it queues under.
{
check("gameTrackKey('assets/music/deepspace-01.mp3') → music_deepspace_01",
gameTrackKey('assets/music/deepspace-01.mp3') === 'music_deepspace_01');
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.
{
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);
}
/** A shuffle-capable stub scene: sound manager + cache + a time.addEvent
* that records the tick so the test can fire it by hand. */
const makeShuffleScene = (o = {}) => {
let playingKey = null;
const plays = [];
const stops = [];
const events = [];
const scene = {
plays,
stops,
sound: o.sound === false ? null : {
play: (key, opts) => { plays.push({ key, opts }); playingKey = key; },
stopByKey: (key) => { stops.push(key); if (playingKey === key) playingKey = null; },
isPlaying: (key) => playingKey === key,
},
cache: { audio: { has: (k) => (o.missing ? !o.missing.includes(k) : true) } },
time: { addEvent: (cfg) => { const ev = { cfg, remove() { const i = events.indexOf(ev); if (i > -1) events.splice(i, 1); } }; events.push(ev); return ev; } },
};
scene.tick = () => events.slice().forEach((ev) => ev.cfg.callback());
return scene;
};
// 14) The shuffle itself: one track at a time, advancing when it ends,
// never the same track twice in a row (2 tracks → deterministic).
{
const [fa, fb] = MUSIC.game;
const ka = gameTrackKey(fa);
const kb = gameTrackKey(fb);
const scene = makeShuffleScene();
startMusicShuffleOn(scene, MUSIC.game);
check('starts exactly ONE track', scene.plays.length === 1);
check('... and it plays loop:false at music.volume',
(scene.plays[0].key === ka || scene.plays[0].key === kb) &&
scene.plays[0].opts.loop === false &&
scene.plays[0].opts.volume === MUSIC.volume);
const first = scene.plays[0].key;
const firstFile = first === ka ? fa : fb;
const second = first === ka ? kb : ka;
const secondFile = first === ka ? fb : fa;
// The track plays out (natural end) → the tick advances to the OTHER one.
scene.sound.stopByKey(first);
scene.tick();
check('when a track ends, the other track starts (no repeat)',
scene.plays.length === 2 && scene.plays[1].key === second);
// ...and the alternation keeps going.
scene.sound.stopByKey(second);
scene.tick();
check('... and the first track comes back on the next pass',
scene.plays.length === 3 && scene.plays[2].key === first);
// While a track is still playing, the tick does nothing.
scene.tick();
check('a tick while a track is still playing plays nothing new', scene.plays.length === 3);
// Stopping the shuffle kills the current track AND the timer.
stopMusicShuffleOn(scene);
const playsAtStop = scene.plays.length;
check('stopMusicShuffleOn stops the current track',
scene.stops[scene.stops.length - 1] === first);
scene.sound.stopByKey(first); // simulate the next natural end
scene.tick();
check('... and its progression timer is gone (no more track picks)',
scene.plays.length === playsAtStop);
}
// 15) Re-firing the shuffle (a scene restart) must not stack tracks.
{
const scene = makeShuffleScene();
startMusicShuffleOn(scene, MUSIC.game);
const first = scene.plays[0].key;
startMusicShuffleOn(scene, MUSIC.game); // restart
check('a restart stops the old track and starts ONE new one',
scene.stops.includes(first) && scene.plays.length === 2);
check('... and only one track is playing after the restart',
[gameTrackKey(MUSIC.game[0]), gameTrackKey(MUSIC.game[1])]
.filter((k) => scene.sound.isPlaying(k)).length === 1);
}
// 16) Shuffle guard rails: master switch, missing cache, no sound manager.
{
config.init({ music: { ...MUSIC, enabled: false }, landing: { ...LANDING } });
const off = makeShuffleScene();
startMusicShuffleOn(off, MUSIC.game);
check('music.enabled=false → the shuffle stays silent', off.plays.length === 0);
config.init({ music: { ...MUSIC }, landing: { ...LANDING } });
const [fa, fb] = MUSIC.game;
const missing = makeShuffleScene({ missing: [gameTrackKey(fa), gameTrackKey(fb)] });
startMusicShuffleOn(missing, MUSIC.game);
check('no usable track in the cache → silent, no throw', missing.plays.length === 0);
const noMgr = makeShuffleScene({ sound: false });
let threw = false;
try {
startMusicShuffleOn(noMgr, MUSIC.game);
} catch {
threw = true;
}
check('no sound manager → silent, no throw', !threw && noMgr.plays.length === 0);
}
// 17) GameScene.preload queues every game-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);
}
console.log(failures === 0 ? '\nAll music voice tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

158
dev/sfx.test.mjs Normal file
View File

@ -0,0 +1,158 @@
/**
* SFX voice test (dev tool, run with Node no browser needed):
*
* node dev/sfx.test.mjs
*
* Exercises the shared SFX voice (js/utils/Sfx.js) with a stub scene:
* playSfxOn() guard rails (master switch off, missing sound manager,
* asset not in the audio cache, the happy path, extra play options like
* loop) and the stop/status helpers (stopSfxOn / sfxPlayingOn). Also
* checks the sfx.json keys follow the play-name convention (sfx_<key>
* cache key) and point at files that exist.
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SFX = JSON.parse(fs.readFileSync(join(__dirname, '../data/sfx.json'), 'utf8'));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
config.init({ sfx: { ...SFX } });
const { playSfxOn, stopSfxOn, sfxPlayingOn } = await import(pathToFileURL(join(__dirname, '../js/utils/Sfx.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
/** A stub scene: sound manager + audio cache, recording play() calls. */
const makeScene = (o = {}) => {
const calls = [];
const stops = [];
let playing = false;
return {
calls,
stops,
sound:
o.sound === false
? null
: {
play: (key, opts) => {
calls.push({ key, opts });
playing = true;
},
stopByKey: (key) => {
stops.push(key);
playing = false;
},
isPlaying: (key) => playing && key === calls[calls.length - 1]?.key,
},
cache: o.noCache
? undefined
: { audio: o.noAudio ? undefined : { has: (k) => (o.inCache === false ? false : true) } },
};
};
// 1) Happy path: plays the sfx_ key at sfx.volume.
{
const scene = makeScene();
playSfxOn(scene, 'ui_click');
check('plays sfx_ui_click at sfx.volume',
scene.calls.length === 1 &&
scene.calls[0].key === 'sfx_ui_click' &&
scene.calls[0].opts.volume === SFX.volume);
}
// 2) Master switch off → silent, no throw.
{
const scene = makeScene();
config.init({ sfx: { ...SFX, enabled: false } });
playSfxOn(scene, 'ui_hover');
check('sfx.enabled=false → nothing plays', scene.calls.length === 0);
config.init({ sfx: { ...SFX } });
}
// 3) No sound manager (headless) → silent, no throw.
{
const scene = makeScene({ sound: false });
playSfxOn(scene, 'ui_click');
check('no sound manager → nothing plays', scene.calls.length === 0);
}
// 4) Asset not in the audio cache → silent, no throw (v4 play() would throw).
{
const scene = makeScene({ inCache: false });
playSfxOn(scene, 'ui_hover');
check('asset missing from cache → nothing plays', scene.calls.length === 0);
}
// 5) Cache object without an audio section → still plays (defensive guard).
{
const scene = makeScene({ noAudio: true });
playSfxOn(scene, 'ui_click');
check('cache without audio section → plays', scene.calls.length === 1);
}
// 6) data/sfx.json carries the UI ticks + the window whoosh + the close tick, pointing at real files.
{
const ok = ['ui_hover', 'ui_click', 'ui_window', 'ui_close'].every((k) => {
const p = SFX[k];
return typeof p === 'string' && fs.existsSync(join(__dirname, '..', p));
});
check('sfx.json ui_hover/ui_click/ui_window/ui_close point at files that exist', ok);
}
// 7) Extra play options (the mining hum's loop) pass through after the
// configured volume.
{
const scene = makeScene();
playSfxOn(scene, 'mining_loop', { loop: true });
check('playSfxOn passes extra options (loop) through with the sfx.volume default',
scene.calls.length === 1 &&
scene.calls[0].key === 'sfx_mining_loop' &&
scene.calls[0].opts.loop === true &&
scene.calls[0].opts.volume === SFX.volume);
}
// 8) stopSfxOn stops by the sfx_ cache key (v4 has no sound.stop()).
{
const scene = makeScene();
playSfxOn(scene, 'mining_loop', { loop: true });
stopSfxOn(scene, 'mining_loop');
check('stopSfxOn stops the sfx_ key', scene.stops.length === 1 && scene.stops[0] === 'sfx_mining_loop');
const noMgr = makeScene({ sound: false });
let threw = false;
try {
stopSfxOn(noMgr, 'mining_loop');
} catch (e) {
threw = true;
}
check('stopSfxOn with no sound manager → no throw', !threw);
}
// 9) sfxPlayingOn mirrors the manager's isPlaying (false when unsure).
{
const scene = makeScene();
check('sfxPlayingOn is false before anything plays', sfxPlayingOn(scene, 'mining_loop') === false);
playSfxOn(scene, 'mining_loop', { loop: true });
check('sfxPlayingOn is true while it plays', sfxPlayingOn(scene, 'mining_loop') === true);
stopSfxOn(scene, 'mining_loop');
check('sfxPlayingOn is false after stop', sfxPlayingOn(scene, 'mining_loop') === false);
const noMgr = makeScene({ sound: false });
check('sfxPlayingOn with no sound manager → false (no throw)', sfxPlayingOn(noMgr, 'mining_loop') === false);
}
// 10) The mining_loop key points at a real file.
{
const p = SFX.mining_loop;
check('sfx.json mining_loop points at a file that exists',
typeof p === 'string' && fs.existsSync(join(__dirname, '..', p)));
}
console.log(failures === 0 ? '\nAll SFX voice tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -105,6 +105,22 @@ const integrate = (ship) => {
ship.y += ship.body.velocity.y * s;
};
// --- Test 0: base stats (data/ship.json → stats) ---------------------------
{
const ship = new Ship(scene, 0, 0);
const s = config.section('ship.stats');
check('base stats loaded from config (hull/shields/cargo/minerals)',
ship.stats.hullIntegrity === s.hullIntegrity &&
ship.stats.shields === s.shields &&
ship.stats.cargoHold === s.cargoHold &&
ship.stats.mineralStorage === s.mineralStorage);
check('base stats are the shipped values (100 / 0 / 100 / 250)',
ship.stats.hullIntegrity === 100 &&
ship.stats.shields === 0 &&
ship.stats.cargoHold === 100 &&
ship.stats.mineralStorage === 250);
}
// --- Test 1: fly to a point, arrive and stop --------------------------------
{
const ship = new Ship(scene, 640, 360);

View File

@ -429,8 +429,19 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
s.played = [];
s.audio = Object.fromEntries(sfxNames.map((n) => [`sfx_${n}`, true])); // the real preload() puts them here
s.audioQueued = [];
s.sound = { play: (key, cfg) => s.played.push({ key, ...(cfg ?? {}) }) };
s.cache = { hasAudio: (key) => s.audio[key] === true };
s.sound = {
// Track which keys are "playing" so the shared voice's isPlaying()
// guard (one mining hum across retargets) is exercised for real.
_playing: new Set(),
play: (key, cfg) => { s.sound._playing.add(key); s.played.push({ key, ...(cfg ?? {}) }); },
isPlaying: (key) => s.sound._playing.has(key),
stopByKey: (key) => { s.sound._playing.delete(key); s.stopped.push(key); },
};
s.stopped = [];
s.ship = { stop() {}, setState() {} }; // onMiningPhase's stopped/extending phases
// v4 (Giedi) cache shape: cache.audio is a Cache with .has() —
// cache.hasAudio() does not exist in this build.
s.cache = { audio: { has: (key) => s.audio[key] === true } };
s.load = {
audio: (key, url) => s.audioQueued.push([key, url]),
spritesheet: () => {},
@ -445,11 +456,13 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
destroy() { this.destroyed = true; return this; },
});
// preload: every configured SFX at its configured path.
// preload: every configured SFX at its configured path. (Music queues
// separately — data/music.json → game — so count SFX keys only.)
GameScene.prototype.preload.call(s);
const sfxQueued = s.audioQueued.filter(([k]) => String(k).startsWith('sfx_'));
const queuedKey = (key) => s.audioQueued.find((e) => e[0] === key);
check('preload loads every configured SFX',
s.audioQueued.length === sfxNames.length &&
sfxQueued.length === sfxNames.length &&
sfxNames.every((n) => queuedKey(`sfx_${n}`)?.[1] === sfxJson[n]));
check('the configured SFX files exist in the repo',
sfxNames.every((n) => fs.existsSync(join(__dirname, '..', sfxJson[n]))));
@ -490,6 +503,28 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
check('the DISCOVERED toast still appears alongside the sound',
s.texts.some((t) => t.text === 'DISCOVERED — KETHRAL · PLANET'));
// mining hum: starts when the beam goes live (phase 'mining'), a
// retarget re-fires 'mining' without doubling it, stops on 'stopped'
// (quietly — stopping mining plays no one-shot sound). setMiningLoop
// rides onPhase → the real seam.
s.played = [];
s.sound._playing.clear();
s.stopped = [];
GameScene.prototype.onMiningPhase.call(s, 'mining');
check('beam live → the mining hum starts (looping, at sfx.volume)',
s.played.length === 1 &&
s.played[0].key === 'sfx_mining_loop' &&
s.played[0].loop === true &&
s.played[0].volume === sfxJson.volume);
GameScene.prototype.onMiningPhase.call(s, 'mining'); // a retarget re-fires it
check('a retarget re-fire keeps ONE hum (isPlaying guard)',
s.played.length === 1);
GameScene.prototype.onMiningPhase.call(s, 'stopped');
check('the sequence ends → the hum stops, quietly (no one-shot sound)',
s.stopped.length === 1 &&
s.stopped[0] === 'sfx_mining_loop' &&
s.played.length === 1); // only the hum from above — nothing new on stop
// guards: a missing asset or the master switch off → never plays, never throws.
s.played = [];
s.audio = {}; // e.g. the load was skipped
@ -503,7 +538,8 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
check('sfx.enabled=false → nothing plays', s.played.length === 0);
s.audioQueued = [];
GameScene.prototype.preload.call(s);
check('sfx.enabled=false → preload loads no audio', s.audioQueued.length === 0);
check('sfx.enabled=false → preload loads no SFX audio',
s.audioQueued.every(([k]) => !String(k).startsWith('sfx_')));
}
if (failures > 0) {

View File

@ -332,6 +332,42 @@ The player holds a REPUTATION (standing) on each planet and space station:
it whenever the camera has scrolled. (MenuButton never bit by this:
the menu camera doesn't move.) Set `scrollFactor(0)` on **every** child
of a screen-fixed UI container — done in `ActionBar.buildSlots`.
- **`sound.play()` throws on a missing key — and `cache.hasAudio()`
doesn't exist in this build.** Playing a key that never loaded is a
hard `Error: Audio key "…" not found in cache`, not a no-op, and the
`cache.hasAudio()` guard the old playSfx relied on is absent here. The
shared voice (`js/utils/Sfx.js → playSfxOn`) therefore checks
`scene.cache.audio.has(key)` directly (the real v4 API) before
playing, so a not-yet-loaded asset is a silent no-op — verified in the
browser (a `playSfx('ui_hover')` against an unloaded key threw through
the click handler until the guard was fixed).
- **The sound manager stops BY KEY — there is no `sound.stop()`.** In
this build the manager exposes `stopByKey(key)` / `stopAll()` (and
`isPlaying(key)` / `getAll(key)` / `getAllPlaying()`), but NOT a
keyless `stop()`. So stopping a looping SFX is
`scene.sound.stopByKey('sfx_<name>')` — the shared voice wraps it as
`js/utils/Sfx.js → stopSfxOn`. (`sound.play(key, { loop: true })`
DOES loop — verified: a 20 s clip kept playing past its natural end,
and stopped cleanly on `stopByKey`.)
- **A scene transition does NOT call `scene.shutdown()`.** The manager
stops a scene via `sys.shutdown()`, which flips the status and emits
the `shutdown` EVENT on the scene's emitter — the scene's own
`shutdown()` method is never invoked (verified: a wrapped
`MenuScene.shutdown` did not run on menu → game, while the
`'shutdown'` event fired). Scene-level cleanup that must run on
transition (stopping a music loop, …) therefore hooks the event:
`this.events.once('shutdown', …)` from `create()` (done for the
menu hum and the surface hum); the `shutdown()` methods stay as the
game-destroy path.
- **The surface is a LAUNCH + SLEEP, not a transition.** Landing does
`scene.launch('SurfaceScene')` and then `scene.sleep()` — a sleeping
scene never emits `shutdown` (verified: the game soundtrack and the
sound manager kept running through the surface until the scene's
`'sleep'` event was hooked). Take Off wakes the scene (`scene.wake`),
which emits `'wake'`. So GameScene's music + mining hum stop on
`'sleep'` and resume on `'wake'` (repeatable — `events.on`, not
`once`), while `'shutdown'` covers return-to-menu and destroy
(js/scenes/GameScene.js → create).
- **Ship art** — the starter ship is now a spritesheet frame:
`data/ship.json → texture` (assets/images/ships-player.png, 256×256
frames, `frame` 0), mirroring the planets pattern. The frame art faces
@ -377,6 +413,61 @@ The player holds a REPUTATION (standing) on each planet and space station:
Pure range math in `js/tether/Tether.js` (Node-tested), visuals +
constraint seam in `js/tether/TetherField.js`, tuning in
`data/tether.json`
- [x] UI hover/click ticks: `ui_hover` + `ui_click` in `data/sfx.json` play
on every clickable item (menu/deck buttons, compass name tags, panel
buttons, save slots) — hover on pointerover, click on press; the
SCAN slot skips `ui_click` (it plays its own sonar ping — `scan` in
`data/sfx.json`, now assets/fx/scan-01.mp3). Shared
voice in `js/utils/Sfx.js → playSfxOn` (both scenes expose
`playSfx(name)`; components call the `this.scene.playSfx?.(…)` seam),
with a v4 cache guard so a missing asset never throws
(js/utils/Sfx.js, dev/sfx.test.mjs)
- [x] Window whoosh: `ui_window` (assets/fx/ui-window.mp3) plays when a
window OPENS — the mining pop-up (`MiningPopup.open`) and the
comms / request-landing panel (`CommsPanel.open`) play it from
their own open seam (the `this.scene.playSfx?.(…)` convention);
the old `construct` plays at those two open sites were dropped so
the whoosh stands on its own
- [x] Close tick: `ui_close` (assets/fx/ui-close.mp3) plays when a UI
element is CANCELLED/CLOSED — the menu sub-bar (`MenuSubBar.close`),
the save vault (`SavePanel.close`) and the confirm dialog
(`ConfirmOverlay.startClose`); `type-deconstruct.mp3` is reserved
for text UNDECODING now (the dossier collapsing — the only
remaining `deconstruct` play), and stopping mining is silent
(the hum just ends — the old deconstruct blip on 'stopped' is gone)
- [x] Mining hum: `mining_loop` (assets/fx/mining-01.mp3) LOOPS while the
mining beam is live — starts when the arm finishes extending
(phase 'mining'), stops when the sequence ends (phase 'stopped'),
one hum across retargets (isPlaying guard). v4 quirk: the manager
stops by key — `sound.stopByKey()` (no `sound.stop()` in this build)
- [x] Music: `data/music.json` — the menu loops mainmenu.mp3 while the
main menu is up; in the game scene the DEEP-SPACE soundtrack
(music.game — a plain file list: deepspace-01/02 for now, adding a
track = adding a line) SHUFFLES: one track at its natural length
(loop OFF), the next picked at random when one ends (never the same
twice in a row) — the v4 build has no sound 'complete' event, so a
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);
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
end of the takeoff clip. Shared voice in `js/utils/Music.js`
(looped play + `stopByKey` + one-loop-per-track guard;
`music.enabled` / `music.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
dies on landing AND on returning to the menu
(js/utils/Music.js, dev/music.test.mjs). The soundtrack plays ONLY
in space: landing is a launch+sleep (not a shutdown — v4 quirk), so
it dies on the scene's `'sleep'` event and the shuffle restarts on
`'wake'` (Take Off) — the mining hum rides the same seam, so
neither leaks onto a world's surface. Also fixed the landing
frame hand-off — `startLanding` passed the texture `frame` OBJECT
(→ NaN); it now passes `sheetFrame`, so the land/surface/takeoff
clips are actually selected per world. intro-01.mp3 is parked for
the intro sequence that hasn't been built yet
(js/scenes/GameScene.js → setMiningLoop, data/sfx.json)
- [ ] Tether progression: the build/research verbs that anchor tethers on
planets/stations and upgrade levels (the `add`/`setLevel`/`onChange`
seams are in place; the costs + panel + research gate come next)
@ -413,6 +504,10 @@ The player holds a REPUTATION (standing) on each planet and space station:
- [ ] Build panel: pay credits/minerals, apply `effects`, respect
`requires`; the Build slot on the deck opens it; a credits/minerals
readout in the HUD
- [x] Ship base stats: `data/ship.json → stats` (hullIntegrity 100,
shields 0, cargoHold 100, mineralStorage 250), exposed as
`ship.stats` — combat/trading/mining systems will read them as
capacities; upgrades (ship-category builds) will layer deltas on top
- [ ] Ship screen (the Ship slot) — inspect & upgrade the ship
(ship-category builds) from one place
- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`:

View File

@ -19,6 +19,13 @@ import { toColor } from '../utils/Color.js';
* - rotSpeed how fast it turns toward its heading (rad/s)
* - brakeDistance where it starts easing off the throttle
* - arriveRadius / arriveSpeed when it considers itself "arrived"
*
* Base stats (data/ship.json stats, exposed as this.stats) are the
* ship's starting condition before any upgrades:
* - hullIntegrity the hull's max health (damage it can take)
* - shields damage absorbed in front of the hull
* - cargoHold goods capacity
* - mineralStorage minerals capacity
*/
export class Ship extends Phaser.Physics.Arcade.Sprite {
static TEXTURE_KEY = '__ship';
@ -85,6 +92,16 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
this.arriveRadius = config.get('ship.arriveRadius', 8); // px
this.arriveSpeed = config.get('ship.arriveSpeed', 50); // px/s
// Base stats (data/ship.json → stats) — the ship's starting
// condition, before any upgrades. Combat, trading, and mining
// systems will read these as capacities.
this.stats = {
hullIntegrity: config.get('ship.stats.hullIntegrity', 100),
shields: config.get('ship.stats.shields', 0),
cargoHold: config.get('ship.stats.cargoHold', 100),
mineralStorage: config.get('ship.stats.mineralStorage', 250),
};
// World size = size × scale. Sheet frames are frameWidth px wide; the
// dart is drawn at `size` px — the sprite scale maps either one onto
// the same world size, so both look identical.

View File

@ -8,6 +8,8 @@ import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Discovery } from '../galaxy/Discovery.js';
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
@ -119,7 +121,19 @@ export class GameScene extends Phaser.Scene {
this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3'));
this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3'));
this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3'));
this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/system-scan.mp3'));
this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3'));
this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3'));
this.load.audio('sfx_ui_hover', config.get('sfx.ui_hover', 'assets/fx/ui-hover.mp3'));
this.load.audio('sfx_ui_click', config.get('sfx.ui_click', 'assets/fx/ui-click.mp3'));
this.load.audio('sfx_ui_window', config.get('sfx.ui_window', 'assets/fx/ui-window.mp3'));
this.load.audio('sfx_ui_close', config.get('sfx.ui_close', 'assets/fx/ui-close.mp3'));
}
// The deep-space soundtrack (data/music.json → game) — the shuffled
// playlist. Each file queues under its derived key (music_deepspace_01…).
const gameTracks = config.get('music.game', []);
if (config.get('music.enabled', true) && Array.isArray(gameTracks)) {
for (const f of gameTracks) this.load.audio(gameTrackKey(f), f);
}
}
@ -133,6 +147,28 @@ export class GameScene extends Phaser.Scene {
// a LOAD replaces it with the saved value (applyRestore).
this.playTimeMs = 0;
// The deep-space soundtrack — the data/music.json (game) playlist,
// shuffled: one track at a time, next picked when one 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.startGameMusic();
this.events.once('shutdown', () => {
this.stopGameMusic();
this.setMiningLoop(false); // the hum can't outlive the scene
});
this.events.on('sleep', () => {
this.stopGameMusic(); // no space hum on a world's surface
this.setMiningLoop(false); // …nor the mining hum
});
this.events.on('wake', () => {
this.startGameMusic(); // back in space — the shuffle starts fresh
if (this.mining?.state === 'mining') this.setMiningLoop(true); // the beam is still live
});
// We are, after all, in a system. Establish the galaxy (shared
// registry), the current system record, and its lazy contents — the
// dossier below and the world's other planets both read them.
@ -1162,7 +1198,7 @@ export class GameScene extends Phaser.Scene {
*/
openMiningMenu(rock, pointer) {
const isTarget = this.mining.state === 'mining' && this.mining.cluster === rock.cluster;
this.playSfx('construct'); // the menu decodes in
// The pop-up plays its own ui_window whoosh (MiningPopup.open).
this.miningPopup.open(rock.x, rock.y, pointer.y, {
name: rock.cluster.discoveryName,
primaryId: isTarget ? 'stop' : 'mine',
@ -1277,7 +1313,6 @@ export class GameScene extends Phaser.Scene {
*/
openCommsPanel(obj, pointer) {
const t = this.commsTargetFor(obj);
this.playSfx('construct'); // the panel decodes in
// The FULL target goes through the panel — lastTarget comes back to
// commsAction() and needs `isPlanet`/`frame` (the landing handoff),
// not just the display fields.
@ -1332,8 +1367,11 @@ export class GameScene extends Phaser.Scene {
this.hideHint();
// A previous surface (left sleeping by an earlier Take Off) is shut
// down + restarted by this — the fresh init gets this world's frame.
// sheetFrame is the raw planets.png index (`.frame` is the texture
// Frame OBJECT — Number() of it is NaN, and the video/music lookups
// need the number).
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? 0),
frame: Number(target.sheetFrame ?? 0),
name: target.name,
type: target.kindLabel ?? '',
tetherLevel: this.tetherLevelFor(target.name),
@ -1377,28 +1415,53 @@ export class GameScene extends Phaser.Scene {
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: this.mining.armExtendMs,
});
} else if (phase === 'mining') {
this.setMiningLoop(true); // the beam is live — the hum runs until the sequence ends
} else if (phase === 'stopped') {
this.playSfx('deconstruct'); // the arm pulls back
this.setMiningLoop(false); // the arm pulls back — the hum ends with it (no one-shot sound)
// Back to normal — a no-op when the ship already left 'mining'
// itself (the player moved it, which ended the sequence).
this.ship.setState('normal', 'mining-ended');
}
// 'mining' = the beam is live — the visual speaks for itself.
}
/**
* The mining hum (data/sfx.json mining_loop, assets/fx/mining-01.mp3):
* a loop that starts when the beam goes live (phase 'mining') and
* stops when the sequence ends (phase 'stopped'). A retarget re-fires
* 'mining' without a 'stopped' in between keep ONE hum across
* retargets (the isPlaying guard). No-ops when SFX are disabled or the
* asset is missing (the shared voice's guards, js/utils/Sfx.js).
*/
setMiningLoop(on) {
if (on) {
if (!config.get('sfx.enabled', true)) return;
if (sfxPlayingOn(this, 'mining_loop')) return; // already humming (a retarget)
playSfxOn(this, 'mining_loop', { loop: true });
} else {
stopSfxOn(this, 'mining_loop');
}
}
/**
* Play one of the configured sound effects (data/sfx.json).
* Silently no-ops when SFX are disabled, the sound manager isn't
* available (headless/test), or the asset never loaded the game
* never blocks or warns on sound.
* never blocks or warns on sound. (Shared voice: js/utils/Sfx.js.)
*/
playSfx(name) {
if (!config.get('sfx.enabled', true)) return;
const snd = this.sound;
if (!snd || typeof snd.play !== 'function') return;
const key = `sfx_${name}`;
if (this.cache && typeof this.cache.hasAudio === 'function' && !this.cache.hasAudio(key)) return;
snd.play(key, { volume: config.get('sfx.volume', 0.55) });
playSfxOn(this, name);
}
/** The deep-space soundtrack (data/music.json game): shuffled tracks,
* one at a time, advancing when a track ends. Clean to re-fire a
* restart stops the old track and its timer first. */
startGameMusic() {
startMusicShuffleOn(this, config.get('music.game', []));
}
stopGameMusic() {
stopMusicShuffleOn(this);
}
/** The "new object" moment: a rim ping at the world + a HUD toast. */
@ -1713,6 +1776,8 @@ export class GameScene extends Phaser.Scene {
}
shutdown() {
this.setMiningLoop(false); // the hum can't outlive the scene
this.stopGameMusic(); // neither can the soundtrack
this.starfield?.destroy();
this.compass?.destroy();
this.actionBar?.destroy();

View File

@ -10,6 +10,8 @@ import { CyberShape } from '../ui/CyberShape.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js';
import { ScrambleDecode, DECODE_DURATION } from '../utils/Decode.js';
import { playSfxOn } from '../utils/Sfx.js';
import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { SaveManager } from '../save/SaveManager.js';
import { resetRunState } from '../save/SaveData.js';
@ -42,7 +44,50 @@ export class MenuScene extends Phaser.Scene {
super({ key: 'MenuScene' });
}
preload() {
// Sound effects (data/sfx.json → enabled) — the menu's buttons and
// the save pop-up's decode ticks need their voices queued here (the
// menu runs before GameScene, so its load is what makes them exist
// on first use). Skipped entirely when the master switch is off —
// no load cost, no files fetched.
if (config.get('sfx.enabled', true)) {
this.load.audio('sfx_construct', config.get('sfx.construct', 'assets/fx/type-construct.mp3'));
this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3'));
this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3'));
this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3'));
this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3'));
this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3'));
this.load.audio('sfx_ui_hover', config.get('sfx.ui_hover', 'assets/fx/ui-hover.mp3'));
this.load.audio('sfx_ui_click', config.get('sfx.ui_click', 'assets/fx/ui-click.mp3'));
this.load.audio('sfx_ui_window', config.get('sfx.ui_window', 'assets/fx/ui-window.mp3'));
this.load.audio('sfx_ui_close', config.get('sfx.ui_close', 'assets/fx/ui-close.mp3'));
}
// Music (data/music.json → enabled): the menu's loop (the menu runs
// before GameScene, so its load is what makes it exist on first use).
if (config.get('music.enabled', true)) {
const menuTrack = config.get('music.menu');
if (typeof menuTrack === 'string') {
this.load.audio(musicKey('music.menu'), menuTrack);
}
}
}
/**
* Play one of the configured sound effects (data/sfx.json) the
* shared voice (js/utils/Sfx.js); silently no-ops when SFX are
* disabled or the asset isn't loaded.
*/
playSfx(name) {
playSfxOn(this, name);
}
create() {
playMusicOn(this, 'music.menu'); // the menu hum (loop) — starts here, dies on shutdown
// v4 (Giedi) quirk: scene transitions stop us via sys.shutdown and emit
// the 'shutdown' EVENT — they never call our shutdown() method — so the
// hum's stop is hooked on the event (the method stays for game destroy).
this.events.once('shutdown', () => stopMusicOn(this, 'music.menu'));
const menu = config.section('menu', {});
const theme = config.section('theme', {});
const colors = menu.colors ?? {};
@ -540,6 +585,7 @@ export class MenuScene extends Phaser.Scene {
shutdown() {
this.dead = true;
stopMusicOn(this, 'music.menu'); // the menu hum can't outlive the scene
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
this.titleBloom?.destroy();
this.overlay?.destroy();

View File

@ -3,6 +3,7 @@ import { config } from '../config/Config.js';
import { toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js';
import { ActionBar } from '../ui/ActionBar.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
@ -48,6 +49,7 @@ export class SurfaceScene extends Phaser.Scene {
this.planetType = String(data.type ?? ''); // e.g. "Rocky World" (planets.typeLabels)
this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0)));
this.phase = 'landing'; // 'landing' → 'surface'
this.musicSpec = 'music.frames.' + this.planetFrame; // the surface loop (data/music.json)
this.gameScene = null; // the paused GameScene beneath us (save state lives there)
this.backdrop = null;
this.landVideo = null;
@ -79,6 +81,16 @@ export class SurfaceScene extends Phaser.Scene {
if (this.landUrl) this.load.video(this.landKey, this.landUrl);
if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl);
if (this.takeoffUrl) this.load.video(this.takeoffKey, this.takeoffUrl);
// The surface's music loop (data/music.json → frames[planets.png frame])
// — the same key as the videos, so a world hums the track matching the
// art it is drawn with. A frame with no entry is silent.
if (config.get('music.enabled', true)) {
const track = config.get(this.musicSpec);
if (typeof track === 'string') {
this.load.audio(musicKey(this.musicSpec), track);
}
}
}
create() {
@ -92,6 +104,15 @@ export class SurfaceScene extends Phaser.Scene {
// opaque full-screen plate, or GameScene bleeds through the seams.
this.backdrop = this.add.rectangle(W / 2, H / 2, W, H, 0x04060d).setScrollFactor(0).setDepth(0);
// The surface hum — on from the moment the landing sequence starts
// (landing clip, or straight to the deck when there is no clip) until
// the takeoff clip ends (finishTakeoff) or the scene leaves otherwise.
this.setSurfaceMusic(true);
// v4 (Giedi) quirk: scene transitions stop us via sys.shutdown and emit
// the 'shutdown' EVENT — they never call our shutdown() method — so the
// hum's stop is also hooked on the event (Return to Menu path).
this.events.once('shutdown', () => this.setSurfaceMusic(false));
if (this.hasVideo(this.landKey)) {
this.playLanding();
} else {
@ -416,10 +437,22 @@ export class SurfaceScene extends Phaser.Scene {
}
}
/**
* The surface's music (data/music.json frames[this.planetFrame]): a
* loop that runs from the start of the landing sequence to the end of
* the takeoff clip. One loop per world (the shared voice's isPlaying
* guard); no-ops when music is disabled or the frame has no track.
*/
setSurfaceMusic(on) {
if (on) playMusicOn(this, this.musicSpec);
else stopMusicOn(this, this.musicSpec);
}
/** Take-off clip done (or bailed out) — back to the flight world. */
finishTakeoff() {
if (this.takeoffDone) return;
this.takeoffDone = true;
this.setSurfaceMusic(false); // the takeoff clip ends — the hum ends with it
this.destroyVideo(this.takeoffVideo);
this.takeoffVideo = null;
this.scene.stop('SurfaceScene');
@ -532,6 +565,7 @@ export class SurfaceScene extends Phaser.Scene {
}
shutdown() {
this.setSurfaceMusic(false); // the hum can't outlive the scene
this.destroyVideo(this.landVideo);
this.destroyVideo(this.surfaceVideo);
this.destroyVideo(this.takeoffVideo);

View File

@ -399,6 +399,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
setHover(s, on) {
if (!s.live || this.dead) return;
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
s.hoverOn = on;
this.paintSlot(s, on ? 'hover' : 'base');
@ -432,6 +433,8 @@ export class ActionBar extends Phaser.GameObjects.Container {
/** Click feedback: white flash + scale punch, then restore. Fires onAction. */
press(s) {
if (!s.live || s.pressing || this.dead) return;
// SCAN plays its own sonar ping (the scene's onAction) — no double tick.
if (s.id !== 'scan') this.scene.playSfx?.('ui_click');
s.pressing = true;
this.paintSlot(s, 'press');
this.scene.tweens.add({

View File

@ -87,6 +87,7 @@ class TermButton extends Phaser.GameObjects.Container {
this.box.on('pointerout', () => this.hover(false));
this.box.on('pointerdown', () => {
if (this.disabled) return;
this.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
this.press();
if (typeof onFire === 'function') onFire();
});
@ -141,6 +142,7 @@ class TermButton extends Phaser.GameObjects.Container {
hover(on) {
if (this.disabled) return;
this.hoverOn = on;
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
this.paint();
}
@ -324,6 +326,7 @@ export class CommsPanel extends Phaser.GameObjects.Container {
*/
open(wx, wy, sx, sy, o = {}) {
if (this.scene === null || this.active === false) return;
this.scene.playSfx?.('ui_window'); // the window whoosh (the scene is the voice)
const name = String(o.name ?? 'UNKNOWN').toUpperCase();
this.settled = !!o.settled;
this.rep = Math.round(o.reputation ?? 0);

View File

@ -211,7 +211,7 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
if (this.state === 'hidden' || this.state === 'closing') return;
this.state = 'closing';
this.t0 = this.lastTime ?? this.scene.time.now;
this.scene.playSfx?.('deconstruct');
this.scene.playSfx?.('ui_close'); // the close tick (deconstruct is the text's sound)
}
/** @returns {boolean} true while the dialog is up (blocks the modal behind) */

View File

@ -234,6 +234,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
/** Repaint the chip's edge for 'base' | 'hover'. */
paintChip(e, state) {
const hover = state === 'hover';
if (hover) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
e.chip.clear();
CyberShape.draw(e.chip, e.w, e.h, {
notch: Math.min(8, e.h * 0.3),
@ -250,6 +251,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
/** Press feedback, then the autopilot callback. */
pressChip(e, id) {
this.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
e.chipRoot.setAlpha(0.55);
this.scene.tweens.add({ targets: e.chipRoot, alpha: 1, duration: 260, ease: 'Sine.easeOut' });
this.scene.tweens.add({ targets: e.arrow, scale: 1.3, duration: 110, yoyo: true, ease: 'Sine.easeOut' });

View File

@ -116,6 +116,7 @@ export class MenuButton extends Phaser.GameObjects.Container {
this.panel.on('pointerout', () => this.hover(false));
this.panel.on('pointerdown', () => {
if (this.disabled) return;
this.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
this.press();
if (typeof onClick === 'function') onClick(this);
});
@ -174,6 +175,7 @@ export class MenuButton extends Phaser.GameObjects.Container {
if (this.disabled) return;
this.hoverOn = on;
this.paint(on ? 'hover' : 'base');
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
if (this._sweepTween) {
this._sweepTween.remove();

View File

@ -248,7 +248,7 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
if (i > 0) s._dec.t0 = this.t0 + i * 25;
});
this.spawnSlices(0.8);
this.scene.playSfx?.('deconstruct');
this.scene.playSfx?.('ui_close'); // the close tick (deconstruct is the text's sound)
}
/** Instantly kill the bar (scene transitions). */

View File

@ -169,6 +169,7 @@ export class MiningPopup extends Phaser.GameObjects.Container {
*/
open(wx, wy, screenY, o = {}) {
if (this.scene === null || this.active === false) return;
this.scene.playSfx?.('ui_window'); // the window whoosh (the scene is the voice)
this.lastTarget = o.target ?? null;
const name = String(o.name ?? 'ASTEROID CLUSTER').toUpperCase();
const primaryLabel = String(o.primaryLabel ?? 'MINE ASTEROIDS').toUpperCase();

View File

@ -358,7 +358,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
setInteractiveEnabled(this.scrim, false);
setInteractiveEnabled(this.cancelBtn.panel, false);
setInteractiveEnabled(this.downloadBtn.panel, false);
this.scene.playSfx?.('deconstruct');
this.scene.playSfx?.('ui_close'); // the close tick (deconstruct is the text's sound)
}
// ------------------------------------------------------------------

View File

@ -279,6 +279,7 @@ export class SlotCard extends Phaser.GameObjects.Container {
setHover(on) {
if (this.disabled) return;
this.hoverOn = on;
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
this.paint(on ? 'hover' : (this.filled ? 'filled' : 'empty'));
if (this._sweepTween) {
this._sweepTween.remove();
@ -301,6 +302,7 @@ export class SlotCard extends Phaser.GameObjects.Container {
press() {
if (this.disabled || this.dead || this.locked) return;
this.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
// Punch + fire — the MenuButton's click language.
this.scene.tweens.add({ targets: this, scale: 0.965, duration: 70, yoyo: true, ease: 'Sine.easeOut' });
if (typeof this.onClick === 'function') {

144
js/utils/Music.js Normal file
View File

@ -0,0 +1,144 @@
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
},
});
}
}

46
js/utils/Sfx.js Normal file
View File

@ -0,0 +1,46 @@
import { config } from '../config/Config.js';
/**
* The shared SFX voice (data/sfx.json).
*
* Scenes expose a `playSfx(name)` method (MenuScene / GameScene) that
* delegates here, and UI components call it through the scene seam
* (`this.scene.playSfx?.('ui_hover')`) one voice, one place (the same
* rule ConfirmOverlay follows for the construct ticks).
*
* Convention: `name` is the data/sfx.json key, and the asset is queued
* as `sfx_<name>` (the play name resolves to the cache key directly
* the same rule the existing construct/scan/ voices follow).
*
* Guards, in order: the master switch (sfx.enabled), the sound manager
* (absent in headless), and the audio cache v4 (Giedi) quirk:
* `sound.play()` THROWS on a key that never loaded, and
* `cache.hasAudio()` does not exist in this build, so we check
* `cache.audio.has()` directly. The game never blocks or warns on sound.
*/
export function playSfxOn(scene, name, o = {}) {
if (!scene || !config.get('sfx.enabled', true)) return;
const snd = scene.sound;
if (!snd || typeof snd.play !== 'function') return;
const key = `sfx_${name}`;
if (scene.cache && scene.cache.audio && typeof scene.cache.audio.has === 'function' && !scene.cache.audio.has(key)) return;
snd.play(key, { volume: config.get('sfx.volume', 0.55), ...o });
}
/**
* Stop one of the configured SFX by its data/sfx.json key (a no-op when
* it isn't playing). v4 (Giedi) API: the manager stops BY KEY
* `sound.stop()` does not exist in this build.
*/
export function stopSfxOn(scene, name) {
const snd = scene && scene.sound;
if (!snd || typeof snd.stopByKey !== 'function') return;
snd.stopByKey(`sfx_${name}`);
}
/** Is one of the configured SFX playing right now? (false when unsure). */
export function sfxPlayingOn(scene, name) {
const snd = scene && scene.sound;
if (!snd || typeof snd.isPlaying !== 'function') return false;
return snd.isPlaying(`sfx_${name}`) === true;
}