Add thunder audio on lightning strike edges

- Play a soft thunder SFX (sfx_thunder_01..04 at volume 0.35) on the
  strobe's 0→>0 edge, once per strike; mid-strobe frames stay silent.
- Move `wasOn` latch after the thunder check so it reflects the previous
  frame's state (fixes arm-frame and edge detection).
- Add `thunder: false` config to silence the voice while keeping the bolt.
- Cover with tests: arm frame silent, one clap per strike, mute path.
This commit is contained in:
Brian Fertig 2026-09-10 23:11:35 -06:00
parent 8e035d0891
commit 32215fa737
2 changed files with 52 additions and 6 deletions

View File

@ -477,6 +477,52 @@ check('lightning: a strike frame draws the bolt (both stroked); a quiet frame cl
return drewOnStrike && cleared(lScene.gfx[0]) && cleared(lScene.gfx[1]);
})());
// Thunder (the VOICE of the bolt) — a fresh instance + a sound-recording
// fake scene (the Sfx seam: scene.sound.play + the audio-cache guard), so
// the assertions are independent of the update history above.
const tScene = makeFakeScene();
const thunderPlays = [];
tScene.sound = { play: (key, o) => thunderPlays.push({ key, volume: o?.volume }) };
tScene.cache = { audio: { has: (key) => String(key).startsWith('sfx_thunder_') } };
const THUNDER_KEYS = ['sfx_thunder_01', 'sfx_thunder_02', 'sfx_thunder_03', 'sfx_thunder_04'];
const tL = new NebulaLightning(tScene, '#f0884a', '#e9f36d', lcfg, lFx);
tL.create();
const tEdge = (tL.phase % tL.interval) * 1000; // this cycle's strike boundary
tL.update(tEdge, 16); // first frame: arms the detector (entered mid-strike → silent)
check('thunder: the arm frame (a system entered mid-strike) stays silent', thunderPlays.length === 0);
// A dark frame between strikes (the real cadence has a long one — the
// window is 1.35s, the interval 8–15s) resets the 0→>0 edge.
const DARK = (t) => tL.update(t, 16);
DARK(tEdge + 1500); // just past the 1.35s strobe window → dark sky
tL.update(tEdge + tL.interval * 1000, 16); // the NEXT strike: a real 0→>0 edge
check('thunder: one clap on the strike edge — a far, soft thunder voice (0.35)', (() => {
return (
thunderPlays.length === 1 &&
THUNDER_KEYS.includes(thunderPlays[0].key) &&
Math.abs(thunderPlays[0].volume - 0.35) < 1e-9
);
})());
tL.update(tEdge + tL.interval * 1000 + 100, 16); // mid-strobe: the bolt is still up
DARK(tEdge + tL.interval * 1000 + 1500); // …and then the sky darkens
tL.update(tEdge + tL.interval * 2000, 16); // the strike AFTER next: a new edge
tL.update(tEdge + tL.interval * 2000 + 100, 16); // …and its mid-strobe
check('thunder: one clap per strike (mid-strobe frames stay silent)', thunderPlays.length === 2);
// The `thunder: false` config silences the voice (the bolt still strikes).
const qScene = makeFakeScene();
const quietPlays = [];
qScene.sound = { play: () => quietPlays.push(1) };
qScene.cache = { audio: { has: () => true } };
const qL = new NebulaLightning(qScene, '#f0884a', '#e9f36d', { ...lcfg, thunder: false }, lFx);
qL.create();
const qEdge = (qL.phase % qL.interval) * 1000;
qL.update(qEdge, 16);
qL.update(qEdge + qL.interval * 1000, 16);
check('thunder: `thunder: false` silences the voice (the bolt still strikes)', (() => {
const drew = (g) => g.calls.filter((c) => c === 'line').length > 0;
return quietPlays.length === 0 && drew(qScene.gfx[0]) && drew(qScene.gfx[1]);
})());
check('lightning: camera-anchored (sky model); destroy is idempotent', (() => {
lScene.cameras.main.scrollX = 2600;
lScene.cameras.main.scrollY = 1500;

View File

@ -164,15 +164,15 @@ export class NebulaLightning {
this.wasOn = false;
return;
}
this.wasOn = true;
// THE STRIKE (data/sfx.json → thunder_01…04, soft): the bolt going
// off is the strobe's 0 → >0 edge (this.wasOn is reset on the dark
// frames above). The first frame only arms (a system entered
// mid-strike stays silent), and the throttle makes near-simultaneous
// frames one clap.
// off is the strobe's 0 → >0 edge — this.wasOn is still the
// PREVIOUS frame's state here (it was reset on the dark frames
// above, and latched AFTER this check). The first frame only arms
// (a system entered mid-strike stays silent), and the throttle
// makes near-simultaneous frames one clap.
if (this.armed && !this.wasOn) this.thunderStrike(nowMs);
this.armed = true;
this.wasOn = true;
// THE BOLT: shape from (strike ordinal, system seed) — stable through
// this strobe, different every strike, identical every run.