320 lines
14 KiB
JavaScript
320 lines
14 KiB
JavaScript
/**
|
|
* System effects test (dev tool, run with Node — no browser needed):
|
|
*
|
|
* node dev/system-effects.test.mjs
|
|
*
|
|
* Asserts:
|
|
* - the RIPPLE MATH (js/visuals/SystemEffectsMath.js): world->screen->UV
|
|
* under identity, translation, rotation and zoom transforms; the
|
|
* phase clock is monotonic and speed-scaled;
|
|
* - the DATA CONTRACT (data/systems.json): every system type carries an
|
|
* `effect` block; `nebula` is the one live family (ripple) with
|
|
* sane numeric parameters (and padding that covers the max
|
|
* displacement); the other types render untouched (none);
|
|
* - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the
|
|
* requested type wins, ties fall to roster order, missing type => null;
|
|
* - the SHADER CONTRACT (js/visuals/SystemEffects.js, exercised against
|
|
* the Phaser stub): every uniform setupUniforms pushes is declared in
|
|
* the fragment source, and the fragment keeps the build's filter-shader
|
|
* conventions (uMainSampler / outTexCoord / boundedSampler);
|
|
* - the FACADE (SystemEffects.apply/update/release) on a fake scene:
|
|
* "none" touches nothing (no camera, no filter); "ripple" needs WebGL
|
|
* and registers the node once, splits the cameras (UI roots off the
|
|
* world pass, world roots off the UI pass), attaches a parameterized
|
|
* controller to the world camera's internal filter list, advances the
|
|
* phase in update() (screen anchor pinned, star anchor tracking
|
|
* world 0,0), and releases cleanly.
|
|
*/
|
|
|
|
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 { worldToScreen, worldToUV, ripplePhase } = await import(
|
|
pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href
|
|
);
|
|
const { pickFxSystem } = await import(
|
|
pathToFileURL(join(__dirname, '../js/galaxy/FxSystems.js')).href
|
|
);
|
|
const SE = await import(pathToFileURL(join(__dirname, '../js/visuals/SystemEffects.js')).href);
|
|
const { ensureUiCameras, assignUi, assignWorld, isScreenPinned } = await import(
|
|
pathToFileURL(join(__dirname, '../js/visuals/UiCameras.js')).href
|
|
);
|
|
|
|
let pass = 0;
|
|
function check(name, cond) {
|
|
if (!cond) {
|
|
console.error(`✗ ${name}`);
|
|
process.exit(1);
|
|
}
|
|
pass++;
|
|
console.log(`✓ ${name}`);
|
|
}
|
|
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
|
|
|
// --- The ripple math -------------------------------------------------------
|
|
|
|
const ID = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
|
|
check('identity: world (0,0) -> screen (0,0)', worldToScreen(ID, 0, 0).x === 0 && worldToScreen(ID, 0, 0).y === 0);
|
|
check('identity: UV of a screen point is itself / size', (() => {
|
|
const uv = worldToUV(ID, 100, 50, 25, 10);
|
|
return approx(uv.x, 0.25) && approx(uv.y, 0.2);
|
|
})());
|
|
check('translation: world origin lands on the camera offset', (() => {
|
|
const m = { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 40 };
|
|
const p = worldToScreen(m, 0, 0);
|
|
return p.x === 100 && p.y === 40;
|
|
})());
|
|
check('rotation: world->screen rotates (90° about the origin)', (() => {
|
|
// 90° CCW in screen space (y-down): (1,0) -> (0,1).
|
|
const m = { a: 0, b: 1, c: -1, d: 0, tx: 0, ty: 0 };
|
|
const p = worldToScreen(m, 1, 0);
|
|
return approx(p.x, 0) && approx(p.y, 1);
|
|
})());
|
|
check('zoom: world origin UV scales with the camera zoom', (() => {
|
|
const m = { a: 2, b: 0, c: 0, d: 2, tx: 20, ty: 10 };
|
|
const uv = worldToUV(m, 100, 100, 0, 0);
|
|
return approx(uv.x, 0.2) && approx(uv.y, 0.1);
|
|
})());
|
|
check('phase: monotonic and speed-scaled (rad at t, from a ms clock)', (() => {
|
|
return (
|
|
ripplePhase(0) === 0 &&
|
|
ripplePhase(1000) === 1 &&
|
|
ripplePhase(1000, 2) === 2 &&
|
|
ripplePhase(2000) > ripplePhase(1000)
|
|
);
|
|
})());
|
|
|
|
// --- The data contract (data/systems.json) ---------------------------------
|
|
|
|
const types = config.section('systems.types', {});
|
|
const typeIds = Object.keys(types);
|
|
check('all six archetypes are present', typeIds.length === 6 && ['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void'].every((t) => typeIds.includes(t)));
|
|
check('every type carries an effect block (data-driven by rule)', typeIds.every((t) => types[t].effect && typeof types[t].effect.kind === 'string'));
|
|
check('nebula wears the ripple; the other five render untouched', (() => {
|
|
const kinds = Object.fromEntries(typeIds.map((t) => [t, types[t].effect.kind]));
|
|
return kinds.nebula === 'ripple' && typeIds.filter((t) => t !== 'nebula').every((t) => kinds[t] === 'none');
|
|
})());
|
|
check('ripple parameters are sane numbers', (() => {
|
|
const e = types.nebula.effect;
|
|
const n = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
return (
|
|
n(e.strength) && e.strength > 0 && n(e.amplitude) && e.amplitude > 0 &&
|
|
n(e.speed) && e.speed > 0 && n(e.padding) && e.padding > 0 &&
|
|
(e.center === undefined || e.center === 'screen' || e.center === 'star')
|
|
);
|
|
})());
|
|
check('padding covers the max displacement (no out-of-range UV sampling)', (() => {
|
|
const e = types.nebula.effect;
|
|
const maxDispPx = Math.max(config.get('game.width', 1280), config.get('game.height', 720)) * e.amplitude;
|
|
return e.padding >= maxDispPx;
|
|
})());
|
|
|
|
// --- The demo picker (js/galaxy/FxSystems.js) -------------------------------
|
|
|
|
const fakeGalaxy = (records, contents = {}) => ({
|
|
records,
|
|
contentCache: new Map(Object.entries(contents)),
|
|
});
|
|
const rec = (id, type) => ({ id, type });
|
|
check('picker: richest system of the type wins', (() => {
|
|
const g = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula'), rec('C', 'main')], {
|
|
A: { planets: [1], settlements: [], asteroids: [] },
|
|
B: { planets: [1, 2, 3], settlements: [1], asteroids: [1, 2] },
|
|
C: { planets: [1, 2, 3, 4, 5], settlements: [1, 2], asteroids: [1, 2, 3] },
|
|
});
|
|
return pickFxSystem(g, 'nebula')?.id === 'B';
|
|
})());
|
|
check('picker: more objects beat more gates; ties fall to roster order', (() => {
|
|
const g = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula')], {
|
|
A: { planets: [1], jumps: [1, 2, 3, 4] },
|
|
B: { planets: [1, 2], jumps: [1] },
|
|
});
|
|
const first = pickFxSystem(g, 'nebula')?.id;
|
|
const g2 = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula')], {
|
|
A: { planets: [1] },
|
|
B: { planets: [1] },
|
|
});
|
|
return first === 'B' && pickFxSystem(g2, 'nebula')?.id === 'A';
|
|
})());
|
|
check('picker: no system of the type => null', pickFxSystem(fakeGalaxy([rec('A', 'void')]), 'nebula') === null);
|
|
check('picker: empty roster => null', pickFxSystem(fakeGalaxy([]), 'nebula') === null);
|
|
|
|
// --- The shader contract -----------------------------------------------------
|
|
|
|
const fakeRenderer = {
|
|
renderNodes: {
|
|
_ctors: {},
|
|
hasNode(n) { return Object.prototype.hasOwnProperty.call(this._ctors, n); },
|
|
addNodeConstructor(n, C) { if (this._ctors[n]) throw new Error('node constructor ' + n + ' already exists'); this._ctors[n] = C; },
|
|
},
|
|
};
|
|
const makeCamera = (id) => ({
|
|
id,
|
|
width: 1280,
|
|
height: 720,
|
|
matrixCombined: { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 40 },
|
|
filters: {
|
|
internal: {
|
|
list: [],
|
|
add(f) { this.list.push(f); return f; },
|
|
remove(f) { const i = this.list.indexOf(f); if (i !== -1) this.list.splice(i, 1); return this; },
|
|
getActive() { return this.list.filter((f) => f.active); },
|
|
},
|
|
},
|
|
ignore(targets) {
|
|
(Array.isArray(targets) ? targets : [targets]).forEach((t) => { t.cameraFilter |= this.id; });
|
|
return this;
|
|
},
|
|
setForceComposite(v) { this.forceComposite = v; return this; },
|
|
});
|
|
const fakeCamMain = makeCamera(1);
|
|
const mkObject = (id, scrollFactor) => ({ id, cameraFilter: 0, scrollFactorX: scrollFactor, scrollFactorY: scrollFactor });
|
|
const uiRoot = mkObject('ui-root', 0);
|
|
const worldRoot = mkObject('world-root', 1);
|
|
const fakeCameras = {
|
|
main: fakeCamMain,
|
|
cameras: [fakeCamMain],
|
|
add(_x, _y, _w, _h, _isMain, name) {
|
|
const c = makeCamera(2);
|
|
c.name = name;
|
|
this.cameras.push(c);
|
|
return c;
|
|
},
|
|
};
|
|
const fakeScene = {
|
|
scale: { width: 1280, height: 720 },
|
|
cameras: fakeCameras,
|
|
sys: { displayList: { getChildren: () => [uiRoot, worldRoot, mkObject('world-2', 1)] } },
|
|
renderer: { gl: {}, renderNodes: fakeRenderer.renderNodes },
|
|
};
|
|
|
|
// The real split:
|
|
const split = ensureUiCameras(fakeScene);
|
|
check('split: exactly two passes, main first (world under UI), UI pass force-composited', split && split.main === fakeCamMain && fakeCameras.cameras.length === 2 && fakeCameras.cameras[1] === split.ui && split.ui.forceComposite === true);
|
|
check('split: screen-pinned roots are ignored by the world pass', (uiRoot.cameraFilter & fakeCamMain.id) !== 0);
|
|
check('split: world roots are ignored by the UI pass', (worldRoot.cameraFilter & split.ui.id) !== 0);
|
|
check('split: idempotent (same split object, no third camera)', ensureUiCameras(fakeScene) === split && fakeCameras.cameras.length === 2);
|
|
check('isScreenPinned: scrollFactor-0 on either axis is UI', isScreenPinned({ scrollFactorX: 0, scrollFactorY: 1 }) && !isScreenPinned({ scrollFactorX: 1, scrollFactorY: 1 }));
|
|
|
|
const lateUi = mkObject('late-ui', 0);
|
|
assignUi(fakeScene, lateUi);
|
|
check('assignUi: a late UI object joins the UI pass only', (lateUi.cameraFilter & fakeCamMain.id) !== 0 && (lateUi.cameraFilter & split.ui.id) === 0);
|
|
const lateWorld = mkObject('late-world', 1);
|
|
assignWorld(fakeScene, lateWorld);
|
|
check('assignWorld: a late world object stays on the world pass only', (lateWorld.cameraFilter & split.ui.id) !== 0 && (lateWorld.cameraFilter & fakeCamMain.id) === 0);
|
|
const noSplitScene = { };
|
|
assignUi(noSplitScene, lateUi); // must not throw
|
|
check('assign*: no-op while the split does not exist (single-camera pipeline intact)', true);
|
|
|
|
// The facade:
|
|
const fx = new SE.SystemEffects(fakeScene);
|
|
check('facade: "none" (main) attaches nothing — no filter, no new camera', (() => {
|
|
const camsBefore = fakeCameras.cameras.length;
|
|
const active = fx.apply('main');
|
|
return active === false && fx.active === false && fakeCamMain.filters.internal.list.length === 0 && fakeCameras.cameras.length === camsBefore;
|
|
})());
|
|
check('facade: ripple requires WebGL (canvas degrades to none)', (() => {
|
|
const canvasScene = { ...fakeScene, renderer: { gl: null, renderNodes: fakeRenderer.renderNodes } };
|
|
const f = new SE.SystemEffects(canvasScene);
|
|
return f.apply('nebula') === false && f.active === false;
|
|
})());
|
|
check('facade: apply(nebula) registers the node once + splits + attaches', (() => {
|
|
fakeCamMain.filters.internal.list.length = 0;
|
|
const active = fx.apply('nebula');
|
|
const list = fakeCamMain.filters.internal.list;
|
|
return (
|
|
active === true &&
|
|
fx.active === true &&
|
|
fx.kind === 'ripple' &&
|
|
fakeRenderer.renderNodes.hasNode(SE.RIPPLE_NODE) &&
|
|
list.length === 1 &&
|
|
list[0].renderNode === SE.RIPPLE_NODE &&
|
|
list[0].camera === fakeCamMain
|
|
);
|
|
})());
|
|
check('facade: config parameters land on the controller', (() => {
|
|
const c = fakeCamMain.filters.internal.list[0];
|
|
const e = types.nebula.effect;
|
|
return (
|
|
c.strength === e.strength &&
|
|
c.amplitude === e.amplitude &&
|
|
c.speed === e.speed &&
|
|
c.paddingOverride &&
|
|
c.paddingOverride.x === -Math.max(4, Math.ceil(e.padding)) &&
|
|
c.paddingOverride.width === 2 * Math.max(4, Math.ceil(e.padding))
|
|
);
|
|
})());
|
|
check('facade: apply() twice replaces (no duplicate filters)', (() => {
|
|
fx.apply('nebula');
|
|
return fakeCamMain.filters.internal.list.length === 1;
|
|
})());
|
|
check('facade: update() advances the phase and keeps the screen anchor pinned', (() => {
|
|
const c = fakeCamMain.filters.internal.list[0];
|
|
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 }; // camera moved
|
|
fx.update(1500);
|
|
return approx(c.centerX, 0.5) && approx(c.centerY, 0.5) && approx(c.time, 1.5 * (Number(types.nebula.effect.speed) || 1));
|
|
})());
|
|
check('facade: the "star" anchor tracks world 0,0 in screen UV', (() => {
|
|
const c = fakeCamMain.filters.internal.list[0];
|
|
c.center = 'star'; // exercise the tracking branch
|
|
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 };
|
|
fx.update(1500);
|
|
return approx(c.centerX, 320 / 1280) && approx(c.centerY, 180 / 720);
|
|
})());
|
|
check('facade: release() detaches cleanly (and twice)', (() => {
|
|
fx.release();
|
|
const first = fakeCamMain.filters.internal.list.length === 0 && fx.active === false;
|
|
fx.release();
|
|
return first;
|
|
})());
|
|
|
|
// --- The shader source contract (through a live node instance) ---------------
|
|
|
|
const NodeClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE];
|
|
check('node: the registered constructor exists and names itself', typeof NodeClass === 'function' && SE.RIPPLE_NODE === 'FilterRippleEffect');
|
|
const liveNode = new NodeClass({ renderer: {} });
|
|
const fragSrc = liveNode.fragmentSource;
|
|
check('shader: declares every uniform setupUniforms pushes', (() => {
|
|
const ctrl = { time: 1, strength: 90, amplitude: 0.01, centerX: 0.5, centerY: 0.5 };
|
|
liveNode.setupUniforms(ctrl, {});
|
|
const pushed = Object.keys(liveNode.uniforms);
|
|
const declared = ['time', 'strength', 'amplitude', 'centerX', 'centerY'].every((u) =>
|
|
new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(fragSrc));
|
|
return pushed.length === 5 && declared;
|
|
})());
|
|
check('shader: keeps the build\'s filter conventions (uMainSampler, outTexCoord, boundedSampler)', (() => {
|
|
return (
|
|
fragSrc.includes('uniform sampler2D uMainSampler;') &&
|
|
fragSrc.includes('varying vec2 outTexCoord;') &&
|
|
fragSrc.includes('boundedSampler(uMainSampler') &&
|
|
fragSrc.includes('#pragma phaserTemplate(shaderName)') &&
|
|
fragSrc.includes('#pragma phaserTemplate(fragmentHeader)')
|
|
);
|
|
})());
|
|
check('shader: displacement is radial from the center, clean at the center, calm at the corners', (() => {
|
|
return (
|
|
fragSrc.includes('length(delta)') &&
|
|
fragSrc.includes('smoothstep(0.0, 0.02, dist)') &&
|
|
fragSrc.includes('0.8 + 0.2 * exp(-dist * 0.25)') &&
|
|
fragSrc.includes('wave * amplitude * fade')
|
|
);
|
|
})());
|
|
|
|
console.log(`\n✓ system effects: ${pass} checks passed`);
|