297 lines
13 KiB
JavaScript
297 lines
13 KiB
JavaScript
/**
|
||
* PerimeterShimmer test (dev tool, run with Node — no browser):
|
||
*
|
||
* node dev/perimeter-shimmer.test.mjs
|
||
*
|
||
* Runs the REAL shimmer (js/ui/PerimeterShimmer.js) — its pure arc math
|
||
* (perimeterPath / arcPoint / arcPolyline, no Phaser) and the class
|
||
* against a stubbed scene (recording FakeGraphics — the
|
||
* quest-tracker.test.mjs pattern) — and pins the READY row's
|
||
* dual-shimmer rim contract:
|
||
*
|
||
* - the perimeter is the CUT-CORNER polygon (6 segments) when notched,
|
||
* a plain rectangle (4) when not — and the notch is clamped (it can
|
||
* never eat the whole plate);
|
||
* - arcPoint / arcPolyline sample the perimeter in arc order,
|
||
* wrap-safe, with consecutive duplicates removed;
|
||
* - the base rim is drawn ONCE (a glow pass under a core pass);
|
||
* - two comets in counter-streams (opposite dirs, half a lap apart —
|
||
* the data/quests.json → tracker.shimmer defaults survive the map);
|
||
* - update(t) re-streams the comets every frame, on the ABSOLUTE
|
||
* clock (a repaint mid-flight never resets the phase — phase0 +
|
||
* t/period give the same head at the same absolute time);
|
||
* - a zero-alpha comet draws nothing; destroy() is idempotent.
|
||
*/
|
||
import { pathToFileURL } from 'node:url';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import fs from 'node:fs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
||
// --- Stub just enough of Phaser for the Color.js import chain ----------
|
||
const PhaserStub = {
|
||
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
|
||
};
|
||
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
|
||
|
||
const {
|
||
PerimeterShimmer,
|
||
perimeterPath,
|
||
arcPoint,
|
||
arcPolyline,
|
||
} = await import(pathToFileURL(join(__dirname, '../js/ui/PerimeterShimmer.js')).href);
|
||
|
||
let failures = 0;
|
||
const check = (label, cond) => {
|
||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||
if (!cond) failures++;
|
||
};
|
||
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) < eps;
|
||
const near = (p, x, y, eps = 1e-6) => approx(p.x, x, eps) && approx(p.y, y, eps);
|
||
|
||
// ===========================================================================
|
||
// 1) Pure: the perimeter — cut-corner vs plain, notch clamped
|
||
// ===========================================================================
|
||
{
|
||
const rect = perimeterPath(0, 0, 100, 20, 0);
|
||
check('plain rect: 4 perimeter segments, P = 2*(w+h)',
|
||
rect.segs.length === 4 && approx(rect.P, 240));
|
||
|
||
const cut = perimeterPath(0, 0, 100, 20, 6);
|
||
const diag = Math.hypot(6, 6);
|
||
check('cut-corner: 6 perimeter segments (4 edges + 2 diagonals)', cut.segs.length === 6);
|
||
check('…and P = 2*(w-n) + 2*(h-n) + 2*diag',
|
||
approx(cut.P, 2 * 94 + 2 * 14 + 2 * diag, 1e-9));
|
||
check('the diagonals sit at top-left + bottom-right (the CyberShape corners)',
|
||
near({ x: cut.segs[2].ax, y: cut.segs[2].ay }, 100, 14) &&
|
||
near({ x: cut.segs[2].bx, y: cut.segs[2].by }, 94, 20) &&
|
||
near({ x: cut.segs[5].ax, y: cut.segs[5].ay }, 0, 6) &&
|
||
near({ x: cut.segs[5].bx, y: cut.segs[5].by }, 6, 0));
|
||
|
||
const big = perimeterPath(0, 0, 100, 20, 1000);
|
||
check('a runaway notch is CLAMPED (n <= h/2 — never eats the plate)',
|
||
big.segs.length === 6 && Number.isFinite(big.P) && approx(big.segs[0].ax, 10));
|
||
|
||
const off = perimeterPath(30, 40, 100, 20, 0);
|
||
check('absolute coords: the rect starts at (x, y)',
|
||
near({ x: off.segs[0].ax, y: off.segs[0].ay }, 30, 40));
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 2) Pure: arcPoint — arc order, corners, wrap, negatives
|
||
// ===========================================================================
|
||
{
|
||
const { segs, P } = perimeterPath(0, 0, 100, 20, 0);
|
||
check('arcPoint: along the top edge', near(arcPoint(segs, P, 10), 10, 0));
|
||
check('arcPoint: the top-right corner (d = w)', near(arcPoint(segs, P, 100), 100, 0));
|
||
check('arcPoint: down the right edge', near(arcPoint(segs, P, 110), 100, 10));
|
||
check('arcPoint: bottom-left corner (d = 2w + h)', near(arcPoint(segs, P, 220), 0, 20));
|
||
check('arcPoint: d == P wraps to the start', near(arcPoint(segs, P, 240), 0, 0));
|
||
check('arcPoint: negative d wraps the long way (−10 ≡ 230)', near(arcPoint(segs, P, -10), 0, 10));
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 3) Pure: arcPolyline — in-segment, corner-crossing, wrap-safe, deduped
|
||
// ===========================================================================
|
||
{
|
||
const { segs, P } = perimeterPath(0, 0, 100, 20, 0);
|
||
const flat = (pl) => pl.map((p) => `${p.x},${p.y}`).join(' → ');
|
||
|
||
const a = arcPolyline(segs, P, 10, 30);
|
||
check('in one segment: a straight 2-point dash',
|
||
a.length === 2 && near(a[0], 10, 0) && near(a[1], 40, 0));
|
||
|
||
const b = arcPolyline(segs, P, 95, 30);
|
||
check('crossing the top-right corner: [95,0] → [100,0] → [100,20] → [95,20]',
|
||
b.length === 4 && near(b[0], 95, 0) && near(b[1], 100, 0) && near(b[2], 100, 20) && near(b[3], 95, 20));
|
||
|
||
const c = arcPolyline(segs, P, 230, 30);
|
||
check('WRAP-SAFE (crosses d = P): [0,10] → [0,0] → [20,0] (up the left edge, over the corner, 20 units along)',
|
||
c.length === 3 && near(c[0], 0, 10) && near(c[1], 0, 0) && near(c[2], 20, 0));
|
||
|
||
const deduped = (pl) => pl.every((p, i) => i === 0 || !near(p, pl[i - 1]));
|
||
check('no consecutive duplicates (shared boundary points are removed)',
|
||
deduped(a) && deduped(b) && deduped(c));
|
||
|
||
check('zero length → an empty polyline', arcPolyline(segs, P, 50, 0).length === 0);
|
||
|
||
// The notched plate: the dash rides the diagonal cleanly.
|
||
const cut = perimeterPath(0, 0, 100, 20, 6);
|
||
const { P: Pc } = cut;
|
||
const topLen = 94;
|
||
const d = arcPolyline(cut.segs, Pc, topLen + 6, 20);
|
||
check('notched: the dash crosses the bottom-right diagonal (right edge → cut → bottom edge)',
|
||
d.length === 4 && near(d[0], 100, 6, 1e-6) && near(d[1], 100, 14) && near(d[2], 94, 20) &&
|
||
approx(d[3].y, 20) && near(d[3], ...Object.values(arcPoint(cut.segs, Pc, topLen + 26))));
|
||
console.log(` (debug) ${flat(d)}`);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 4) Class: the rim is drawn once; two counter-streaming comets
|
||
// ===========================================================================
|
||
function makeScene() {
|
||
const glist = [];
|
||
const container = {
|
||
children: [],
|
||
add(o) {
|
||
this.children.push(o);
|
||
return this;
|
||
},
|
||
};
|
||
return {
|
||
time: { now: 0 },
|
||
add: {
|
||
graphics: () => {
|
||
const g = { calls: [], destroyed: false, alpha: 1 };
|
||
for (const fn of ['clear', 'lineStyle', 'fillStyle', 'fillRect', 'fillPoints', 'strokePoints', 'lineBetween']) {
|
||
g[fn] = (...args) => {
|
||
if (fn === 'clear') g.calls.length = 0;
|
||
else g.calls.push({ fn, args });
|
||
return g;
|
||
};
|
||
}
|
||
g.setScrollFactor = () => g;
|
||
g.destroy = () => {
|
||
g.destroyed = true;
|
||
return g;
|
||
};
|
||
glist.push(g);
|
||
return g;
|
||
},
|
||
existing: (o) => o,
|
||
},
|
||
glist,
|
||
container,
|
||
};
|
||
}
|
||
const linePts = (g) => g.calls.filter((c) => c.fn === 'lineBetween').map((c) => `${c.args[0]},${c.args[1]}→${c.args[2]},${c.args[3]}`);
|
||
|
||
{
|
||
const scene = makeScene();
|
||
const sh = new PerimeterShimmer(scene, 14, 72, 250, 20, { container: scene.container, notch: 6 });
|
||
|
||
check('the rim + the comet layer join the container (they slide with the tracker)',
|
||
scene.container.children.length === 2 && scene.container.children.includes(sh.rim) && scene.container.children.includes(sh.cometLayer));
|
||
|
||
const rimLines = sh.rim.calls.filter((c) => c.fn === 'lineStyle');
|
||
check('the base rim is drawn ONCE: a glow pass + a core pass (2 lineStyle + 2 strokePoints)',
|
||
rimLines.length === 2 && sh.rim.calls.filter((c) => c.fn === 'strokePoints').length === 2);
|
||
const [glow, core] = rimLines.map((c) => c.args);
|
||
check('the glow is the WIDER, fainter pass (width+3.5, low alpha)',
|
||
glow[0] > core[0] && glow[2] < core[2] && glow[1] === 0xffc94d);
|
||
check('the rim is the AMBER claimable color (0xffc94d)', core[1] === 0xffc94d);
|
||
|
||
check('TWO comets in COUNTER-STREAMS: amber clockwise + cyan counter-clockwise',
|
||
sh.comets.length === 2 &&
|
||
sh.comets[0].dir === 1 && sh.comets[1].dir === -1 &&
|
||
sh.comets[0].color === 0xffc94d && sh.comets[1].color === 0x00e5ff);
|
||
check('…half a lap apart (the crossing lands twice per cycle)',
|
||
Math.abs(sh.comets[0].phase - sh.comets[1].phase) === 0.5);
|
||
check('the rim traces the CUT-CORNER plate (6 segments)', sh.segs.length === 6);
|
||
|
||
// Per-frame: the comets re-stream (line passes land) and MOVE.
|
||
sh.update(0);
|
||
const at0 = linePts(sh.cometLayer);
|
||
sh.update(sh.periodMs / 4);
|
||
const atQuarter = linePts(sh.cometLayer);
|
||
check('update(t) re-streams the comets (line passes on the comet layer)',
|
||
at0.length > 0 && atQuarter.length > 0);
|
||
check('…and they MOVE with the clock (a quarter lap shifts the dashes)',
|
||
JSON.stringify(at0) !== JSON.stringify(atQuarter));
|
||
|
||
// ABSOLUTE phase: phase0 + t/period is the head — a "repaint" (a fresh
|
||
// instance at the same absolute time) draws the SAME dashes.
|
||
const A = new PerimeterShimmer(makeScene(), 14, 72, 250, 20, { notch: 6, phase0: 0, periodMs: 2400 });
|
||
A.update(1200); // half a lap by the clock
|
||
const B = new PerimeterShimmer(makeScene(), 14, 72, 250, 20, { notch: 6, phase0: 0.5, periodMs: 2400 });
|
||
B.update(0); // the SAME head, reached by phase0 instead of the clock
|
||
check('the phase is ABSOLUTE: phase0 0 @ t = T/2 ≡ phase0 0.5 @ t = 0',
|
||
JSON.stringify(linePts(A.cometLayer)) === JSON.stringify(linePts(B.cometLayer)));
|
||
|
||
// destroy() — idempotent, and update() after it is a safe no-op.
|
||
sh.destroy();
|
||
sh.destroy();
|
||
check('destroy(): rim + comet layer gone, idempotent',
|
||
sh.destroyed === true && sh.rim.destroyed === true && sh.cometLayer.destroyed === true);
|
||
sh.update(100); // must not throw
|
||
check('update() after destroy(): a safe no-op', true);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 5) Config-driven: data/quests.json → tracker.shimmer survives the map
|
||
// ===========================================================================
|
||
{
|
||
const quests = JSON.parse(fs.readFileSync(join(__dirname, '../data/quests.json'), 'utf8'));
|
||
const cfg = quests.tracker.shimmer;
|
||
check('the config exists and is enabled (with the tuned defaults)',
|
||
cfg !== null && cfg.enabled === true && cfg.notch === 6 && cfg.periodMs === 2400 &&
|
||
cfg.dashFrac === 0.3 && cfg.claimFlashMs === 150);
|
||
check('the config defines the dual comets (amber CW, neon CCW, half a lap apart)',
|
||
cfg.comets.length === 2 && cfg.comets[0].dir === 1 && cfg.comets[1].dir === -1 &&
|
||
cfg.comets[0].phase === 0 && cfg.comets[1].phase === 0.5);
|
||
|
||
const scene = makeScene();
|
||
const sh = new PerimeterShimmer(scene, 14, 72, 250, 20, {
|
||
container: scene.container,
|
||
notch: cfg.notch,
|
||
periodMs: cfg.periodMs,
|
||
dashFrac: cfg.dashFrac,
|
||
base: cfg.base,
|
||
comets: cfg.comets,
|
||
});
|
||
check('the tracker.shimmer base rim lands (amber, the config alpha)',
|
||
(() => {
|
||
const lines = sh.rim.calls.filter((c) => c.fn === 'lineStyle');
|
||
const core = lines[lines.length - 1].args;
|
||
return core[1] === 0xffc94d && approx(core[2], 0.55);
|
||
})());
|
||
sh.update(100);
|
||
check('the tracker.shimmer comets stream (both colors drawn on the comet layer)',
|
||
(() => {
|
||
const colors = new Set(sh.cometLayer.calls.filter((c) => c.fn === 'lineStyle').map((c) => c.args[1]));
|
||
return colors.has(0xffc94d) && colors.has(0x00e5ff);
|
||
})());
|
||
sh.destroy();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 6) Degenerate configs stay safe
|
||
// ===========================================================================
|
||
{
|
||
const scene = makeScene();
|
||
// A zero-alpha comet draws nothing (the rim still stands).
|
||
const sh0 = new PerimeterShimmer(scene, 0, 0, 100, 20, {
|
||
comets: [
|
||
{ color: '#ffc94d', dir: 1, phase: 0, width: 2, alpha: 0, haloAlpha: 0.3 },
|
||
{ color: '#00e5ff', dir: -1, phase: 0.5, width: 2, alpha: 0, haloAlpha: 0.3 },
|
||
],
|
||
});
|
||
sh0.update(0);
|
||
check('zero-alpha comets draw NOTHING (the rim is untouched)',
|
||
sh0.cometLayer.calls.filter((c) => c.fn === 'lineBetween').length === 0 &&
|
||
sh0.rim.calls.filter((c) => c.fn === 'strokePoints').length === 2);
|
||
sh0.destroy();
|
||
|
||
// Missing comets → the built-in dual pair (amber + neon).
|
||
const sh1 = new PerimeterShimmer(scene, 0, 0, 100, 20, {});
|
||
check('no comet config → the built-in dual pair (2 comets, opposite dirs)',
|
||
sh1.comets.length === 2 && sh1.comets[0].dir === 1 && sh1.comets[1].dir === -1);
|
||
sh1.destroy();
|
||
|
||
// A degenerate rect throws loudly (never a silent NaN perimeter).
|
||
let threw = false;
|
||
try {
|
||
new PerimeterShimmer(scene, 0, 0, 0, 0, {});
|
||
} catch {
|
||
threw = true;
|
||
}
|
||
check('a degenerate rect (0x0) throws (no silent NaN perimeter)', threw === true);
|
||
}
|
||
|
||
if (failures > 0) {
|
||
console.error(`\n${failures} perimeter-shimmer test(s) FAILED`);
|
||
process.exit(1);
|
||
}
|
||
console.log('\nperimeter-shimmer: all checks passed');
|