214 lines
8.6 KiB
JavaScript
214 lines
8.6 KiB
JavaScript
/**
|
|
* 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);
|