diff --git a/assets/images/planets.png b/assets/images/planets.png index a8e8dfd..f760ce9 100644 Binary files a/assets/images/planets.png and b/assets/images/planets.png differ diff --git a/assets/music/gasgiant-03.mp3 b/assets/music/gasgiant-03.mp3 new file mode 100644 index 0000000..e5a5c94 Binary files /dev/null and b/assets/music/gasgiant-03.mp3 differ diff --git a/assets/music/rocky-01.mp3 b/assets/music/rocky-01.mp3 new file mode 100644 index 0000000..2a856fc Binary files /dev/null and b/assets/music/rocky-01.mp3 differ diff --git a/assets/music/rocky-02.mp3 b/assets/music/rocky-02.mp3 new file mode 100644 index 0000000..0e9036c Binary files /dev/null and b/assets/music/rocky-02.mp3 differ diff --git a/data/music.json b/data/music.json index 0139377..bb663ae 100644 --- a/data/music.json +++ b/data/music.json @@ -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, "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", "game": [ "assets/music/deepspace-01.mp3", @@ -10,10 +14,16 @@ "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", + "2": "assets/music/terran-03.mp3", + "3": "assets/music/gasgiant-01.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": {} diff --git a/data/sfx.json b/data/sfx.json index 26bc217..f2769fa 100644 --- a/data/sfx.json +++ b/data/sfx.json @@ -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('') plays the asset queued as 'sfx_'. 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('') plays the asset queued as 'sfx_'. 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, "volume": 1, "construct": "assets/fx/type-construct.mp3", @@ -9,6 +9,13 @@ "mining_loop": "assets/fx/mining-01.mp3", "mining_split": "assets/fx/mining-02.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_click": "assets/fx/ui-click.mp3", "ui_window": "assets/fx/ui-window.mp3", diff --git a/dev/music.test.mjs b/dev/music.test.mjs index 7ef63c7..164ff58 100644 --- a/dev/music.test.mjs +++ b/dev/music.test.mjs @@ -5,7 +5,8 @@ * * 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 + * 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 + * setSurfaceMusic seam, and the data/music.json contract (menu + every * 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 }, Physics: { Arcade: { Sprite: ClassStub } }, 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); 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 ); const { SurfaceScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/SurfaceScene.js')).href); @@ -86,11 +91,11 @@ const makeScene = (o = {}) => { { const scene = makeScene(); 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[0].key === 'music_menu' && 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. @@ -151,16 +156,15 @@ const makeScene = (o = {}) => { 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. { 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); + MUSIC.frames != null && typeof MUSIC.frames === 'object' && + Object.entries(MUSIC.frames).every(([f, 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); } // 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). const s2 = Object.create(SurfaceScene.prototype); - s2.planetFrame = 9; // no such frame - s2.musicSpec = 'music.frames.9'; + s2.planetFrame = 12; // beyond the sheet — no such frame + s2.musicSpec = 'music.frames.12'; s2.queued = []; s2.load = { audio: (k, u) => s2.queued.push([k, u]), video: () => {} }; SurfaceScene.prototype.preload.call(s2); @@ -207,11 +211,11 @@ const makeScene = (o = {}) => { cache: { audio: { has: () => 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[0].key === 'music_frames_3' && plays[0].opts.loop === true && - plays[0].opts.volume === MUSIC.volume); + plays[0].opts.volume === musicVolumeFor('frames.3')); SurfaceScene.prototype.setSurfaceMusic.call(scene, true); check('... a re-fire (scene restart) keeps ONE loop', plays.length === 1); SurfaceScene.prototype.setSurfaceMusic.call(scene, false); @@ -263,10 +267,10 @@ const makeShuffleScene = (o = {}) => { 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', + 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].opts.loop === false && - scene.plays[0].opts.volume === MUSIC.volume); + scene.plays[0].opts.volume === musicVolumeFor('game')); const first = scene.plays[0].key; const firstFile = first === ka ? fa : fb; 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); } +// 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 ✘`); process.exit(failures === 0 ? 0 : 1); diff --git a/dev/sfx.test.mjs b/dev/sfx.test.mjs index c0413e6..b49c1fc 100644 --- a/dev/sfx.test.mjs +++ b/dev/sfx.test.mjs @@ -7,8 +7,9 @@ * 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_ - * cache key) and point at files that exist. + * checks playSfxOneOfOn() (the one-of-a-set voice: a thunder strike, + * a solar flare) and that the sfx.json keys follow the play-name + * convention (sfx_ cache key) and point at files that exist. */ import { pathToFileURL, fileURLToPath } from 'node:url'; 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); 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; const check = (label, cond) => { @@ -154,5 +155,48 @@ const makeScene = (o = {}) => { 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 ✘`); process.exit(failures === 0 ? 0 : 1); diff --git a/dev/storm.test.mjs b/dev/storm.test.mjs new file mode 100644 index 0000000..e70ec15 --- /dev/null +++ b/dev/storm.test.mjs @@ -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_01…04): + * - 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); diff --git a/dev/system-effects.test.mjs b/dev/system-effects.test.mjs index 7ebf2dd..173eabb 100644 --- a/dev/system-effects.test.mjs +++ b/dev/system-effects.test.mjs @@ -439,12 +439,17 @@ const fakeCameras = { 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 = { scale: { width: 1280, height: 720 }, cameras: fakeCameras, sys: { displayList: { getChildren: () => [uiRoot, worldRoot, mkObject('world-2', 1)] } }, renderer: { gl: {}, renderNodes: fakeRenderer.renderNodes }, systemContent: { fx: { phase: 0.5, angle: 0.3, drift: 0.7 } }, // per-system variation + sound: { play: (key) => { sfxCalls.push({ key }); } }, }; const split = ensureUiCameras(fakeScene); @@ -618,6 +623,46 @@ check('facade: release() detaches cleanly (and twice)', (() => { 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) ---------------------- const RippleClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE]; diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index e82f745..75ca214 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 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 + `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 on Take Off AND on Return to Main Menu, and the game soundtrack dies on landing AND on returning to the menu diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 2cfa5cd..3e78408 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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_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_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_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')); @@ -609,6 +616,17 @@ export class GameScene extends Phaser.Scene { this.starfield = new Starfield(this); 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), // tinted with the system's own atmosphere color (assigned at random at // galaxy generation — SystemGenerator). Nebula systems only. See @@ -4538,6 +4556,34 @@ export class GameScene extends Phaser.Scene { 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() { this.setMiningLoop(false); // the hum can't outlive the scene this.stopGameMusic(); // neither can the soundtrack diff --git a/js/utils/Music.js b/js/utils/Music.js index b21495a..7b3bdc0 100644 --- a/js/utils/Music.js +++ b/js/utils/Music.js @@ -15,8 +15,16 @@ import { config } from '../config/Config.js'; * (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. + * Music is always played LOOPING at its context's volume, and one loop + * 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.' / 'stationFrames.' (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…). @@ -27,6 +35,22 @@ export function musicKey(spec) { 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.', 'stationFrames.') — 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. */ export function playMusicOn(scene, spec) { if (!scene || !config.get('music.enabled', true)) return; @@ -35,7 +59,7 @@ export function playMusicOn(scene, spec) { 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) }); + 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. */ @@ -124,7 +148,7 @@ export function startMusicShuffleOn(scene, files) { } const file = pool[Math.floor(Math.random() * pool.length)]; st.current = file; - snd.play(gameTrackKey(file), { loop: false, volume: config.get('music.volume', 0.6) }); + snd.play(gameTrackKey(file), { loop: false, volume: musicVolumeFor('game') }); return true; }; diff --git a/js/utils/Sfx.js b/js/utils/Sfx.js index 7fab2fb..02a6a6e 100644 --- a/js/utils/Sfx.js +++ b/js/utils/Sfx.js @@ -27,6 +27,19 @@ export function playSfxOn(scene, name, 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_`); + * 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 * it isn't playing). v4 (Giedi) API: the manager stops BY KEY — diff --git a/js/visuals/StormLayer.js b/js/visuals/StormLayer.js index 5b5f0cd..2bba3d9 100644 --- a/js/visuals/StormLayer.js +++ b/js/visuals/StormLayer.js @@ -33,11 +33,17 @@ * Each cluster gets its own seeded interval + phase, so the strikes are * staggered — the sky flickers from different places, like weather. * + * VOICE (data/sfx.json → thunder_01…04): 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. */ import Phaser from '../vendor/phaser.js'; import { toColor } from '../utils/Color.js'; import { canvasTexture } from '../utils/Textures.js'; +import { playSfxOneOfOn } from '../utils/Sfx.js'; import { drawCloud, cloudAlpha } from './NebulaAtmosphere.js'; import { stormEnvelope } from './SystemEffectsMath.js'; import { assignWorld } from './UiCameras.js'; @@ -48,6 +54,8 @@ const DEPTH_FLASH = 3.1; 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 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. */ function makeRng(seedNum) { @@ -113,6 +121,7 @@ export class StormLayer { this.clusters = []; this.lastScrollX = 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). */ @@ -213,6 +222,8 @@ export class StormLayer { interval, phase, strikeDur, chargeWindow, strength, backBase: range(baMin, baMax), 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; @@ -269,9 +280,27 @@ export class StormLayer { c.back.setAlpha(c.backBase * (0.7 + 0.5 * env.charge + 0.3 * env.flash)); c.flash.setAlpha(env.flash * c.strength); 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. */ destroy() { for (const c of this.clusters) { diff --git a/js/visuals/SystemEffects.js b/js/visuals/SystemEffects.js index bc04976..2589854 100644 --- a/js/visuals/SystemEffects.js +++ b/js/visuals/SystemEffects.js @@ -32,6 +32,15 @@ * the ripple's amplitude surge AND the grade's brightness flash, so the * 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 * no-op (the game degrades to no filter, never to a crash); the CONTENT * sub-effects still render (they are plain sprites). @@ -49,6 +58,7 @@ import { hexToRgb01, tintMultiplier, } from './SystemEffectsMath.js'; +import { playSfxOn, playSfxOneOfOn } from '../utils/Sfx.js'; import { ensureUiCameras } from './UiCameras.js'; /** Registered render-node names (renderer-global). */ @@ -435,6 +445,8 @@ export class SystemEffects { this.kind = null; this.flare = null; // the shared flare params (or null) 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. */ @@ -622,6 +634,17 @@ export class SystemEffects { ? flareIntensity(nowMs, fl.interval, fl.duration, this.phase * (Number(fl.interval) || 18)) : 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; if (c) { c.time = ripplePhase(nowMs, c.speed); @@ -657,6 +680,17 @@ export class SystemEffects { if (g.breathPeriod > 0) { const t = nowMs / 1000; 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.kind = null; this.flare = null; + this.flareOn = null; // re-arm the voice detectors (a new apply() starts fresh) + this.breathSign = null; } }