540 lines
24 KiB
JavaScript
540 lines
24 KiB
JavaScript
/**
|
||
* 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);
|