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:
parent
cf999a14fa
commit
9ca0fa55a8
|
|
@ -91,7 +91,7 @@ orbit/
|
||||||
│ ├── game.json # dimensions, colors, starfield, …
|
│ ├── game.json # dimensions, colors, starfield, …
|
||||||
│ ├── menu.json # menu text, colors, button layout, seed panel
|
│ ├── menu.json # menu text, colors, button layout, seed panel
|
||||||
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
|
│ ├── 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
|
│ ├── planets.json # home world + system layout + solid-disc rules
|
||||||
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
|
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
|
||||||
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
|
│ ├── 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/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/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/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),
|
`dev/test-game.html` boots straight into the GameScene (no menu click),
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -18,6 +18,7 @@
|
||||||
"actionbar.json",
|
"actionbar.json",
|
||||||
"landing.json",
|
"landing.json",
|
||||||
"sfx.json",
|
"sfx.json",
|
||||||
|
"music.json",
|
||||||
"scan.json",
|
"scan.json",
|
||||||
"signalCompass.json",
|
"signalCompass.json",
|
||||||
"save.json"
|
"save.json"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"title": "ORBIT",
|
"title": "ORBIT",
|
||||||
"titleFontSize": 118,
|
"titleFontSize": 118,
|
||||||
"titleLetterSpacing": 12,
|
"titleLetterSpacing": 12,
|
||||||
"subtitle": "A PROCEDURAL SPACE RPG",
|
"subtitle": "THE GALAXY AWAITS",
|
||||||
"subtitleFontSize": 20,
|
"subtitleFontSize": 20,
|
||||||
"subtitleLetterSpacing": 8,
|
"subtitleLetterSpacing": 8,
|
||||||
"buttonFontSize": 26,
|
"buttonFontSize": 26,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
"enabled": true,
|
||||||
"volume": 0.55,
|
"volume": 0.55,
|
||||||
"construct": "assets/fx/type-construct.mp3",
|
"construct": "assets/fx/type-construct.mp3",
|
||||||
"deconstruct": "assets/fx/type-deconstruct.mp3",
|
"deconstruct": "assets/fx/type-deconstruct.mp3",
|
||||||
"discovery": "assets/fx/discovery.mp3",
|
"discovery": "assets/fx/discovery.mp3",
|
||||||
"mining": "assets/fx/system-scan.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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,12 @@
|
||||||
"rotSpeed": 10,
|
"rotSpeed": 10,
|
||||||
"brakeDistance": 260,
|
"brakeDistance": 260,
|
||||||
"arriveRadius": 8,
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -105,6 +105,22 @@ const integrate = (ship) => {
|
||||||
ship.y += ship.body.velocity.y * s;
|
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 --------------------------------
|
// --- Test 1: fly to a point, arrive and stop --------------------------------
|
||||||
{
|
{
|
||||||
const ship = new Ship(scene, 640, 360);
|
const ship = new Ship(scene, 640, 360);
|
||||||
|
|
|
||||||
|
|
@ -429,8 +429,19 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
|
||||||
s.played = [];
|
s.played = [];
|
||||||
s.audio = Object.fromEntries(sfxNames.map((n) => [`sfx_${n}`, true])); // the real preload() puts them here
|
s.audio = Object.fromEntries(sfxNames.map((n) => [`sfx_${n}`, true])); // the real preload() puts them here
|
||||||
s.audioQueued = [];
|
s.audioQueued = [];
|
||||||
s.sound = { play: (key, cfg) => s.played.push({ key, ...(cfg ?? {}) }) };
|
s.sound = {
|
||||||
s.cache = { hasAudio: (key) => s.audio[key] === true };
|
// 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 = {
|
s.load = {
|
||||||
audio: (key, url) => s.audioQueued.push([key, url]),
|
audio: (key, url) => s.audioQueued.push([key, url]),
|
||||||
spritesheet: () => {},
|
spritesheet: () => {},
|
||||||
|
|
@ -445,11 +456,13 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
|
||||||
destroy() { this.destroyed = true; return this; },
|
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);
|
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);
|
const queuedKey = (key) => s.audioQueued.find((e) => e[0] === key);
|
||||||
check('preload loads every configured SFX',
|
check('preload loads every configured SFX',
|
||||||
s.audioQueued.length === sfxNames.length &&
|
sfxQueued.length === sfxNames.length &&
|
||||||
sfxNames.every((n) => queuedKey(`sfx_${n}`)?.[1] === sfxJson[n]));
|
sfxNames.every((n) => queuedKey(`sfx_${n}`)?.[1] === sfxJson[n]));
|
||||||
check('the configured SFX files exist in the repo',
|
check('the configured SFX files exist in the repo',
|
||||||
sfxNames.every((n) => fs.existsSync(join(__dirname, '..', sfxJson[n]))));
|
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',
|
check('the DISCOVERED toast still appears alongside the sound',
|
||||||
s.texts.some((t) => t.text === 'DISCOVERED — KETHRAL · PLANET'));
|
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.
|
// guards: a missing asset or the master switch off → never plays, never throws.
|
||||||
s.played = [];
|
s.played = [];
|
||||||
s.audio = {}; // e.g. the load was skipped
|
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);
|
check('sfx.enabled=false → nothing plays', s.played.length === 0);
|
||||||
s.audioQueued = [];
|
s.audioQueued = [];
|
||||||
GameScene.prototype.preload.call(s);
|
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) {
|
if (failures > 0) {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
it whenever the camera has scrolled. (MenuButton never bit by this:
|
||||||
the menu camera doesn't move.) Set `scrollFactor(0)` on **every** child
|
the menu camera doesn't move.) Set `scrollFactor(0)` on **every** child
|
||||||
of a screen-fixed UI container — done in `ActionBar.buildSlots`.
|
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:
|
- **Ship art** — the starter ship is now a spritesheet frame:
|
||||||
`data/ship.json → texture` (assets/images/ships-player.png, 256×256
|
`data/ship.json → texture` (assets/images/ships-player.png, 256×256
|
||||||
frames, `frame` 0), mirroring the planets pattern. The frame art faces
|
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 +
|
Pure range math in `js/tether/Tether.js` (Node-tested), visuals +
|
||||||
constraint seam in `js/tether/TetherField.js`, tuning in
|
constraint seam in `js/tether/TetherField.js`, tuning in
|
||||||
`data/tether.json`
|
`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
|
- [ ] Tether progression: the build/research verbs that anchor tethers on
|
||||||
planets/stations and upgrade levels (the `add`/`setLevel`/`onChange`
|
planets/stations and upgrade levels (the `add`/`setLevel`/`onChange`
|
||||||
seams are in place; the costs + panel + research gate come next)
|
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
|
- [ ] Build panel: pay credits/minerals, apply `effects`, respect
|
||||||
`requires`; the Build slot on the deck opens it; a credits/minerals
|
`requires`; the Build slot on the deck opens it; a credits/minerals
|
||||||
readout in the HUD
|
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 screen (the Ship slot) — inspect & upgrade the ship
|
||||||
(ship-category builds) from one place
|
(ship-category builds) from one place
|
||||||
- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`:
|
- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ import { toColor } from '../utils/Color.js';
|
||||||
* - rotSpeed → how fast it turns toward its heading (rad/s)
|
* - rotSpeed → how fast it turns toward its heading (rad/s)
|
||||||
* - brakeDistance → where it starts easing off the throttle
|
* - brakeDistance → where it starts easing off the throttle
|
||||||
* - arriveRadius / arriveSpeed → when it considers itself "arrived"
|
* - 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 {
|
export class Ship extends Phaser.Physics.Arcade.Sprite {
|
||||||
static TEXTURE_KEY = '__ship';
|
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.arriveRadius = config.get('ship.arriveRadius', 8); // px
|
||||||
this.arriveSpeed = config.get('ship.arriveSpeed', 50); // px/s
|
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
|
// World size = size × scale. Sheet frames are frameWidth px wide; the
|
||||||
// dart is drawn at `size` px — the sprite scale maps either one onto
|
// dart is drawn at `size` px — the sprite scale maps either one onto
|
||||||
// the same world size, so both look identical.
|
// the same world size, so both look identical.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { formatSystemReport } from '../galaxy/SystemReport.js';
|
||||||
import { Discovery } from '../galaxy/Discovery.js';
|
import { Discovery } from '../galaxy/Discovery.js';
|
||||||
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
|
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
|
||||||
import { ScrambleDecode, decodeDur } from '../utils/Decode.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 { Ship } from '../entities/Ship.js';
|
||||||
import { Planet } from '../entities/Planet.js';
|
import { Planet } from '../entities/Planet.js';
|
||||||
import { AsteroidCluster } from '../entities/AsteroidCluster.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_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_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', 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).
|
// a LOAD replaces it with the saved value (applyRestore).
|
||||||
this.playTimeMs = 0;
|
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
|
// We are, after all, in a system. Establish the galaxy (shared
|
||||||
// registry), the current system record, and its lazy contents — the
|
// registry), the current system record, and its lazy contents — the
|
||||||
// dossier below and the world's other planets both read them.
|
// dossier below and the world's other planets both read them.
|
||||||
|
|
@ -1162,7 +1198,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
*/
|
*/
|
||||||
openMiningMenu(rock, pointer) {
|
openMiningMenu(rock, pointer) {
|
||||||
const isTarget = this.mining.state === 'mining' && this.mining.cluster === rock.cluster;
|
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, {
|
this.miningPopup.open(rock.x, rock.y, pointer.y, {
|
||||||
name: rock.cluster.discoveryName,
|
name: rock.cluster.discoveryName,
|
||||||
primaryId: isTarget ? 'stop' : 'mine',
|
primaryId: isTarget ? 'stop' : 'mine',
|
||||||
|
|
@ -1277,7 +1313,6 @@ export class GameScene extends Phaser.Scene {
|
||||||
*/
|
*/
|
||||||
openCommsPanel(obj, pointer) {
|
openCommsPanel(obj, pointer) {
|
||||||
const t = this.commsTargetFor(obj);
|
const t = this.commsTargetFor(obj);
|
||||||
this.playSfx('construct'); // the panel decodes in
|
|
||||||
// The FULL target goes through the panel — lastTarget comes back to
|
// The FULL target goes through the panel — lastTarget comes back to
|
||||||
// commsAction() and needs `isPlanet`/`frame` (the landing handoff),
|
// commsAction() and needs `isPlanet`/`frame` (the landing handoff),
|
||||||
// not just the display fields.
|
// not just the display fields.
|
||||||
|
|
@ -1332,8 +1367,11 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.hideHint();
|
this.hideHint();
|
||||||
// A previous surface (left sleeping by an earlier Take Off) is shut
|
// A previous surface (left sleeping by an earlier Take Off) is shut
|
||||||
// down + restarted by this — the fresh init gets this world's frame.
|
// 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', {
|
this.scene.launch('SurfaceScene', {
|
||||||
frame: Number(target.frame ?? 0),
|
frame: Number(target.sheetFrame ?? 0),
|
||||||
name: target.name,
|
name: target.name,
|
||||||
type: target.kindLabel ?? '',
|
type: target.kindLabel ?? '',
|
||||||
tetherLevel: this.tetherLevelFor(target.name),
|
tetherLevel: this.tetherLevelFor(target.name),
|
||||||
|
|
@ -1377,28 +1415,53 @@ export class GameScene extends Phaser.Scene {
|
||||||
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
|
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
|
||||||
durationMs: this.mining.armExtendMs,
|
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') {
|
} 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'
|
// Back to normal — a no-op when the ship already left 'mining'
|
||||||
// itself (the player moved it, which ended the sequence).
|
// itself (the player moved it, which ended the sequence).
|
||||||
this.ship.setState('normal', 'mining-ended');
|
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).
|
* Play one of the configured sound effects (data/sfx.json).
|
||||||
* Silently no-ops when SFX are disabled, the sound manager isn't
|
* Silently no-ops when SFX are disabled, the sound manager isn't
|
||||||
* available (headless/test), or the asset never loaded — the game
|
* 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) {
|
playSfx(name) {
|
||||||
if (!config.get('sfx.enabled', true)) return;
|
playSfxOn(this, name);
|
||||||
const snd = this.sound;
|
}
|
||||||
if (!snd || typeof snd.play !== 'function') return;
|
|
||||||
const key = `sfx_${name}`;
|
/** The deep-space soundtrack (data/music.json → game): shuffled tracks,
|
||||||
if (this.cache && typeof this.cache.hasAudio === 'function' && !this.cache.hasAudio(key)) return;
|
* one at a time, advancing when a track ends. Clean to re-fire — a
|
||||||
snd.play(key, { volume: config.get('sfx.volume', 0.55) });
|
* 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. */
|
/** The "new object" moment: a rim ping at the world + a HUD toast. */
|
||||||
|
|
@ -1713,6 +1776,8 @@ export class GameScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
|
this.setMiningLoop(false); // the hum can't outlive the scene
|
||||||
|
this.stopGameMusic(); // neither can the soundtrack
|
||||||
this.starfield?.destroy();
|
this.starfield?.destroy();
|
||||||
this.compass?.destroy();
|
this.compass?.destroy();
|
||||||
this.actionBar?.destroy();
|
this.actionBar?.destroy();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { CyberShape } from '../ui/CyberShape.js';
|
||||||
import { Rng } from '../utils/Rng.js';
|
import { Rng } from '../utils/Rng.js';
|
||||||
import { NameGenerator } from '../utils/NameGenerator.js';
|
import { NameGenerator } from '../utils/NameGenerator.js';
|
||||||
import { ScrambleDecode, DECODE_DURATION } from '../utils/Decode.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 { Galaxy } from '../galaxy/Galaxy.js';
|
||||||
import { SaveManager } from '../save/SaveManager.js';
|
import { SaveManager } from '../save/SaveManager.js';
|
||||||
import { resetRunState } from '../save/SaveData.js';
|
import { resetRunState } from '../save/SaveData.js';
|
||||||
|
|
@ -42,7 +44,50 @@ export class MenuScene extends Phaser.Scene {
|
||||||
super({ key: 'MenuScene' });
|
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() {
|
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 menu = config.section('menu', {});
|
||||||
const theme = config.section('theme', {});
|
const theme = config.section('theme', {});
|
||||||
const colors = menu.colors ?? {};
|
const colors = menu.colors ?? {};
|
||||||
|
|
@ -540,6 +585,7 @@ export class MenuScene extends Phaser.Scene {
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
this.dead = true;
|
this.dead = true;
|
||||||
|
stopMusicOn(this, 'music.menu'); // the menu hum can't outlive the scene
|
||||||
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
|
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
|
||||||
this.titleBloom?.destroy();
|
this.titleBloom?.destroy();
|
||||||
this.overlay?.destroy();
|
this.overlay?.destroy();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { config } from '../config/Config.js';
|
||||||
import { toCss } from '../utils/Color.js';
|
import { toCss } from '../utils/Color.js';
|
||||||
import { fontStack, themeColor } from '../utils/Theme.js';
|
import { fontStack, themeColor } from '../utils/Theme.js';
|
||||||
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
|
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
|
||||||
|
import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js';
|
||||||
import { ActionBar } from '../ui/ActionBar.js';
|
import { ActionBar } from '../ui/ActionBar.js';
|
||||||
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
||||||
import { SavePanel } from '../ui/SavePanel.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.planetType = String(data.type ?? ''); // e.g. "Rocky World" (planets.typeLabels)
|
||||||
this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0)));
|
this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0)));
|
||||||
this.phase = 'landing'; // 'landing' → 'surface'
|
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.gameScene = null; // the paused GameScene beneath us (save state lives there)
|
||||||
this.backdrop = null;
|
this.backdrop = null;
|
||||||
this.landVideo = 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.landUrl) this.load.video(this.landKey, this.landUrl);
|
||||||
if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl);
|
if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl);
|
||||||
if (this.takeoffUrl) this.load.video(this.takeoffKey, this.takeoffUrl);
|
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() {
|
create() {
|
||||||
|
|
@ -92,6 +104,15 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
// opaque full-screen plate, or GameScene bleeds through the seams.
|
// 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);
|
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)) {
|
if (this.hasVideo(this.landKey)) {
|
||||||
this.playLanding();
|
this.playLanding();
|
||||||
} else {
|
} 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. */
|
/** Take-off clip done (or bailed out) — back to the flight world. */
|
||||||
finishTakeoff() {
|
finishTakeoff() {
|
||||||
if (this.takeoffDone) return;
|
if (this.takeoffDone) return;
|
||||||
this.takeoffDone = true;
|
this.takeoffDone = true;
|
||||||
|
this.setSurfaceMusic(false); // the takeoff clip ends — the hum ends with it
|
||||||
this.destroyVideo(this.takeoffVideo);
|
this.destroyVideo(this.takeoffVideo);
|
||||||
this.takeoffVideo = null;
|
this.takeoffVideo = null;
|
||||||
this.scene.stop('SurfaceScene');
|
this.scene.stop('SurfaceScene');
|
||||||
|
|
@ -532,6 +565,7 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
|
this.setSurfaceMusic(false); // the hum can't outlive the scene
|
||||||
this.destroyVideo(this.landVideo);
|
this.destroyVideo(this.landVideo);
|
||||||
this.destroyVideo(this.surfaceVideo);
|
this.destroyVideo(this.surfaceVideo);
|
||||||
this.destroyVideo(this.takeoffVideo);
|
this.destroyVideo(this.takeoffVideo);
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
||||||
|
|
||||||
setHover(s, on) {
|
setHover(s, on) {
|
||||||
if (!s.live || this.dead) return;
|
if (!s.live || this.dead) return;
|
||||||
|
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
|
||||||
s.hoverOn = on;
|
s.hoverOn = on;
|
||||||
this.paintSlot(s, on ? 'hover' : 'base');
|
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. */
|
/** Click feedback: white flash + scale punch, then restore. Fires onAction. */
|
||||||
press(s) {
|
press(s) {
|
||||||
if (!s.live || s.pressing || this.dead) return;
|
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;
|
s.pressing = true;
|
||||||
this.paintSlot(s, 'press');
|
this.paintSlot(s, 'press');
|
||||||
this.scene.tweens.add({
|
this.scene.tweens.add({
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ class TermButton extends Phaser.GameObjects.Container {
|
||||||
this.box.on('pointerout', () => this.hover(false));
|
this.box.on('pointerout', () => this.hover(false));
|
||||||
this.box.on('pointerdown', () => {
|
this.box.on('pointerdown', () => {
|
||||||
if (this.disabled) return;
|
if (this.disabled) return;
|
||||||
|
this.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
|
||||||
this.press();
|
this.press();
|
||||||
if (typeof onFire === 'function') onFire();
|
if (typeof onFire === 'function') onFire();
|
||||||
});
|
});
|
||||||
|
|
@ -141,6 +142,7 @@ class TermButton extends Phaser.GameObjects.Container {
|
||||||
hover(on) {
|
hover(on) {
|
||||||
if (this.disabled) return;
|
if (this.disabled) return;
|
||||||
this.hoverOn = on;
|
this.hoverOn = on;
|
||||||
|
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
|
||||||
this.paint();
|
this.paint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,6 +326,7 @@ export class CommsPanel extends Phaser.GameObjects.Container {
|
||||||
*/
|
*/
|
||||||
open(wx, wy, sx, sy, o = {}) {
|
open(wx, wy, sx, sy, o = {}) {
|
||||||
if (this.scene === null || this.active === false) return;
|
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();
|
const name = String(o.name ?? 'UNKNOWN').toUpperCase();
|
||||||
this.settled = !!o.settled;
|
this.settled = !!o.settled;
|
||||||
this.rep = Math.round(o.reputation ?? 0);
|
this.rep = Math.round(o.reputation ?? 0);
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
|
||||||
if (this.state === 'hidden' || this.state === 'closing') return;
|
if (this.state === 'hidden' || this.state === 'closing') return;
|
||||||
this.state = 'closing';
|
this.state = 'closing';
|
||||||
this.t0 = this.lastTime ?? this.scene.time.now;
|
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) */
|
/** @returns {boolean} true while the dialog is up (blocks the modal behind) */
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
|
||||||
/** Repaint the chip's edge for 'base' | 'hover'. */
|
/** Repaint the chip's edge for 'base' | 'hover'. */
|
||||||
paintChip(e, state) {
|
paintChip(e, state) {
|
||||||
const hover = state === 'hover';
|
const hover = state === 'hover';
|
||||||
|
if (hover) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
|
||||||
e.chip.clear();
|
e.chip.clear();
|
||||||
CyberShape.draw(e.chip, e.w, e.h, {
|
CyberShape.draw(e.chip, e.w, e.h, {
|
||||||
notch: Math.min(8, e.h * 0.3),
|
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. */
|
/** Press feedback, then the autopilot callback. */
|
||||||
pressChip(e, id) {
|
pressChip(e, id) {
|
||||||
|
this.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
|
||||||
e.chipRoot.setAlpha(0.55);
|
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.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' });
|
this.scene.tweens.add({ targets: e.arrow, scale: 1.3, duration: 110, yoyo: true, ease: 'Sine.easeOut' });
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ export class MenuButton extends Phaser.GameObjects.Container {
|
||||||
this.panel.on('pointerout', () => this.hover(false));
|
this.panel.on('pointerout', () => this.hover(false));
|
||||||
this.panel.on('pointerdown', () => {
|
this.panel.on('pointerdown', () => {
|
||||||
if (this.disabled) return;
|
if (this.disabled) return;
|
||||||
|
this.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
|
||||||
this.press();
|
this.press();
|
||||||
if (typeof onClick === 'function') onClick(this);
|
if (typeof onClick === 'function') onClick(this);
|
||||||
});
|
});
|
||||||
|
|
@ -174,6 +175,7 @@ export class MenuButton extends Phaser.GameObjects.Container {
|
||||||
if (this.disabled) return;
|
if (this.disabled) return;
|
||||||
this.hoverOn = on;
|
this.hoverOn = on;
|
||||||
this.paint(on ? 'hover' : 'base');
|
this.paint(on ? 'hover' : 'base');
|
||||||
|
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
|
||||||
|
|
||||||
if (this._sweepTween) {
|
if (this._sweepTween) {
|
||||||
this._sweepTween.remove();
|
this._sweepTween.remove();
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
|
||||||
if (i > 0) s._dec.t0 = this.t0 + i * 25;
|
if (i > 0) s._dec.t0 = this.t0 + i * 25;
|
||||||
});
|
});
|
||||||
this.spawnSlices(0.8);
|
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). */
|
/** Instantly kill the bar (scene transitions). */
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ export class MiningPopup extends Phaser.GameObjects.Container {
|
||||||
*/
|
*/
|
||||||
open(wx, wy, screenY, o = {}) {
|
open(wx, wy, screenY, o = {}) {
|
||||||
if (this.scene === null || this.active === false) return;
|
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;
|
this.lastTarget = o.target ?? null;
|
||||||
const name = String(o.name ?? 'ASTEROID CLUSTER').toUpperCase();
|
const name = String(o.name ?? 'ASTEROID CLUSTER').toUpperCase();
|
||||||
const primaryLabel = String(o.primaryLabel ?? 'MINE ASTEROIDS').toUpperCase();
|
const primaryLabel = String(o.primaryLabel ?? 'MINE ASTEROIDS').toUpperCase();
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
|
||||||
setInteractiveEnabled(this.scrim, false);
|
setInteractiveEnabled(this.scrim, false);
|
||||||
setInteractiveEnabled(this.cancelBtn.panel, false);
|
setInteractiveEnabled(this.cancelBtn.panel, false);
|
||||||
setInteractiveEnabled(this.downloadBtn.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,7 @@ export class SlotCard extends Phaser.GameObjects.Container {
|
||||||
setHover(on) {
|
setHover(on) {
|
||||||
if (this.disabled) return;
|
if (this.disabled) return;
|
||||||
this.hoverOn = on;
|
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'));
|
this.paint(on ? 'hover' : (this.filled ? 'filled' : 'empty'));
|
||||||
if (this._sweepTween) {
|
if (this._sweepTween) {
|
||||||
this._sweepTween.remove();
|
this._sweepTween.remove();
|
||||||
|
|
@ -301,6 +302,7 @@ export class SlotCard extends Phaser.GameObjects.Container {
|
||||||
|
|
||||||
press() {
|
press() {
|
||||||
if (this.disabled || this.dead || this.locked) return;
|
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.
|
// Punch + fire — the MenuButton's click language.
|
||||||
this.scene.tweens.add({ targets: this, scale: 0.965, duration: 70, yoyo: true, ease: 'Sine.easeOut' });
|
this.scene.tweens.add({ targets: this, scale: 0.965, duration: 70, yoyo: true, ease: 'Sine.easeOut' });
|
||||||
if (typeof this.onClick === 'function') {
|
if (typeof this.onClick === 'function') {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue