Add system effect SFX, per-context music volumes, and storm teardown

- Play thunder claps on habitable storm strikes, solar-flare SFX on red
  dwarf flare onset, and solar-buzz on binary light-trade midpoint
  crossings, all via the shared Sfx voice (new playSfxOneOfOn helper)
- Add per-context music volume overrides (music.volumes) so each context
  can deviate from the global mix; menu ships at full volume
- Expand surface music frames to cover rocky and ice worlds (frames 6-11)
- Teardown per-system effect layers on scene restart (jump) so a previous
  system's storm doesn't keep striking and playing thunder in the new one
- Update planets.png sheet, add gasgiant-03/rocky-01/rocky-02 tracks, and
  register the new SFX assets in GameScene preload
This commit is contained in:
Brian Fertig 2026-09-09 15:40:26 -06:00
parent a946a13a19
commit 3b0c150971
16 changed files with 567 additions and 29 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 MiB

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

View File

@ -1,7 +1,11 @@
{ {
"_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.", "_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for the mix — the DEFAULT volume for ALL music (menu, game, surface), 0..1. volumes = per-CONTEXT overrides on top of it (see _volumes_comment). menu = the loop that plays while the main menu is up. game = the SHUFFLED soundtrack for the main game scene (open space) — a plain file list, so adding a track is adding one line; one track plays at its natural length and when it ends the next is picked at random (never the same track twice in a row). frames = the loop that plays on a world's surface — keyed by the planets.png SHEET FRAME (the same key the landing videos use, data/landing.json: frames 0..2 are the terran worlds, 3..5 the gas giants), so a world hums the track matching the art it is drawn with. The surface loop starts the moment the landing clip starts and stops the moment the takeoff clip ends (or the scene leaves any other way). A frame with no entry (or a null entry) is silent. intro-01.mp3 is parked for the intro sequence that hasn't been built yet.",
"enabled": true, "enabled": true,
"volume": 0.6, "volume": 0.6,
"_volumes_comment": "volumes = per-CONTEXT overrides on top of volume (volume stays the default for everything else): 'menu' = the main-menu loop, 'game' = the deep-space shuffle, 'frames' = the world surface loops, 'stationFrames' = the station surface loops. A NUMBER applies to that whole context; an OBJECT goes one level deeper, so one frame/variant can have its own volume (e.g. \"frames\": { \"0\": 1.0 }). A context with no entry plays at volume. The menu ships at 1.0 — full volume — on purpose.",
"volumes": {
"menu": 1.0
},
"menu": "assets/music/mainmenu.mp3", "menu": "assets/music/mainmenu.mp3",
"game": [ "game": [
"assets/music/deepspace-01.mp3", "assets/music/deepspace-01.mp3",
@ -10,10 +14,16 @@
"frames": { "frames": {
"0": "assets/music/terran-01.mp3", "0": "assets/music/terran-01.mp3",
"1": "assets/music/terran-02.mp3", "1": "assets/music/terran-02.mp3",
"2": "assets/music/terran-01.mp3", "2": "assets/music/terran-03.mp3",
"3": "assets/music/gasgiant-02.mp3", "3": "assets/music/gasgiant-01.mp3",
"4": "assets/music/gasgiant-02.mp3", "4": "assets/music/gasgiant-02.mp3",
"5": "assets/music/gasgiant-02.mp3" "5": "assets/music/gasgiant-03.mp3",
"6": "assets/music/rocky-01.mp3",
"7": "assets/music/rocky-02.mp3",
"8": "assets/music/rocky-03.mp3",
"9": "assets/music/ice-01.mp3",
"10": "assets/music/ice-02.mp3",
"11": "assets/music/ice-03.mp3"
}, },
"_stationFrames_comment": "stationFrames = the loop that plays on a deep-space station's surface — keyed by the spacestations.png VARIANT frame (the same key its landing/takeoff clips use, data/landing.json → stationVideos), so a station hums the track matching the art it is drawn with. Empty for now (a station surface is silent) until the station tracks are authored — add one line per variant when they land.", "_stationFrames_comment": "stationFrames = the loop that plays on a deep-space station's surface — keyed by the spacestations.png VARIANT frame (the same key its landing/takeoff clips use, data/landing.json → stationVideos), so a station hums the track matching the art it is drawn with. Empty for now (a station surface is silent) until the station tracks are authored — add one line per variant when they land.",
"stationFrames": {} "stationFrames": {}

View File

@ -1,5 +1,5 @@
{ {
"_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>'. mining_split = the asteroid CRACKING when it breaks (shatters into pieces, or splits in half) — once per break.", "_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>'. mining_split = the asteroid CRACKING when it breaks (shatters into pieces, or splits in half) — once per break. thunder_01…thunder_04 = the HABITABLE system's storm STRIKE (the lightning going off) — one of the four at random, once per strike, with a short throttle so near-simultaneous clusters make one clap, not four; solar_buzz = the BINARY system's two-light trade passing its MIDPOINT (the star light's color change half-way between the two stars' hues — both crossings, A→B and B→A); solar_flare_01/02 = the RED DWARF's flare FLARING UP — one of the two at random, once per flare onset.",
"enabled": true, "enabled": true,
"volume": 1, "volume": 1,
"construct": "assets/fx/type-construct.mp3", "construct": "assets/fx/type-construct.mp3",
@ -9,6 +9,13 @@
"mining_loop": "assets/fx/mining-01.mp3", "mining_loop": "assets/fx/mining-01.mp3",
"mining_split": "assets/fx/mining-02.mp3", "mining_split": "assets/fx/mining-02.mp3",
"scan": "assets/fx/scan-01.mp3", "scan": "assets/fx/scan-01.mp3",
"solar_buzz": "assets/fx/solar-buzz.mp3",
"solar_flare_01": "assets/fx/solar-flare-01.mp3",
"solar_flare_02": "assets/fx/solar-flare-02.mp3",
"thunder_01": "assets/fx/thunder-01.mp3",
"thunder_02": "assets/fx/thunder-02.mp3",
"thunder_03": "assets/fx/thunder-03.mp3",
"thunder_04": "assets/fx/thunder-04.mp3",
"ui_hover": "assets/fx/ui-hover.mp3", "ui_hover": "assets/fx/ui-hover.mp3",
"ui_click": "assets/fx/ui-click.mp3", "ui_click": "assets/fx/ui-click.mp3",
"ui_window": "assets/fx/ui-window.mp3", "ui_window": "assets/fx/ui-window.mp3",

View File

@ -5,7 +5,8 @@
* *
* Exercises the shared music voice (js/utils/Music.js) with a stub scene: * 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 guard rails (master switch off, missing sound manager, asset not in
* the audio cache, ONE loop per track), the loop + volume config, the * the audio cache, ONE loop per track), the loop + volume config (the
* global knob AND the per-context overrides), the
* spec cache-key rule (music_frames_3), the SurfaceScene preload + * spec cache-key rule (music_frames_3), the SurfaceScene preload +
* setSurfaceMusic seam, and the data/music.json contract (menu + every * setSurfaceMusic seam, and the data/music.json contract (menu + every
* planets.png frame entry points at a file that exists), and the * planets.png frame entry points at a file that exists), and the
@ -26,6 +27,10 @@ globalThis.window = {
GameObjects: { Sprite: ClassStub, Container: ClassStub, Image: ClassStub }, GameObjects: { Sprite: ClassStub, Container: ClassStub, Image: ClassStub },
Physics: { Arcade: { Sprite: ClassStub } }, Physics: { Arcade: { Sprite: ClassStub } },
Geom: { Rectangle: class {} }, Geom: { Rectangle: class {} },
// SystemEffects.js (pulled in via GameScene) declares its filter
// classes against these two base classes at module level:
Filters: { Controller: class {} },
Renderer: { WebGL: { RenderNodes: { BaseFilterShader: class {} } } },
}, },
}; };
@ -36,7 +41,7 @@ const LANDING = JSON.parse(fs.readFileSync(join(__dirname, '../data/landing.json
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
config.init({ music: { ...MUSIC }, landing: { ...LANDING } }); config.init({ music: { ...MUSIC }, landing: { ...LANDING } });
const { playMusicOn, stopMusicOn, musicPlayingOn, musicKey, gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } = await import( const { playMusicOn, stopMusicOn, musicPlayingOn, musicKey, gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn, musicVolumeFor } = await import(
pathToFileURL(join(__dirname, '../js/utils/Music.js')).href pathToFileURL(join(__dirname, '../js/utils/Music.js')).href
); );
const { SurfaceScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/SurfaceScene.js')).href); const { SurfaceScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/SurfaceScene.js')).href);
@ -86,11 +91,11 @@ const makeScene = (o = {}) => {
{ {
const scene = makeScene(); const scene = makeScene();
playMusicOn(scene, 'music.menu'); playMusicOn(scene, 'music.menu');
check('plays music_menu, looping, at music.volume', check('plays music_menu, looping, at the menu context\'s volume',
scene.plays.length === 1 && scene.plays.length === 1 &&
scene.plays[0].key === 'music_menu' && scene.plays[0].key === 'music_menu' &&
scene.plays[0].opts.loop === true && scene.plays[0].opts.loop === true &&
scene.plays[0].opts.volume === MUSIC.volume); scene.plays[0].opts.volume === musicVolumeFor('menu'));
} }
// 3) One loop per track: a scene restart must not stack a second copy. // 3) One loop per track: a scene restart must not stack a second copy.
@ -151,16 +156,15 @@ const makeScene = (o = {}) => {
check('musicPlayingOn is false after stop', musicPlayingOn(scene, 'music.menu') === false); check('musicPlayingOn is false after stop', musicPlayingOn(scene, 'music.menu') === false);
} }
// 9) data/music.json contract: the menu + every planets.png frame (0..5) // 9) data/music.json contract: the menu + every defined frame entry
// point at files that exist. // point at files that exist.
{ {
const ok = const ok =
typeof MUSIC.menu === 'string' && fs.existsSync(join(__dirname, '..', MUSIC.menu)) && typeof MUSIC.menu === 'string' && fs.existsSync(join(__dirname, '..', MUSIC.menu)) &&
[0, 1, 2, 3, 4, 5].every((f) => { MUSIC.frames != null && typeof MUSIC.frames === 'object' &&
const p = MUSIC.frames?.[String(f)]; Object.entries(MUSIC.frames).every(([f, p]) =>
return typeof p === 'string' && fs.existsSync(join(__dirname, '..', p)); (p == null) || (typeof p === 'string' && fs.existsSync(join(__dirname, '..', p))));
}); check('music.json: menu + every frame entry points at a file that exists', ok);
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. // 10) SurfaceScene.preload: the world's track, queued under the derived key.
@ -176,8 +180,8 @@ const makeScene = (o = {}) => {
// A frame with no track is silent (nothing queued, no crash). // A frame with no track is silent (nothing queued, no crash).
const s2 = Object.create(SurfaceScene.prototype); const s2 = Object.create(SurfaceScene.prototype);
s2.planetFrame = 9; // no such frame s2.planetFrame = 12; // beyond the sheet — no such frame
s2.musicSpec = 'music.frames.9'; s2.musicSpec = 'music.frames.12';
s2.queued = []; s2.queued = [];
s2.load = { audio: (k, u) => s2.queued.push([k, u]), video: () => {} }; s2.load = { audio: (k, u) => s2.queued.push([k, u]), video: () => {} };
SurfaceScene.prototype.preload.call(s2); SurfaceScene.prototype.preload.call(s2);
@ -207,11 +211,11 @@ const makeScene = (o = {}) => {
cache: { audio: { has: () => true } }, cache: { audio: { has: () => true } },
}; };
SurfaceScene.prototype.setSurfaceMusic.call(scene, true); SurfaceScene.prototype.setSurfaceMusic.call(scene, true);
check('setSurfaceMusic(true) starts the world\'s loop (looping, at music.volume)', check('setSurfaceMusic(true) starts the world\'s loop (looping, at the frame\'s volume)',
plays.length === 1 && plays.length === 1 &&
plays[0].key === 'music_frames_3' && plays[0].key === 'music_frames_3' &&
plays[0].opts.loop === true && plays[0].opts.loop === true &&
plays[0].opts.volume === MUSIC.volume); plays[0].opts.volume === musicVolumeFor('frames.3'));
SurfaceScene.prototype.setSurfaceMusic.call(scene, true); SurfaceScene.prototype.setSurfaceMusic.call(scene, true);
check('... a re-fire (scene restart) keeps ONE loop', plays.length === 1); check('... a re-fire (scene restart) keeps ONE loop', plays.length === 1);
SurfaceScene.prototype.setSurfaceMusic.call(scene, false); SurfaceScene.prototype.setSurfaceMusic.call(scene, false);
@ -263,10 +267,10 @@ const makeShuffleScene = (o = {}) => {
const scene = makeShuffleScene(); const scene = makeShuffleScene();
startMusicShuffleOn(scene, MUSIC.game); startMusicShuffleOn(scene, MUSIC.game);
check('starts exactly ONE track', scene.plays.length === 1); check('starts exactly ONE track', scene.plays.length === 1);
check('... and it plays loop:false at music.volume', check('... and it plays loop:false at the game context\'s volume',
(scene.plays[0].key === ka || scene.plays[0].key === kb) && (scene.plays[0].key === ka || scene.plays[0].key === kb) &&
scene.plays[0].opts.loop === false && scene.plays[0].opts.loop === false &&
scene.plays[0].opts.volume === MUSIC.volume); scene.plays[0].opts.volume === musicVolumeFor('game'));
const first = scene.plays[0].key; const first = scene.plays[0].key;
const firstFile = first === ka ? fa : fb; const firstFile = first === ka ? fa : fb;
const second = first === ka ? kb : ka; const second = first === ka ? kb : ka;
@ -345,5 +349,69 @@ const makeShuffleScene = (o = {}) => {
check('preload queues each music.game file under its derived key', ok); check('preload queues each music.game file under its derived key', ok);
} }
// 18) Per-context volume overrides (data/music.json → volumes): a context
// can deviate from the global mix without touching it.
{
const base = { enabled: true, volume: 0.6, menu: 'assets/music/mainmenu.mp3', game: ['assets/music/a.mp3', 'assets/music/b.mp3'] };
config.init({ music: { ...base } });
check('no volumes section → every context falls back to the global knob',
musicVolumeFor('menu') === 0.6 &&
musicVolumeFor('game') === 0.6 &&
musicVolumeFor('frames.0') === 0.6 &&
musicVolumeFor('stationFrames.2') === 0.6);
config.init({ music: { ...base, volumes: { menu: 1.0 } } });
check('volumes.menu overrides ONLY the menu context',
musicVolumeFor('menu') === 1.0 &&
musicVolumeFor('game') === 0.6 &&
musicVolumeFor('frames.0') === 0.6);
const menuScene = makeScene();
playMusicOn(menuScene, 'music.menu');
check('the menu loop plays at its override',
menuScene.plays.length === 1 && menuScene.plays[0].opts.volume === 1.0);
stopMusicOn(menuScene, 'music.menu');
config.init({ music: { ...base, volumes: { frames: 0.8, game: 0.5 } } });
check('a scalar covers the whole context (all frames, the shuffle)',
musicVolumeFor('frames.3') === 0.8 &&
musicVolumeFor('frames.11') === 0.8 &&
musicVolumeFor('game') === 0.5);
const sh = makeShuffleScene();
startMusicShuffleOn(sh, base.game);
check('the shuffle plays at volumes.game',
sh.plays.length === 1 && sh.plays[0].opts.volume === 0.5);
stopMusicShuffleOn(sh);
config.init({ music: { ...base, volumes: { frames: { 0: 1.0, 1: 0.4 } } } });
check('a per-frame entry overrides ONLY that frame',
musicVolumeFor('frames.0') === 1.0 &&
musicVolumeFor('frames.1') === 0.4 &&
musicVolumeFor('frames.2') === 0.6);
config.init({ music: { ...base, volumes: { stationFrames: { 2: 0.9 } } } });
check('stationFrames overrides are per-variant and independent of frames',
musicVolumeFor('stationFrames.2') === 0.9 &&
musicVolumeFor('stationFrames.0') === 0.6 &&
musicVolumeFor('frames.2') === 0.6);
config.init({ music: { ...MUSIC }, landing: { ...LANDING } }); // back to the shipped data
}
// 19) The shipped data: volumes entries are numbers (or per-frame objects
// of numbers), and every context resolves to a finite volume.
{
const vols = MUSIC.volumes;
const wellFormed = (node) =>
(typeof node === 'number') ||
(node != null && typeof node === 'object' &&
Object.values(node).every((v) => (typeof v === 'number') || wellFormed(v)));
check('music.json: volumes is absent or a context→number (or per-frame) map',
vols == null || (typeof vols === 'object' && Object.values(vols).every((v) => wellFormed(v))));
check('music.json: every context resolves to a finite volume',
['menu', 'game', 'frames.0', 'frames.11', 'stationFrames.0']
.every((c) => Number.isFinite(musicVolumeFor(c))));
check('music.json: the shipped menu override is full volume', musicVolumeFor('menu') === 1.0);
}
console.log(failures === 0 ? '\nAll music voice tests passed ✔' : `\n${failures} test(s) FAILED ✘`); console.log(failures === 0 ? '\nAll music voice tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1); process.exit(failures === 0 ? 0 : 1);

View File

@ -7,8 +7,9 @@
* playSfxOn() guard rails (master switch off, missing sound manager, * playSfxOn() guard rails (master switch off, missing sound manager,
* asset not in the audio cache, the happy path, extra play options like * asset not in the audio cache, the happy path, extra play options like
* loop) and the stop/status helpers (stopSfxOn / sfxPlayingOn). Also * loop) and the stop/status helpers (stopSfxOn / sfxPlayingOn). Also
* checks the sfx.json keys follow the play-name convention (sfx_<key> * checks playSfxOneOfOn() (the one-of-a-set voice: a thunder strike,
* cache key) and point at files that exist. * a solar flare) and that 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 { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@ -20,7 +21,7 @@ const SFX = JSON.parse(fs.readFileSync(join(__dirname, '../data/sfx.json'), 'utf
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
config.init({ sfx: { ...SFX } }); config.init({ sfx: { ...SFX } });
const { playSfxOn, stopSfxOn, sfxPlayingOn } = await import(pathToFileURL(join(__dirname, '../js/utils/Sfx.js')).href); const { playSfxOn, playSfxOneOfOn, stopSfxOn, sfxPlayingOn } = await import(pathToFileURL(join(__dirname, '../js/utils/Sfx.js')).href);
let failures = 0; let failures = 0;
const check = (label, cond) => { const check = (label, cond) => {
@ -154,5 +155,48 @@ const makeScene = (o = {}) => {
typeof p === 'string' && fs.existsSync(join(__dirname, '..', p))); typeof p === 'string' && fs.existsSync(join(__dirname, '..', p)));
} }
// 11) EVERY sfx.json asset key points at a file that exists (the scene
// load list trusts the data; a typo would be a silent dead voice).
{
const keys = Object.entries(SFX)
.filter(([k, v]) => k !== '_comment' && typeof v === 'string');
const ok = keys.length > 0 && keys.every(([, v]) => fs.existsSync(join(__dirname, '..', v)));
check(`every sfx.json asset key (n=${keys.length}) points at a file that exists`, ok);
}
// 12) The new effect voices (thunder / solar) point at real files.
{
const ok = ['thunder_01', 'thunder_02', 'thunder_03', 'thunder_04',
'solar_buzz', 'solar_flare_01', 'solar_flare_02'].every((k) =>
typeof SFX[k] === 'string' && fs.existsSync(join(__dirname, '..', SFX[k])));
check('sfx.json thunder_01…04 + solar_buzz + solar_flare_01/02 point at files that exist', ok);
}
// 13) playSfxOneOfOn: exactly ONE member of the set plays, always a valid
// member, at the configured volume; empty/invalid lists stay silent.
{
const names = ['solar_flare_01', 'solar_flare_02'];
const valid = new Set(names.map((n) => `sfx_${n}`));
let ok = true;
for (let i = 0; i < 200 && ok; i++) {
const scene = makeScene();
playSfxOneOfOn(scene, names);
ok = scene.calls.length === 1 && valid.has(scene.calls[0].key) && scene.calls[0].opts.volume === SFX.volume;
}
check('playSfxOneOfOn plays exactly one valid member of the set (200 draws)', ok);
const empty = makeScene();
playSfxOneOfOn(empty, []);
check('playSfxOneOfOn with an empty set → nothing plays', empty.calls.length === 0);
const junk = makeScene();
playSfxOneOfOn(junk, 'not-a-list');
check('playSfxOneOfOn with a non-list → nothing plays (no throw)', junk.calls.length === 0);
const noMgr = makeScene({ sound: false });
playSfxOneOfOn(noMgr, names);
check('playSfxOneOfOn with no sound manager → nothing plays (no throw)', noMgr.calls.length === 0);
}
console.log(failures === 0 ? '\nAll SFX voice tests passed ✔' : `\n${failures} test(s) FAILED ✘`); console.log(failures === 0 ? '\nAll SFX voice tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1); process.exit(failures === 0 ? 0 : 1);

213
dev/storm.test.mjs Normal file
View File

@ -0,0 +1,213 @@
/**
* Storm layer test (dev tool, run with Node no browser needed):
*
* node dev/storm.test.mjs
*
* Asserts the HABITABLE storm's VOICE (js/visuals/StormLayer.js +
* data/sfx.json thunder_0104):
* - a cluster STRIKES (its flash envelope opens, env.flash 0 >0)
* exactly once per strike one thunder clap, a random member of
* the four thunder_XX keys, at the configured volume;
* - the first update() frame only ARMS (a cluster already mid-strike
* at scene entry stays silent);
* - the throttle: a second strike inside ~0.7 s makes no second clap;
* - the master switch (sfx.enabled=false) silences it;
* - the JUMP teardown: a destroyed storm can't strike anymore (jumping
* out of a habitable system must not leave its thunder ringing on).
*/
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const SFX = configData.sfx;
const { stormEnvelope } = await import(
pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href
);
const { StormLayer } = await import(
pathToFileURL(join(__dirname, '../js/visuals/StormLayer.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// --- A headless scene just enough for StormLayer.create()/update() --------
// textures.exists() → true (the cloud textures are "already there", so the
// canvasTexture() draw never runs — no document/canvas needed), and the
// sprites are plain chainable records.
function makeScene(o = {}) {
const calls = [];
const image = (x, y) => ({
x, y, rotation: 0,
setDepth() { return this; },
setTint() { return this; },
setScale() { return this; },
setAlpha() { return this; },
setBlendMode() { return this; },
setRotation(r) { this.rotation = r; return this; },
setPosition(x, y) { this.x = x; this.y = y; return this; },
destroy() { this.active = false; },
});
return {
calls,
scale: { width: 1280, height: 720 },
cameras: { main: { scrollX: 0, scrollY: 0 } },
add: { image: (x, y, _key) => image(x, y) },
textures: { exists: () => true, addCanvas: () => {} },
sound:
o.sound === false
? null
: { play: (key, opts) => { calls.push({ key, opts }); } },
};
}
const FX = { phase: 0.37, angle: 0.5, drift: 0.6 }; // per-system variation (seeded)
const STORM_CFG = configData.systems.types.habitable.effect.storm;
const THUNDER_KEYS = ['thunder_01', 'thunder_02', 'thunder_03', 'thunder_04'];
/** The first moment (ms) at which ANY cluster's flash envelope opens. */
function firstStrikeMs(storm, horizonMs = 120000) {
for (let ms = 16; ms < horizonMs; ms += 16) {
const now = storm.clusters.some((c) => stormEnvelope(ms, c.interval, c.strikeDur, c.phase, c.chargeWindow).flash > 0);
const before = storm.clusters.some((c) => stormEnvelope(ms - 16, c.interval, c.strikeDur, c.phase, c.chargeWindow).flash > 0);
if (now && !before) return ms;
}
return null;
}
const isThunder = (call) => call && THUNDER_KEYS.includes(call.key.replace(/^sfx_/, ''));
// 1) A strike plays exactly one thunder clap — a random member of the
// four keys — at the configured volume.
{
const scene = makeScene();
const storm = new StormLayer(scene, STORM_CFG, FX);
storm.create();
check('create() builds 3 clusters (back/flash/front each)', storm.clusters.length === 3);
const strikeMs = firstStrikeMs(storm);
check('the storm has a strike within the horizon', strikeMs !== null);
scene.calls.length = 0;
storm.update(strikeMs - 16, 16); // just before: quiet (and this arms the detector)
const quiet = scene.calls.length === 0;
storm.update(strikeMs, 16); // the strike: the flash window opens
const played = scene.calls.length === 1 && isThunder(scene.calls[0]) && scene.calls[0].opts.volume === SFX.volume;
check('quiet before the strike', quiet);
check('the strike plays exactly one thunder clap (sfx_thunder_XX, sfx.volume)', played);
// Mid-strobe: the envelope is still > 0 → NO repeat (once per strike).
storm.update(strikeMs + 400, 16);
check('no repeat mid-strobe (once per strike)', scene.calls.length === 1);
}
// 2) First frame only ARMS: a cluster already mid-strike at entry is silent.
{
const scene = makeScene();
const storm = new StormLayer(scene, STORM_CFG, FX);
storm.create();
const strikeMs = firstStrikeMs(storm);
scene.calls.length = 0;
storm.update(strikeMs + 400, 16); // first frame, mid-strike (flash > 0)
const armed = scene.calls.length === 0;
// …and the NEXT strike does clap.
let next = null;
for (let ms = strikeMs + 416; ms < strikeMs + 120000; ms += 16) {
const now = storm.clusters.some((c) => stormEnvelope(ms, c.interval, c.strikeDur, c.phase, c.chargeWindow).flash > 0);
const before = storm.clusters.some((c) => stormEnvelope(ms - 16, c.interval, c.strikeDur, c.phase, c.chargeWindow).flash > 0);
if (now && !before) { next = ms; break; }
}
check('a cluster mid-strike at entry stays silent (arming frame)', armed && next !== null);
if (next !== null) {
scene.calls.length = 0;
storm.update(next - 16, 16);
storm.update(next, 16);
check('…and the following strike still claps', scene.calls.length === 1 && isThunder(scene.calls[0]));
}
}
// 3) The throttle: strikes closer than ~0.7 s make one clap, not two.
{
const scene = makeScene();
const storm = new StormLayer(scene, STORM_CFG, FX);
storm.create();
const strikeMs = firstStrikeMs(storm);
scene.calls.length = 0;
storm.update(strikeMs - 16, 16); // quiet (arms the detector)
storm.update(strikeMs, 16); // the strike → the first clap
const first = scene.calls.length === 1 && isThunder(scene.calls[0]);
storm.thunderStrike(strikeMs + 100); // a near-simultaneous strike (another cluster)
const throttled = scene.calls.length === 1;
storm.thunderStrike(strikeMs + 800); // …but a strike after the window claps again
const resumes = scene.calls.length === 2 && isThunder(scene.calls[1]);
check('the strike claps (setup)', first);
check('near-simultaneous strikes make one clap (throttled)', throttled);
check('a strike after the throttle window claps again', resumes);
}
// 4) The master switch silences the strike.
{
config.init({ ...configData, sfx: { ...SFX, enabled: false } });
const scene = makeScene();
const storm = new StormLayer(scene, STORM_CFG, FX);
storm.create();
const strikeMs = firstStrikeMs(storm);
scene.calls.length = 0;
storm.update(strikeMs, 16);
check('sfx.enabled=false → the strike is silent', scene.calls.length === 0);
config.init(configData);
}
// 5) No sound manager (headless) → no throw.
{
const scene = makeScene({ sound: false });
const storm = new StormLayer(scene, STORM_CFG, FX);
storm.create();
let threw = false;
try {
storm.update(firstStrikeMs(storm), 16);
storm.thunderStrike(999999);
} catch (e) {
threw = true;
}
check('no sound manager → the strike is silent, no throw', !threw);
}
// 6) The JUMP teardown contract (GameScene._teardownEffectLayers): a
// DESTROYED storm can't strike anymore — the player jumps out of a
// habitable system and its thunder must not ring on in the next one.
// (The scene side of the contract — create() dropping the ref — is
// the GameScene change; this is the half the storm owes.)
{
const scene = makeScene();
const storm = new StormLayer(scene, STORM_CFG, FX);
storm.create();
const strikeMs = firstStrikeMs(storm);
scene.calls.length = 0;
storm.update(strikeMs - 16, 16); // quiet (arms the detector)
storm.destroy(); // the jump's teardown
storm.update(strikeMs, 16); // the strike that used to ring on…
storm.update(strikeMs + 400, 16);
storm.update(strikeMs + 20000, 16); // …and one a cycle later
check('a destroyed storm stays silent (the jump teardown)', scene.calls.length === 0);
}
console.log(failures === 0 ? '\nAll storm voice tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -439,12 +439,17 @@ const fakeCameras = {
return c; return c;
}, },
}; };
// The facade's VOICES (data/sfx.json) recorded on the fake scene — the
// shared voice (js/utils/Sfx.js) guards on scene.sound, so this is all
// it needs (the audio-cache guard passes on a missing cache).
const sfxCalls = [];
const fakeScene = { const fakeScene = {
scale: { width: 1280, height: 720 }, scale: { width: 1280, height: 720 },
cameras: fakeCameras, cameras: fakeCameras,
sys: { displayList: { getChildren: () => [uiRoot, worldRoot, mkObject('world-2', 1)] } }, sys: { displayList: { getChildren: () => [uiRoot, worldRoot, mkObject('world-2', 1)] } },
renderer: { gl: {}, renderNodes: fakeRenderer.renderNodes }, renderer: { gl: {}, renderNodes: fakeRenderer.renderNodes },
systemContent: { fx: { phase: 0.5, angle: 0.3, drift: 0.7 } }, // per-system variation systemContent: { fx: { phase: 0.5, angle: 0.3, drift: 0.7 } }, // per-system variation
sound: { play: (key) => { sfxCalls.push({ key }); } },
}; };
const split = ensureUiCameras(fakeScene); const split = ensureUiCameras(fakeScene);
@ -618,6 +623,46 @@ check('facade: release() detaches cleanly (and twice)', (() => {
return first; return first;
})()); })());
// --- The facade's VOICES (data/sfx.json, via the shared SFX voice) --------
check('facade: a type without a flare/breath voice stays silent', (() => {
sfxCalls.length = 0;
fx.release();
fx.apply('main'); // "none" — no filters, no flare, no breath
fx.update(1000);
fx.update(20000);
return sfxCalls.length === 0;
})());
check('facade: the redDwarf flare ONSET plays a solar-flare SFX (one of the two, once per flare)', (() => {
sfxCalls.length = 0;
fx.release();
fx.apply('redDwarf');
// phase 0.5 → phaseSec 9: quiet at t = 1 s (u = 10), the bell OPENS at t = 9 s
fx.update(1000);
const quiet = sfxCalls.length === 0;
fx.update(9050); // u = 0.05 → the flare has flared up
const played = sfxCalls.length === 1 &&
(sfxCalls[0].key === 'sfx_solar_flare_01' || sfxCalls[0].key === 'sfx_solar_flare_02');
fx.update(9500); // still inside the SAME flare → no repeat
return quiet && played && sfxCalls.length === 1;
})());
check('facade: the binary light-trade MIDPOINT plays the solar-buzz SFX (both crossings)', (() => {
sfxCalls.length = 0;
fx.release();
fx.apply('binary');
// phase 0.5 → breathPhase π: the sign of uBreath is on (0, P/2),
// + on (P/2, P), on (P, 3P/2), … — crossings at every half period
// (t = 12 s and t = 24 s for the 24 s period). The detector arms on
// the first frame (whatever sign it sees), then fires on each change.
fx.update(100); // sign (armed; near-midpoint, but one side of it)
fx.update(6000); // still (the trade fully on side B) — no buzz
fx.update(12100); // + : the trade passed its MIDPOINT (−→+) → buzz
const firstCrossing = sfxCalls.length === 1 && sfxCalls[0].key === 'sfx_solar_buzz';
fx.update(18000); // still + (side A) — no repeat
fx.update(24100); // : the SECOND midpoint crossing (+→−) → buzz again
return firstCrossing && sfxCalls.length === 2 && sfxCalls.every((c) => c.key === 'sfx_solar_buzz');
})());
// --- The shader contract (through live node instances) ---------------------- // --- The shader contract (through live node instances) ----------------------
const RippleClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE]; const RippleClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE];

View File

@ -1423,7 +1423,10 @@ The player holds a REPUTATION (standing) on each planet and space station:
the landing videos) runs from the start of the landing clip to the 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` end of the takeoff clip. Shared voice in `js/utils/Music.js`
(looped play + `stopByKey` + one-loop-per-track guard; (looped play + `stopByKey` + one-loop-per-track guard;
`music.enabled` / `music.volume`), stops hooked on the scene's `music.enabled` / `music.volume` (the ONE knob for the mix) +
per-CONTEXT volume overrides in `music.volumes` — menu / game /
frames / stationFrames, a number per context or a per-frame object,
the menu ships at 1.0 for full volume), stops hooked on the scene's
`shutdown` EVENT (v4 quirk — see the list above), so the hum dies `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 on Take Off AND on Return to Main Menu, and the game soundtrack
dies on landing AND on returning to the menu dies on landing AND on returning to the menu

View File

@ -266,6 +266,13 @@ export class GameScene extends Phaser.Scene {
this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3')); this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3'));
this.load.audio('sfx_mining_split', config.get('sfx.mining_split', 'assets/fx/mining-02.mp3')); this.load.audio('sfx_mining_split', config.get('sfx.mining_split', 'assets/fx/mining-02.mp3'));
this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3')); this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3'));
this.load.audio('sfx_solar_buzz', config.get('sfx.solar_buzz', 'assets/fx/solar-buzz.mp3'));
this.load.audio('sfx_solar_flare_01', config.get('sfx.solar_flare_01', 'assets/fx/solar-flare-01.mp3'));
this.load.audio('sfx_solar_flare_02', config.get('sfx.solar_flare_02', 'assets/fx/solar-flare-02.mp3'));
this.load.audio('sfx_thunder_01', config.get('sfx.thunder_01', 'assets/fx/thunder-01.mp3'));
this.load.audio('sfx_thunder_02', config.get('sfx.thunder_02', 'assets/fx/thunder-02.mp3'));
this.load.audio('sfx_thunder_03', config.get('sfx.thunder_03', 'assets/fx/thunder-03.mp3'));
this.load.audio('sfx_thunder_04', config.get('sfx.thunder_04', 'assets/fx/thunder-04.mp3'));
this.load.audio('sfx_ui_hover', config.get('sfx.ui_hover', 'assets/fx/ui-hover.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_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_window', config.get('sfx.ui_window', 'assets/fx/ui-window.mp3'));
@ -609,6 +616,17 @@ export class GameScene extends Phaser.Scene {
this.starfield = new Starfield(this); this.starfield = new Starfield(this);
this.starfield.create(); this.starfield.create();
// A JUMP restarts this scene IN PLACE (v4: the same instance — the
// 'shutdown' EVENT tears down the display list, but the engine never
// calls our shutdown() method, and our own refs survive; see the
// MenuScene note on the v4 quirk). The per-system EFFECT LAYERS below
// are conditional, so a layer the LAST entry built would keep ticking
// — and talking — in this system: a habitable storm's update() would
// keep striking, and keep THUNDERING, on a starless sky. Drop whatever
// the last entry built; each conditional below re-adds its layer or
// leaves the ref null.
this._teardownEffectLayers();
// NEBULA GAS — a soft coloured cloud layer BEHIND the art (depth 3), // NEBULA GAS — a soft coloured cloud layer BEHIND the art (depth 3),
// tinted with the system's own atmosphere color (assigned at random at // tinted with the system's own atmosphere color (assigned at random at
// galaxy generation — SystemGenerator). Nebula systems only. See // galaxy generation — SystemGenerator). Nebula systems only. See
@ -4538,6 +4556,34 @@ export class GameScene extends Phaser.Scene {
this.playSfx('construct'); this.playSfx('construct');
} }
/**
* Drop the LAST entry's per-system effect layers (nebula / haze / void /
* storm / embers / wanderer) the conditional ones, created just below
* in create(). A jump's cut is a scene RESTART that reuses this scene
* instance (v4: sys.shutdown + the 'shutdown' EVENT the display list
* destroys its children, but this scene's refs + its update() calls
* survive, and our shutdown() method is never called), so without this
* a previous system's layers would keep running and the habitable
* storm would keep playing thunder in a system with no storm. Safe to
* run over null refs (first boot) and over already-destroyed layers
* (the layers' destroy()s are idempotent, as is the shutdown() net
* below). create() calls this before building this system's set.
*/
_teardownEffectLayers() {
this.nebula?.destroy();
this.nebula = null;
this.haze?.destroy();
this.haze = null;
this.voidBase?.destroy();
this.voidBase = null;
this.storm?.destroy();
this.storm = null;
this.embers?.destroy();
this.embers = null;
this.wanderer?.destroy();
this.wanderer = null;
}
shutdown() { shutdown() {
this.setMiningLoop(false); // the hum can't outlive the scene this.setMiningLoop(false); // the hum can't outlive the scene
this.stopGameMusic(); // neither can the soundtrack this.stopGameMusic(); // neither can the soundtrack

View File

@ -15,8 +15,16 @@ import { config } from '../config/Config.js';
* (Giedi) quirks: `sound.play()` THROWS on a key that never loaded, * (Giedi) quirks: `sound.play()` THROWS on a key that never loaded,
* `cache.hasAudio()` doesn't exist (check `cache.audio.has()`), and the * `cache.hasAudio()` doesn't exist (check `cache.audio.has()`), and the
* manager stops BY KEY (`stopByKey`, there is no `sound.stop()`). * manager stops BY KEY (`stopByKey`, there is no `sound.stop()`).
* Music is always played LOOPING at music.volume, and one loop per * Music is always played LOOPING at its context's volume, and one loop
* track: a scene restart must not stack a second copy. * per track: a scene restart must not stack a second copy.
*
* Volume: music.volume is the ONE KNOB for the mix (the default for
* every context); music.volumes holds per-CONTEXT overrides on top of
* it 'menu', 'game', 'frames.<n>' / 'stationFrames.<n>' (the context
* is the spec minus the 'music.' prefix). A number applies to that whole
* context, an object goes one level deeper (a per-frame/variant entry),
* so a context can hum louder or quieter than the rest without touching
* the global mix (data/music.json: the menu ships at 1.0).
*/ */
/** The cache key a music spec queues under (music_menu, music_frames_3). /** The cache key a music spec queues under (music_menu, music_frames_3).
@ -27,6 +35,22 @@ export function musicKey(spec) {
return 'music_' + key.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, ''); return 'music_' + key.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
} }
/**
* The volume for a music CONTEXT (the spec minus the 'music.' prefix:
* 'menu', 'game', 'frames.<n>', 'stationFrames.<n>') the per-context
* override in data/music.json volumes first (a number at any level of
* the context path wins), then the global knob (music.volume, 0..1).
*/
export function musicVolumeFor(context) {
let node = config.section('music.volumes', {});
for (const part of String(context).split('.')) {
if (node == null || typeof node !== 'object') break;
node = node[part];
if (typeof node === 'number') return node;
}
return config.get('music.volume', 0.6);
}
/** Start a music loop (config spec → file). No-ops per the guard rails above. */ /** Start a music loop (config spec → file). No-ops per the guard rails above. */
export function playMusicOn(scene, spec) { export function playMusicOn(scene, spec) {
if (!scene || !config.get('music.enabled', true)) return; if (!scene || !config.get('music.enabled', true)) return;
@ -35,7 +59,7 @@ export function playMusicOn(scene, spec) {
const key = musicKey(spec); const key = musicKey(spec);
if (typeof snd.isPlaying === 'function' && snd.isPlaying(key)) return; // one loop per track 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; 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) }); snd.play(key, { loop: true, volume: musicVolumeFor(String(spec).replace(/^music\./, '')) });
} }
/** Stop a music loop (config spec → key). A no-op when it isn't playing. */ /** Stop a music loop (config spec → key). A no-op when it isn't playing. */
@ -124,7 +148,7 @@ export function startMusicShuffleOn(scene, files) {
} }
const file = pool[Math.floor(Math.random() * pool.length)]; const file = pool[Math.floor(Math.random() * pool.length)];
st.current = file; st.current = file;
snd.play(gameTrackKey(file), { loop: false, volume: config.get('music.volume', 0.6) }); snd.play(gameTrackKey(file), { loop: false, volume: musicVolumeFor('game') });
return true; return true;
}; };

View File

@ -27,6 +27,19 @@ export function playSfxOn(scene, name, o = {}) {
snd.play(key, { volume: config.get('sfx.volume', 0.55), ...o }); snd.play(key, { volume: config.get('sfx.volume', 0.55), ...o });
} }
/**
* Play ONE of a SET of configured SFX, chosen at random a struck effect
* with several recorded variants (a thunder strike, a solar flare).
* `names` is a list of data/sfx.json keys (each queued as `sfx_<name>`);
* exactly one member plays. All of playSfxOn's guards apply (master
* switch, manager, audio cache) and the same volume/options pass-through.
*/
export function playSfxOneOfOn(scene, names, o = {}) {
const list = Array.isArray(names) ? names.filter((n) => typeof n === 'string') : [];
if (list.length === 0) return;
playSfxOn(scene, list[(Math.random() * list.length) | 0], o);
}
/** /**
* Stop one of the configured SFX by its data/sfx.json key (a no-op when * 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 * it isn't playing). v4 (Giedi) API: the manager stops BY KEY

View File

@ -33,11 +33,17 @@
* Each cluster gets its own seeded interval + phase, so the strikes are * Each cluster gets its own seeded interval + phase, so the strikes are
* staggered the sky flickers from different places, like weather. * staggered the sky flickers from different places, like weather.
* *
* VOICE (data/sfx.json thunder_0104): the moment a cluster's flash
* window opens (the strike itself env.flash goes 0 >0) plays ONE of
* the four thunder claps at random. Throttled to one clap per ~0.7 s, so
* near-simultaneous clusters make one clap, not four.
*
* Config: data/systems.json types.habitable.effect.storm. * Config: data/systems.json types.habitable.effect.storm.
*/ */
import Phaser from '../vendor/phaser.js'; import Phaser from '../vendor/phaser.js';
import { toColor } from '../utils/Color.js'; import { toColor } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js'; import { canvasTexture } from '../utils/Textures.js';
import { playSfxOneOfOn } from '../utils/Sfx.js';
import { drawCloud, cloudAlpha } from './NebulaAtmosphere.js'; import { drawCloud, cloudAlpha } from './NebulaAtmosphere.js';
import { stormEnvelope } from './SystemEffectsMath.js'; import { stormEnvelope } from './SystemEffectsMath.js';
import { assignWorld } from './UiCameras.js'; import { assignWorld } from './UiCameras.js';
@ -48,6 +54,8 @@ const DEPTH_FLASH = 3.1;
const DEPTH_FRONT = 3.2; const DEPTH_FRONT = 3.2;
const SKY = { x: 0.10, y: 0.08, w: 0.80, h: 0.34 }; // home band: upper view (fractions) const SKY = { x: 0.10, y: 0.08, w: 0.80, h: 0.34 }; // home band: upper view (fractions)
const BAND_Y = { top: 0.03, bottom: 0.58 }; // wrap band: strictly inside the view (fractions) const BAND_Y = { top: 0.03, bottom: 0.58 }; // wrap band: strictly inside the view (fractions)
const THUNDER_SFX = ['thunder_01', 'thunder_02', 'thunder_03', 'thunder_04']; // data/sfx.json keys
const THUNDER_MIN_GAP_MS = 700; // one clap per near-simultaneous strikes
/** A seeded 32-bit PRNG (mulberry32) — stable cloud layout per cluster. */ /** A seeded 32-bit PRNG (mulberry32) — stable cloud layout per cluster. */
function makeRng(seedNum) { function makeRng(seedNum) {
@ -113,6 +121,7 @@ export class StormLayer {
this.clusters = []; this.clusters = [];
this.lastScrollX = 0; this.lastScrollX = 0;
this.lastScrollY = 0; this.lastScrollY = 0;
this.lastThunderMs = -Infinity; // the last clap (the throttle's anchor)
} }
/** Build the cloud textures + one back/flash/front trio per cluster (idempotent). */ /** Build the cloud textures + one back/flash/front trio per cluster (idempotent). */
@ -213,6 +222,8 @@ export class StormLayer {
interval, phase, strikeDur, chargeWindow, strength, interval, phase, strikeDur, chargeWindow, strength,
backBase: range(baMin, baMax), backBase: range(baMin, baMax),
frontBase: range(faMin, faMax), frontBase: range(faMin, faMax),
strikeArmed: false, // first update() frame only arms the strike detector
wasFlashing: false, // the previous frame's flash state (0→>0 edge = the strike)
}); });
} }
this.lastScrollX = cam.scrollX; this.lastScrollX = cam.scrollX;
@ -269,9 +280,27 @@ export class StormLayer {
c.back.setAlpha(c.backBase * (0.7 + 0.5 * env.charge + 0.3 * env.flash)); c.back.setAlpha(c.backBase * (0.7 + 0.5 * env.charge + 0.3 * env.flash));
c.flash.setAlpha(env.flash * c.strength); c.flash.setAlpha(env.flash * c.strength);
c.front.setAlpha(c.frontBase * (0.8 + 0.3 * env.charge)); c.front.setAlpha(c.frontBase * (0.8 + 0.3 * env.charge));
// THE STRIKE (data/sfx.json → thunder_01…04): the flash window opens
// exactly at the cycle boundary — the cloud igniting (env.flash goes
// 0 → >0) is the lightning going off. One random thunder clap per
// strike, throttled so a sky of near-simultaneous clusters makes one
// clap, not four. The first frame only ARMS (a cluster already
// mid-strike at scene entry stays silent).
const flashing = env.flash > 0;
if (c.strikeArmed && flashing && !c.wasFlashing) this.thunderStrike(nowMs);
c.strikeArmed = true;
c.wasFlashing = flashing;
} }
} }
/** A strike went off — one random thunder clap (thunder-01…04). */
thunderStrike(nowMs) {
if (nowMs - this.lastThunderMs < THUNDER_MIN_GAP_MS) return;
this.lastThunderMs = nowMs;
playSfxOneOfOn(this.scene, THUNDER_SFX);
}
/** Tear down. */ /** Tear down. */
destroy() { destroy() {
for (const c of this.clusters) { for (const c of this.clusters) {

View File

@ -32,6 +32,15 @@
* the ripple's amplitude surge AND the grade's brightness flash, so the * the ripple's amplitude surge AND the grade's brightness flash, so the
* wave and the light flash together the star flaring. * wave and the light flash together the star flaring.
* *
* The effect also carries its VOICES (data/sfx.json, the shared voice in
* js/utils/Sfx.js): the redDwarf's flare ONSET (flareIntensity 0 >0
* the star flaring up) plays a solar-flare (one of two, at random, once
* per flare); the binary's two-light trade plays solar-buzz as it passes
* its MIDPOINT (uBreath crossing zero the star light's color change
* half-way between the two stars' hues, both crossings). Both detectors
* arm on the first frame, so an effect already in progress at entry
* stays silent.
*
* WebGL only for the filters. On the canvas renderer the filters are a * WebGL only for the filters. On the canvas renderer the filters are a
* no-op (the game degrades to no filter, never to a crash); the CONTENT * no-op (the game degrades to no filter, never to a crash); the CONTENT
* sub-effects still render (they are plain sprites). * sub-effects still render (they are plain sprites).
@ -49,6 +58,7 @@ import {
hexToRgb01, hexToRgb01,
tintMultiplier, tintMultiplier,
} from './SystemEffectsMath.js'; } from './SystemEffectsMath.js';
import { playSfxOn, playSfxOneOfOn } from '../utils/Sfx.js';
import { ensureUiCameras } from './UiCameras.js'; import { ensureUiCameras } from './UiCameras.js';
/** Registered render-node names (renderer-global). */ /** Registered render-node names (renderer-global). */
@ -435,6 +445,8 @@ export class SystemEffects {
this.kind = null; this.kind = null;
this.flare = null; // the shared flare params (or null) this.flare = null; // the shared flare params (or null)
this.phase = 0; // per-system 0..1 this.phase = 0; // per-system 0..1
this.flareOn = null; // the flare's last on/off state (onset detector; null = unarmed)
this.breathSign = null; // the binary light-trade's last sign (midpoint detector; null = unarmed)
} }
/** True while any filter is attached to the world camera. */ /** True while any filter is attached to the world camera. */
@ -622,6 +634,17 @@ export class SystemEffects {
? flareIntensity(nowMs, fl.interval, fl.duration, this.phase * (Number(fl.interval) || 18)) ? flareIntensity(nowMs, fl.interval, fl.duration, this.phase * (Number(fl.interval) || 18))
: 0; : 0;
// THE RED DWARF VOICE (data/sfx.json → solar_flare_01/02): the moment
// the flare bell OPENS (flareIntensity goes 0 → >0 — the star flaring
// up) plays one of the two flares at random. Once per flare (not per
// frame); the first frame only ARMS (a flare already in progress at
// entry stays silent).
const flareOn = flareVal > 0;
if (this.flareOn === false && flareOn) {
playSfxOneOfOn(this.scene, ['solar_flare_01', 'solar_flare_02']);
}
this.flareOn = flareOn;
const c = this.controller; const c = this.controller;
if (c) { if (c) {
c.time = ripplePhase(nowMs, c.speed); c.time = ripplePhase(nowMs, c.speed);
@ -657,6 +680,17 @@ export class SystemEffects {
if (g.breathPeriod > 0) { if (g.breathPeriod > 0) {
const t = nowMs / 1000; const t = nowMs / 1000;
g.uBreath = g.breathDepth * Math.sin((t * Math.PI * 2) / g.breathPeriod + g.breathPhase); g.uBreath = g.breathDepth * Math.sin((t * Math.PI * 2) / g.breathPeriod + g.breathPhase);
// THE BINARY VOICE (data/sfx.json → solar_buzz): the two-light
// trade's MIDPOINT — the star light's color balance (uBreath)
// crossing zero, i.e. the change between the two stars' hues is
// half-way. Both crossings buzz (A→B and B→A). A sample landing
// exactly on zero keeps the last sign (the next nonzero sample
// decides), and the first frame only ARMS the detector.
const sign = g.uBreath > 0 ? 1 : g.uBreath < 0 ? -1 : 0;
if (sign !== 0) {
if (this.breathSign !== null && this.breathSign !== sign) playSfxOn(this.scene, 'solar_buzz');
this.breathSign = sign;
}
} }
} }
} }
@ -673,5 +707,7 @@ export class SystemEffects {
this.grade = null; this.grade = null;
this.kind = null; this.kind = null;
this.flare = null; this.flare = null;
this.flareOn = null; // re-arm the voice detectors (a new apply() starts fresh)
this.breathSign = null;
} }
} }