orbit/dev/nebula.test.mjs

540 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Nebula atmosphere test (dev tool, run with Node — no browser needed):
*
* node dev/nebula.test.mjs
*
* Asserts:
* - the PALETTE contract (data/game.json → nebula.palette): exactly 8
* distinct, valid hex shades — the gas is tinted per-system from this;
* - the PER-SYSTEM COLOR (js/galaxy/SystemGenerator.js): every generated
* system stamps content.atmosphere.color, always a member of the
* palette; same seed ⇒ the same color for a system (deterministic), and
* it is stable across lazy vs. eager generation;
* - the CLOUD TEXTURE (js/visuals/NebulaAtmosphere.js → drawCloud): a
* valid alpha channel (some opaque gas, not empty), faded at the edges
* (center denser than the rim), white RGB (tint sets the color), and
* deterministic (two renders are byte-identical);
* - the FRONT ACCENT (js/utils/Color.js → shiftHue): the near layer's
* tint is the base hue rotated by the configured hueShift, lightness
* lifted (lightScale), saturation preserved (satScale 1); identity,
* white/black preservation, and a valid distinct accent for all 8
* palette shades;
* - the TWO-LAYER STRUCTURE (NebulaAtmosphere.create/update/destroy):
* back clouds at depth 3 in the base tint with far parallax (<1),
* front clouds at depth 11 (ABOVE the ship) in the accent tint with
* near parallax (>1), sizes/alphas in the configured ranges, clouds
* wrapped into the camera window on flight, slow self-swirl, clean
* destroy, and front.enabled=false reverting to the single back layer;
* - the far-sky LIGHTNING (js/visuals/NebulaLightning.js): glow + core
* Graphics on the SKY depth (2.9/2.91 — behind the back gas), the
* glow additive; a strike frame draws the bolt (both stroked), a
* quiet frame clears it; camera-anchored; destroy idempotent.
* (The bolt math itself — strobe envelope, cycle, shape — is covered
* in dev/system-effects.test.mjs.)
*/
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 { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { drawCloud, NebulaAtmosphere } = await import(
pathToFileURL(join(__dirname, '../js/visuals/NebulaAtmosphere.js')).href
);
const { NebulaLightning } = await import(
pathToFileURL(join(__dirname, '../js/visuals/NebulaLightning.js')).href
);
const { shiftHue, toColor, toCss } = await import(
pathToFileURL(join(__dirname, '../js/utils/Color.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// --- Helpers -------------------------------------------------------------
const isHex = (s) =>
typeof s === 'string' && /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s.trim());
function hueOf(hex) {
let h = String(hex).trim().replace(/^#/, '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const n = parseInt(h, 16);
const r = ((n >> 16) & 255) / 255;
const g = ((n >> 8) & 255) / 255;
const b = (n & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
if (d === 0) return 0;
let hue;
if (max === r) hue = ((g - b) / d) % 6;
else if (max === g) hue = (b - r) / d + 2;
else hue = (r - g) / d + 4;
hue *= 60;
return hue < 0 ? hue + 360 : hue;
}
/** HSL of a color (number 0xRRGGBB or hex string) → { h: 0–360, s: 0–1, l: 0–1 } */
function hslOf(c) {
let n;
if (typeof c === 'number') n = c >>> 0;
else {
let h = String(c).trim().replace(/^#/, '');
if (h.length === 3) h = h.split('').map((ch) => ch + ch).join('');
n = parseInt(h, 16);
}
const r = ((n >> 16) & 255) / 255;
const g = ((n >> 8) & 255) / 255;
const b = (n & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
const l = (max + min) / 2;
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h, s, l };
}
// A headless 2-D context just enough for drawCloud (it only uses
// createImageData / putImageData). drawCloud fills the buffer we hand it.
function renderCloud(w, h, tex) {
const data = new Uint8ClampedArray(w * h * 4);
const ctx = {
createImageData: (ww, hh) => ({ width: ww, height: hh, data }),
putImageData: () => {}, // the buffer is already filled by drawCloud
};
drawCloud(ctx, w, h, tex);
return data;
}
// =========================================================================
// 1. PALETTE contract
// =========================================================================
console.log('\n— Palette (data/game.json → nebula.palette) —');
const nebulaCfg = config.section('game.nebula', {});
const palette = nebulaCfg.palette ?? [];
const distinct = new Set(palette.map((c) => String(c).toLowerCase().replace(/^#/, '')));
check('palette has exactly 8 shades', palette.length === 8);
check('all 8 shades are distinct', distinct.size === 8);
check('every shade is a valid hex color', palette.every(isHex));
if (palette.length === 8) {
const hues = palette.map(hueOf);
const minSep = (() => {
const sorted = [...hues].sort((a, b) => a - b);
let m = Infinity;
for (let i = 0; i < sorted.length; i++) {
const next = (i + 1) % sorted.length;
let gap = Math.abs(sorted[next] - sorted[i]);
gap = Math.min(gap, 360 - gap);
if (i < sorted.length - 1) m = Math.min(m, gap);
}
return m;
})();
console.log(
` hues: ${hues.map((h) => h.toFixed(0) + '°').join(' ')} (min neighbour separation ${minSep.toFixed(0)}°)`,
);
check('colours are well spread (min neighbour separation ≥ 25°)', minSep >= 25);
}
// =========================================================================
// 2. PER-SYSTEM COLOR (deterministic, palette-member)
// =========================================================================
console.log('\n— Per-system colour (SystemGenerator) —');
const SEED = 'orbit-nebula-test';
const gal = Galaxy.create(SEED);
const gal2 = Galaxy.create(SEED); // fresh, same seed → must match
let allStamped = true;
let allInPalette = true;
let sameSeedStable = true;
let nebulaSystems = 0;
const seenColors = new Set();
for (const rec of gal.records) {
const c1 = gal.ensureContent(rec.id);
const c2 = gal2.ensureContent(rec.id);
if (!c1?.atmosphere || typeof c1.atmosphere.color !== 'string') allStamped = false;
if (typeof c1?.atmosphere?.color === 'string' && !isHex(c1.atmosphere.color)) allInPalette = false;
const palKey = (x) => String(x ?? '').toLowerCase().replace(/^#/, '');
if (c1?.atmosphere?.color && !palette.some((p) => palKey(p) === palKey(c1.atmosphere.color)))
allInPalette = false;
if (c1?.atmosphere?.color !== c2?.atmosphere?.color) sameSeedStable = false;
if (rec.type === 'nebula') {
nebulaSystems++;
if (c1?.atmosphere?.color) seenColors.add(palKey(c1.atmosphere.color));
}
}
check('every system stamps content.atmosphere.color (a string)', allStamped);
check('every stamped colour is a valid hex in the palette', allInPalette);
check('same seed ⇒ same colour for every system (deterministic)', sameSeedStable);
check('the galaxy actually contains nebula systems', nebulaSystems > 0);
// With 8 colours and ~90 systems, a real random assignment should hit many
// distinct shades across the nebulae (not a degenerate single colour).
check('nebulae draw on more than one shade (assignment is really random)', seenColors.size >= 3);
console.log(
` ${gal.records.length} systems · ${nebulaSystems} nebulae · ${seenColors.size} distinct nebula shades used`,
);
// Lazy === eager: a third galaxy generated on arrival matches.
const spot = gal.records.find((r) => r.type === 'nebula') ?? gal.records[0];
const fresh = Galaxy.create(SEED);
const lazyEq =
fresh.ensureContent(spot.id).atmosphere.color === gal.ensureContent(spot.id).atmosphere.color;
check('lazy (on-arrival) colour === eager (up-front) colour', lazyEq);
// =========================================================================
// 3. CLOUD TEXTURE (drawCloud)
// =========================================================================
console.log('\n— Cloud texture (NebulaAtmosphere.drawCloud) —');
const SIZE = 128; // smaller than the 256 default → fast in Node
const texCfg = nebulaCfg.texture ?? {};
const buf = renderCloud(SIZE, SIZE, texCfg);
const alphaAt = (x, y) => buf[(y * SIZE + x) * 4 + 3];
const rgbWhite = (x, y) => {
const i = (y * SIZE + x) * 4;
return buf[i] === 255 && buf[i + 1] === 255 && buf[i + 2] === 255;
};
// Some gas exists (not an all-transparent sprite).
let opaqueCount = 0;
let maxAlpha = 0;
for (let y = 0; y < SIZE; y++)
for (let x = 0; x < SIZE; x++) {
const a = alphaAt(x, y);
if (a > 0) opaqueCount++;
if (a > maxAlpha) maxAlpha = a;
}
check('the cloud has visible gas (some opaque pixels)', opaqueCount > SIZE * SIZE * 0.01);
check('the brightest filament reaches real density (max alpha ≥ 40)', maxAlpha >= 40);
// White RGB everywhere (the tint sets the color; the texture carries alpha).
let allWhite = true;
for (let y = 0; y < SIZE; y += 7)
for (let x = 0; x < SIZE; x += 7) if (alphaAt(x, y) > 0 && !rgbWhite(x, y)) allWhite = false;
check('opaque pixels are white RGB (tint-able)', allWhite);
// Edge fade: the centre band is denser than the outer ring.
const ring = (x0, x1, y0, y1, fn) => {
let s = 0;
let n = 0;
// Integer coords only — the alpha buffer is indexed by whole pixels.
const ax0 = Math.round(x0), ax1 = Math.round(x1);
const ay0 = Math.round(y0), ay1 = Math.round(y1);
for (let y = ay0; y < ay1; y += 3)
for (let x = ax0; x < ax1; x += 3) {
s += fn(x, y);
n++;
}
return n ? s / n : 0;
};
const centre = ring(SIZE * 0.35, SIZE * 0.65, SIZE * 0.35, SIZE * 0.65, alphaAt);
// The rim: average alpha over ONLY the outer 12% border (apples to apples
// with the centre — not diluted by the interior pixels).
let rimSum = 0;
let rimN = 0;
for (let y = 0; y < SIZE; y += 2)
for (let x = 0; x < SIZE; x += 2)
if (x < SIZE * 0.12 || x > SIZE * 0.88 || y < SIZE * 0.12 || y > SIZE * 0.88) {
rimSum += alphaAt(x, y);
rimN++;
}
const rim = rimN ? rimSum / rimN : 0;
check('edges fade to transparent (centre denser than the rim)', centre > rim);
console.log(` centre avg alpha ${centre.toFixed(1)} · rim avg alpha ${rim.toFixed(1)}`);
// Determinism: two renders are byte-identical.
const buf2 = renderCloud(SIZE, SIZE, texCfg);
let identical = buf.length === buf2.length;
if (identical) for (let i = 0; i < buf.length; i++) if (buf[i] !== buf2[i]) { identical = false; break; }
check('texture is deterministic (two renders byte-identical)', identical);
// =========================================================================
// 4. FRONT ACCENT (shiftHue — the near layer's derived tint)
// =========================================================================
console.log('\n— Front accent (Color.shiftHue) —');
const accentCfg = nebulaCfg.front?.accent ?? {};
const HUE_SHIFT = accentCfg.hueShift ?? 42;
const SAT_SCALE = accentCfg.satScale ?? 1;
const LIGHT_SCALE = accentCfg.lightScale ?? 1.12;
const baseColor = toColor('#f0884a');
const accent = shiftHue(baseColor, { hueShift: HUE_SHIFT, satScale: SAT_SCALE, lightScale: LIGHT_SCALE });
const hueDelta = (() => {
// expected: hue(accent) − hue(base) ≡ +HUE_SHIFT (mod 360)
const d = (hslOf(accent).h - hslOf(baseColor).h + 360) % 360;
const want = HUE_SHIFT % 360;
return Math.min(Math.abs(d - want), 360 - Math.abs(d - want));
})();
check(`hue rotated by the configured ${HUE_SHIFT}° (±3°)`, hueDelta <= 3);
check(`lightness lifted by lightScale ${LIGHT_SCALE}`, hslOf(accent).l > hslOf(baseColor).l);
check(`saturation preserved (satScale ${SAT_SCALE})`, Math.abs(hslOf(accent).s - hslOf(baseColor).s) <= 0.05);
// Identity: no shift ⇒ same color (float round-trip, ±1 per channel).
const identity = shiftHue(0x3f6fa0, {});
const chanDiff = (a, b, shift) => Math.abs(((a >> shift) & 255) - ((b >> shift) & 255));
check('identity round-trip keeps the color (±1/channel)',
chanDiff(0x3f6fa0, identity, 16) <= 1 && chanDiff(0x3f6fa0, identity, 8) <= 1 && chanDiff(0x3f6fa0, identity, 0) <= 1);
// Grays keep their gray: white stays white, black stays black.
check('white and black are preserved',
shiftHue(0xffffff, { hueShift: 42, lightScale: 1.12 }) === 0xffffff &&
shiftHue(0x000000, { hueShift: 42, lightScale: 1.12 }) === 0x000000);
// Every palette shade yields a VALID accent that is DISTINCT from its base
// (a 42° shift can't collide) and keeps saturation sane.
let accentsOk = true;
for (const shade of palette) {
const b2 = toColor(shade);
const a2 = shiftHue(b2, { hueShift: HUE_SHIFT, satScale: SAT_SCALE, lightScale: LIGHT_SCALE });
if (a2 < 0 || a2 > 0xffffff || a2 === b2) accentsOk = false;
const hl = hslOf(a2);
if (hl.l < 0 || hl.l > 1) accentsOk = false;
}
check('all 8 palette shades → valid, distinct accents', accentsOk);
console.log(` base ${toCss(baseColor)} → accent ${toCss(accent)} (Δhue vs the expected +${HUE_SHIFT}°: ${hueDelta.toFixed(1)}°)`);
// =========================================================================
// 5. TWO-LAYER STRUCTURE (create / update / destroy)
// =========================================================================
console.log('\n— Two-layer structure (NebulaAtmosphere) —');
// A minimal fake scene: just enough surface for create()/update()/destroy()
// (scale, cameras, add.image, textures, time). canvasTexture needs a
// document.createElement canvas — stub it (the draw is a no-op here; the
// TEXTURE math is already covered in section 3).
function makeFakeScene() {
const images = [];
const fakeCtx = {
createImageData: (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }),
putImageData: () => {},
};
globalThis.document = {
createElement: () => ({ width: 0, height: 0, getContext: () => fakeCtx }),
};
const makeImage = (x, y, key) => {
const o = { x, y, key, scale: 1, alpha: 1, rotation: 0, depth: 0, tint: 0, destroyed: false };
o.setScale = (v) => ((o.scale = v), o);
o.setAlpha = (v) => ((o.alpha = v), o);
o.setRotation = (v) => ((o.rotation = v), o);
o.setDepth = (v) => ((o.depth = v), o);
o.setTint = (v) => ((o.tint = v), o);
o.destroy = () => (o.destroyed = true);
images.push(o);
return o;
};
// A fake Graphics (the lightning module's only drawing surface): records
// every stroke so the tests can assert draw-vs-clear without a canvas.
const gfx = [];
const makeGfx = () => {
const o = { x: 0, y: 0, depth: 0, calls: [], destroyed: false };
o.clear = () => o.calls.push('clear');
o.lineStyle = (w, c, a) => (o.style = [w, c, a]);
o.lineBetween = () => o.calls.push('line');
o.setDepth = (d) => ((o.depth = d), o);
o.setBlendMode = (m) => ((o.blend = m), o);
o.destroy = () => (o.destroyed = true);
gfx.push(o);
return o;
};
return {
images,
gfx,
scale: { width: 1280, height: 720 },
cameras: { main: { scrollX: 1000, scrollY: 500 } },
time: { now: 0 },
textures: { exists: () => false, addCanvas: () => {} },
add: { image: makeImage, graphics: makeGfx },
};
}
const scene = makeFakeScene();
const neb = new NebulaAtmosphere(scene, '#f0884a', nebulaCfg);
neb.create();
const M = 120; // = MARGIN in NebulaAtmosphere.js
const back = scene.images.filter((o) => o.depth === 3);
const front = scene.images.filter((o) => o.depth === 11);
check(`back layer: cfg.count (${nebulaCfg.count}) clouds at depth 3`, back.length === nebulaCfg.count);
check(`front layer: cfg.front.count (${nebulaCfg.front?.count}) clouds at depth 11 (above the ship)`,
front.length === (nebulaCfg.front?.count ?? 0));
check('back tint = the base atmosphere color', back.every((o) => o.tint === baseColor));
const expectedAccent = shiftHue(baseColor, { hueShift: HUE_SHIFT, satScale: SAT_SCALE, lightScale: LIGHT_SCALE });
check('front tint = the derived accent (shiftHue of the base)', front.every((o) => o.tint === expectedAccent));
check('back parallax < 1 (far gas, slower than the world)', back.every((o) => o.parallax > 0 && o.parallax < 1));
check('front parallax > 1 (near gas, drifts past the world)',
front.every((o) => o.parallax > 1) && front.every((o) => o.parallax <= (nebulaCfg.front?.parallax?.[1] ?? 1.4) + 1e-9));
check('front alpha within cfg.front.alpha',
front.every((o) => o.alpha >= (nebulaCfg.front?.alpha?.[0] ?? 0.07) - 1e-9 && o.alpha <= (nebulaCfg.front?.alpha?.[1] ?? 0.15) + 1e-9));
check('front clouds are the BIG puffs (cfg.front.size range)',
front.every((o) => o.scale * 256 >= (nebulaCfg.front?.size?.[0] ?? 700) - 1 && o.scale * 256 <= (nebulaCfg.front?.size?.[1] ?? 1400) + 1));
check('all clouds start inside the camera window (+margin)',
scene.images.every((o) =>
o.x >= 1000 - M - 1e-6 && o.x < 1000 + 1280 + M + 1e-6 &&
o.y >= 500 - M - 1e-6 && o.y < 500 + 720 + M + 1e-6));
// drift wiring: rotSpeed = drift × [0.0012, 0.0036] rad/s (drift 2.5 ⇒ the
// original 0.003–0.009; the front layer runs its own drift).
const inRot = (o, drift) => Math.abs(o.rotSpeed) >= drift * 0.0012 - 1e-12 && Math.abs(o.rotSpeed) <= drift * 0.0036 + 1e-12;
check('back swirl speed scaled by cfg.drift', back.every((o) => inRot(o, nebulaCfg.drift ?? 2.5)));
check('front swirl speed scaled by cfg.front.drift', front.every((o) => inRot(o, nebulaCfg.front?.drift ?? nebulaCfg.drift ?? 2.5)));
// Self-swirl over the scene clock (frame-safe, ms-based).
scene.time.now = 500;
neb.update();
check('clouds self-swirl over the scene clock', scene.images.some((o) => Math.abs(o.rotation - o.baseRotation) > 1e-6));
// Camera flight: the gas stays wrapped into the live view window.
scene.cameras.main.scrollX += 3000;
neb.update();
const newLeft = 1000 + 3000 - M;
const spanX = 1280 + 2 * M;
check('camera flight keeps the gas wrapped in view',
scene.images.every((o) => o.x >= newLeft - 1e-6 && o.x < newLeft + spanX + 1e-6));
// Clean teardown (scene jump / system change).
neb.destroy();
check('destroy() removes every cloud', scene.images.every((o) => o.destroyed) && neb.clouds.length === 0);
// The REVERT path: front.enabled = false → the single back layer, as before.
const scene2 = makeFakeScene();
const cfgNoFront = { ...nebulaCfg, front: { ...(nebulaCfg.front ?? {}), enabled: false } };
const neb2 = new NebulaAtmosphere(scene2, '#5b9bf2', cfgNoFront);
neb2.create();
check('front.enabled=false reverts to the single back layer',
scene2.images.length === nebulaCfg.count && scene2.images.every((o) => o.depth === 3));
// --- The far-sky lightning (NebulaLightning) ------------------------------
const lcfg = config.section('systems.types.nebula.effect.lightning', null);
check('systems.json: the nebula wears the far-sky lightning (enabled, bounded cadence)', (() => {
return (
!!lcfg &&
lcfg.enabled !== false &&
Array.isArray(lcfg.interval) && lcfg.interval[0] >= 2 && lcfg.interval[1] > lcfg.interval[0] &&
typeof lcfg.strike === 'number' && lcfg.strike > 0
);
})());
const lScene = makeFakeScene();
const lFx = { phase: 0.3, angle: 0.5, drift: 0.7 };
const lightning = new NebulaLightning(lScene, '#f0884a', '#e9f36d', lcfg, lFx);
lightning.create();
check('lightning: glow + core Graphics on the SKY depth (behind the back gas)', (() => {
const [glow, core] = lScene.gfx;
return (
lScene.gfx.length === 2 &&
glow.depth < 3 && core.depth < 3 && core.depth > glow.depth && // sky: below the back gas (3)
glow.blend === 'ADD' && core.blend === undefined && // halo is light; the core stays crisp
typeof lightning.base === 'number' && typeof lightning.accent === 'number' && lightning.base !== lightning.accent
);
})());
check('lightning: a strike frame draws the bolt (both stroked); a quiet frame clears', (() => {
// The strike sits at t ≡ phase (mod interval) — find this cycle's boundary.
const tStrike = (lightning.phase % lightning.interval) * 1000;
lightning.update(tStrike, 16);
const drew = (g) => g.calls.filter((c) => c === 'line').length > 0;
const drewOnStrike = drew(lScene.gfx[0]) && drew(lScene.gfx[1]);
// Half a cycle later: dark sky again.
lightning.update(tStrike + lightning.interval * 500, 16);
const cleared = (g) => g.calls[g.calls.length - 1] === 'clear';
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;
const t = (lightning.phase % lightning.interval) * 1000;
lightning.update(t, 16);
const anchored = Math.abs(lScene.gfx[0].x - 2600) < 500 && Math.abs(lScene.gfx[0].y - 1500) < 500;
lightning.destroy();
lightning.destroy(); // idempotent
return anchored && lScene.gfx[0].destroyed === true && lScene.gfx[1].destroyed === true && lightning.glow === null;
})());
// ---------------------------------------------------------------------------
console.log(failures === 0 ? '\nAll nebula tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);