orbit/dev/system-effects.test.mjs

578 lines
27 KiB
JavaScript
Raw 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.

/**
* System effects test (dev tool, run with Node — no browser needed):
*
* node dev/system-effects.test.mjs
*
* Asserts:
* - the RIPPLE / GRADE / FLARE MATH (js/visuals/SystemEffectsMath.js):
* world->screen->UV under identity / translation / rotation / zoom;
* the phase clock is monotonic and speed-scaled; the flare envelope is
* a smooth periodic 0→1→0 bell; the binary orbit centers are
* diametrically opposed on a seeded circle; the grade color chain
* (brightness → saturation → tint → split → flash) is exact and
* identity when the grade is empty;
* - the DATA CONTRACT (data/systems.json): every type carries an
* `effect` block; nebula/redDwarf/binary are live (kind "ripple");
* the redDwarf bundle has a flare + grade + particles; the binary
* bundle has two centers + orbit + a split grade + a wanderer; padding
* covers the worst-case displacement (amplitude × flare boost ×
* centers × width);
* - 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, against the Phaser
* stub): every uniform each filter's setupUniforms pushes is declared
* in the fragment source, and both fragments keep the build's
* filter-shader conventions (uMainSampler / outTexCoord /
* boundedSampler);
* - the FACADE (SystemEffects.apply/update/release) on a fake scene:
* "none" touches nothing; the filters require WebGL (canvas degrades
* to none); nebula → 1 filter (ripple); redDwarf → 2 (ripple + grade,
* with a flare); binary → 2 (ripple with two orbiting centers + split
* grade); the star anchor tracks world 0,0; release detaches 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 M = await import(pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href);
const { worldToScreen, worldToUV, ripplePhase } = M;
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-6) => 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)', (() => {
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 flare / orbit / grade math ---------------------------------------
check('flare: quiet outside the window, a smooth 0→1→0 bell inside', (() => {
const f = (ms) => M.flareIntensity(ms, 18, 1.6, 0);
const quiet = f(0) === 0 && f(5000) === 0 && f(10000) === 0;
const peak = approx(f(800), 1, 1e-3); // mid of the 1.6 s flare
const edges = f(0) === 0 && f(1600) === 0;
const mid = f(500) > 0.5;
return quiet && peak && edges && mid;
})());
check('flare: per-system phase shifts the rhythm (deterministic)', (() => {
const a = M.flareIntensity(1000, 18, 1.6, 0);
const b = M.flareIntensity(1000, 18, 1.6, 9);
return Number.isFinite(a) && Number.isFinite(b) && a !== b;
})());
check('orbit: two centers, diametrically opposed on a seeded circle', (() => {
const o = M.orbitCenters(0, 36, 0.16, 0);
const mid0 = (o.cx0 + o.cx1) / 2;
const midY = (o.cy0 + o.cy1) / 2;
const dist = Math.hypot(o.cx0 - 0.5, o.cy0 - 0.5);
return (
approx(mid0, 0.5) && approx(midY, 0.5) && // both orbit the view center
approx(o.cx0 + o.cx1, 1) && approx(o.cy0 + o.cy1, 1) && // opposite
approx(dist, 0.16) // on the radius
);
})());
check('orbit: advancing time moves the pair (a full lap per period)', (() => {
const t0 = M.orbitCenters(0, 36, 0.16, 0);
const t9 = M.orbitCenters(9000, 36, 0.16, 0); // quarter lap
const t36 = M.orbitCenters(36000, 36, 0.16, 0); // full lap → back
return (
(t0.cx0 !== t9.cx0 || t0.cy0 !== t9.cy0) &&
(approx(t36.cx0, t0.cx0, 1e-6) && approx(t36.cy0, t0.cy0, 1e-6))
);
})());
check('hexToRgb01: parses #rrggbb and bad input falls to white', (() => {
const w = M.hexToRgb01('#ffffff');
const b = M.hexToRgb01('#0000ff');
const bad = M.hexToRgb01('nope');
return (
approx(w[0], 1) && approx(w[1], 1) && approx(w[2], 1) &&
approx(b[0], 0) && approx(b[1], 0) && approx(b[2], 1) &&
bad[0] === 1 && bad[1] === 1 && bad[2] === 1
);
})());
check('tintMultiplier: a hue shift normalized to mean 1 (white → [1,1,1])', (() => {
const w = M.tintMultiplier('#ffffff');
const orange = M.tintMultiplier('#ff9a5c');
return (
w.every((v) => approx(v, 1)) &&
approx((orange[0] + orange[1] + orange[2]) / 3, 1, 1e-6) && // normalized
orange[0] > orange[2] // warm: red channel above blue
);
})());
check('grade: empty grade is identity', (() => {
const out = M.gradeColor([0.5, 0.5, 0.5], {});
return out.every((v, i) => approx(v, 0.5));
})());
check('grade: brightness scales, saturation pulls to luma, tint warms', (() => {
const bright = M.gradeColor([0.5, 0.5, 0.5], { bright: 2 });
const sat = M.gradeColor([1, 0, 0], { sat: 0.5 }); // red → toward gray
const tint = M.gradeColor([0.5, 0.5, 0.5], { tint: M.tintMultiplier('#ff9a5c'), tintAmt: 0.3 });
return (
approx(bright[0], 1) &&
(sat[0] < 1 && sat[0] > 0.5) && // red desaturated toward gray
(tint[0] > tint[2]) // warmed: red above blue
);
})());
check('grade: the split is a two-color directional wash (opposite sides differ)', (() => {
const sp = { split: { a: M.hexToRgb01('#f2b05c'), b: M.hexToRgb01('#5b9bf2'), axis: [1, 0], mix: 0.15 } };
const left = M.gradeColor([0.5, 0.5, 0.5], { ...sp, pos: [0.1, 0.5] });
const right = M.gradeColor([0.5, 0.5, 0.5], { ...sp, pos: [0.9, 0.5] });
return (
(left[2] > left[0]) && // left leans cool (b = blue)
(right[0] > right[2]) && // right leans warm (a = amber)
(left[2] - left[0]) > (right[2] - right[0]) // the sides actually differ
);
})());
check('grade: the flare flash is an additive warm spike', (() => {
const base = M.gradeColor([0.5, 0.5, 0.5], {});
const flash = M.gradeColor([0.5, 0.5, 0.5], { flash: 0.35, flashColor: M.hexToRgb01('#ffd9a8') });
return flash.every((v) => v > base[0]) && flash[0] > flash[2];
})());
check('shadowLift: lifts the darkness toward red, leaves bright pixels alone', (() => {
const lift = M.hexToRgb01('#c0392b');
const black = M.shadowLift([0, 0, 0], lift, 0.2);
const white = M.shadowLift([0.9, 0.9, 0.9], lift, 0.2);
const none = M.shadowLift([0, 0, 0], lift, 0);
return (
black[0] > 0 && black[0] > black[2] && // black gains a red cast
black[0] < lift[0] && // but stays subtle (a haze, not a flood)
white.every((v) => v < 0.92) && // bright pixels barely move
none.every((v) => v === 0) // amount 0 = identity
);
})());
check('grade: the shadow lift integrates into gradeColor (red void)', (() => {
const dark = M.gradeColor([0.02, 0.02, 0.02], { liftColor: M.hexToRgb01('#c0392b'), liftAmt: 0.2 });
const noLift = M.gradeColor([0.02, 0.02, 0.02], {});
return dark[0] > noLift[0] && dark[0] > dark[2]; // the dark space turns red
})());
// --- The data contract (data/systems.json) ---------------------------------
const types = config.section('systems.types', {});
const typeIds = Object.keys(types);
const LIVE = ['nebula', 'redDwarf', 'binary'];
const num_ = (v) => typeof v === 'number' && Number.isFinite(v);
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 === 'object'));
check('the three live types wear sub-effect blocks; the rest render untouched', (() => {
const live = LIVE.every((t) => Object.keys(types[t].effect).length > 0);
const still = ['main', 'habitable', 'void'].every((t) => Object.keys(types[t].effect || {}).length === 0);
return live && still;
})());
check('nebula: a steady ripple, nothing else', (() => {
const e = types.nebula.effect;
return !!e.ripple && num_(e.ripple.amplitude) && e.ripple.amplitude > 0 &&
e.flare == null && e.grade == null && e.particles == null && e.wanderer == null;
})());
check('redDwarf: a SUPER-subtle ripple that SURGES on a flare', (() => {
const e = types.redDwarf.effect;
const baseAmp = e.ripple?.amplitude;
const boost = e.flare?.rippleBoost;
return (
!!e.ripple && num_(baseAmp) && baseAmp > 0 && baseAmp <= 0.0015 && // super-subtle baseline
num_(boost) && boost >= 4 && baseAmp * (1 + boost) > 0.008 // the surge is clearly visible
);
})());
check('binary: a two-color split grade + a wandering star, and NO ripple', (() => {
const e = types.binary.effect;
const sp = e.grade?.split;
const w = e.wanderer;
return (
e.ripple == null &&
!!sp && typeof sp.a === 'string' && typeof sp.b === 'string' && num_(sp.mix) && sp.mix > 0 &&
!!w && typeof w.color === 'string' && num_(w.parallax) && w.parallax > 0 &&
Array.isArray(w.coreSize) && Array.isArray(w.haloSize)
);
})());
check('every ripple bundle has sane numbers + padding covering the worst case', (() => {
const maxDim = Math.max(1280, 720);
return ['nebula', 'redDwarf'].every((t) => {
const e = types[t].effect;
const r = e.ripple;
const boost = e.flare ? 1 + (e.flare.rippleBoost || 0) : 1;
const centers = num_(r.centers) ? r.centers : 1;
const need = r.amplitude * boost * centers * maxDim;
return (
num_(r.strength) && r.strength > 0 && num_(r.amplitude) && r.amplitude > 0 &&
num_(r.speed) && r.speed > 0 && num_(r.padding) && r.padding >= need &&
(r.center === undefined || r.center === 'screen' || r.center === 'star')
);
});
})());
check('redDwarf bundle: a flare (drives ripple + grade flash), a warm grade + red void, a red haze, ember particles', (() => {
const e = types.redDwarf.effect;
const f = e.flare ?? {};
const g = e.grade ?? {};
const h = e.haze ?? {};
const p = e.particles ?? {};
const n = (v) => typeof v === 'number' && Number.isFinite(v);
return (
n(f.interval) && f.interval > 0 && n(f.duration) && f.duration > 0 &&
n(f.rippleBoost) && f.rippleBoost > 0 && n(f.flash) && f.flash > 0 &&
typeof f.flashColor === 'string' &&
typeof g.tint === 'string' && n(g.amount) && g.amount > 0 && n(g.saturation) && n(g.brightness) &&
n(g.lift?.amount) && g.lift.amount > 0 && typeof g.lift.color === 'string' &&
n(g.grain?.amount) && g.grain.amount > 0 && n(g.grain.parallax) && g.grain.parallax > 0 &&
typeof h.base === 'string' && typeof h.tint === 'string' && n(h.alpha) && h.alpha > 0 && n(h.grain) && h.grain > 0 &&
n(p.count) && p.count > 0 && typeof p.color === 'string' &&
p.blend === 'add'
);
})());
check('binary bundle: a two-color split grade + a companion star (no ripple)', (() => {
const e = types.binary.effect;
const sp = e.grade?.split ?? {};
const w = e.wanderer ?? {};
return (
e.ripple == null && // the binary wears NO ripple
typeof sp.a === 'string' && typeof sp.b === 'string' && num_(sp.mix) && sp.mix > 0 &&
typeof w.color === 'string' && num_(w.parallax) && w.parallax > 0 &&
Array.isArray(w.coreSize) && Array.isArray(w.haloSize)
);
})());
// --- 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 camera split (js/visuals/UiCameras.js) + facade (fake scene) ------
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,
scrollX: 0,
scrollY: 0,
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 },
systemContent: { fx: { phase: 0.5, angle: 0.3, drift: 0.7 } }, // per-system variation
};
const split = ensureUiCameras(fakeScene);
check('split: exactly two passes, main first, 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);
assignUi({}, 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: the filters require WebGL (canvas degrades to none)', (() => {
const canvasScene = { ...fakeScene, renderer: { gl: null, renderNodes: fakeRenderer.renderNodes } };
const f = new SE.SystemEffects(canvasScene);
return f.apply('redDwarf') === false && f.active === false;
})());
check('facade: apply(nebula) → one filter (ripple only)', (() => {
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' &&
list.length === 1 && list[0].renderNode === SE.RIPPLE_NODE && list[0].camera === fakeCamMain &&
list[0].centers === 1 && list[0].anchor === 'screen'
);
})());
check('facade: nebula ripple reads the config (strength/amp/speed + padded)', (() => {
const c = fakeCamMain.filters.internal.list[0];
const r = types.nebula.effect.ripple;
return (
c.strength === r.strength && c.baseAmp === r.amplitude && c.speed === r.speed &&
c.paddingOverride && c.paddingOverride.x < 0 && c.paddingOverride.width > 0
);
})());
check('facade: apply(redDwarf) → ripple (star anchor, flare) + grade (warm tint + flash)', (() => {
fakeCamMain.filters.internal.list.length = 0;
const active = fx.apply('redDwarf');
const list = fakeCamMain.filters.internal.list;
const ripple = list.find((c) => c.renderNode === SE.RIPPLE_NODE);
const grade = list.find((c) => c.renderNode === SE.GRADE_NODE);
return (
active === true && list.length === 2 &&
ripple && ripple.centers === 1 && ripple.anchor === 'star' && ripple.flare?.rippleBoost > 0 &&
grade && grade.uTintAmt > 0 && grade.uSat < 1 && grade.uBright < 1 &&
grade.uLiftAmt > 0 && grade.uLiftR > grade.uLiftB && grade.uGrainAmt > 0 &&
grade.grainParallax > 0 &&
grade.uFlashR > 0 && grade.flareFlash > 0
);
})());
check('facade: update() parallaxes the grain by the camera scroll', (() => {
fakeCamMain.filters.internal.list.length = 0;
fx.apply('redDwarf');
const grade = fakeCamMain.filters.internal.list.find((c) => c.renderNode === SE.GRADE_NODE);
const p = grade.grainParallax;
fakeCamMain.scrollX = 500; fakeCamMain.scrollY = -250;
fx.update(1000);
const offX = grade.uGrainOffX;
const offY = grade.uGrainOffY;
// offset = scroll * parallax, and it changes as the camera moves
fakeCamMain.scrollX = 700;
fx.update(1100);
return (
p > 0 &&
offX === 500 * p && offY === -250 * p &&
grade.uGrainOffX === 700 * p
);
})());
check('facade: apply(binary) → a split grade ONLY (no ripple)', (() => {
fakeCamMain.filters.internal.list.length = 0;
const active = fx.apply('binary');
const list = fakeCamMain.filters.internal.list;
const ripple = list.find((c) => c.renderNode === SE.RIPPLE_NODE);
const grade = list.find((c) => c.renderNode === SE.GRADE_NODE);
return (
active === true && fx.kind === 'grade' && list.length === 1 &&
!ripple && // no ripple on the binary
grade && grade.uSplitMix > 0 && (grade.uSplitAR !== grade.uSplitBR || grade.uSplitAG !== grade.uSplitBG)
);
})());
check('facade: apply() twice replaces (no duplicate filters)', (() => {
fx.apply('redDwarf');
return fakeCamMain.filters.internal.list.length === 2;
})());
check('facade: update() advances the phase and keeps the screen anchor pinned', (() => {
fx.apply('nebula');
const c = fakeCamMain.filters.internal.list.find((x) => x.renderNode === SE.RIPPLE_NODE);
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 }; // camera moved
fx.update(1500);
return approx(c.cx0, 0.5) && approx(c.cy0, 0.5) && approx(c.time, 1.5 * (Number(types.nebula.effect.ripple.speed) || 1));
})());
check('facade: the "star" anchor tracks world 0,0 in screen UV', (() => {
fx.apply('redDwarf');
const c = fakeCamMain.filters.internal.list.find((x) => x.renderNode === SE.RIPPLE_NODE);
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 };
fx.update(1500);
return approx(c.cx0, 320 / 1280) && approx(c.cy0, 180 / 720);
})());
check('facade: the flare drives BOTH the ripple amplitude AND the grade flash', (() => {
fx.apply('redDwarf');
const list = fakeCamMain.filters.internal.list;
const ripple = list.find((x) => x.renderNode === SE.RIPPLE_NODE);
const grade = list.find((x) => x.renderNode === SE.GRADE_NODE);
// quiet moment → baseline amplitude, no flash
const quietAmp = M.flareIntensity(0, 18, 1.6, 0.5 * 18) === 0;
fx.update(0);
const baseAmp = ripple.amp0;
const baseFlash = grade.uFlash;
// find a flare peak for this phase (scan the first interval)
let peakMs = 0;
for (let ms = 0; ms < 18000; ms += 10) {
if (M.flareIntensity(ms, 18, 1.6, 0.5 * 18) > 0.999) { peakMs = ms; break; }
}
fx.update(peakMs);
return (
quietAmp && baseAmp > 0 && baseFlash === 0 &&
ripple.amp0 > baseAmp && // the ripple surges during the flare
grade.uFlash > 0 && // and the grade flashes
ripple.amp0 < ripple.baseAmp * (1 + types.redDwarf.effect.flare.rippleBoost) + 1e-9
);
})());
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 contract (through live node instances) ----------------------
const RippleClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE];
const GradeClass = fakeRenderer.renderNodes._ctors[SE.GRADE_NODE];
check('nodes: both registered constructors exist and name themselves', typeof RippleClass === 'function' && typeof GradeClass === 'function' && SE.RIPPLE_NODE === 'FilterRippleEffect' && SE.GRADE_NODE === 'FilterGradeEffect');
const rippleNode = new RippleClass({ renderer: {} });
const rippleSrc = rippleNode.fragmentSource;
check('ripple shader: declares every uniform setupUniforms pushes', (() => {
rippleNode.setupUniforms({ time: 1, strength: 90, amp0: 0.01, amp1: 0, cx0: 0.5, cy0: 0.5, cx1: 0.5, cy1: 0.5 }, {});
const pushed = Object.keys(rippleNode.uniforms);
const declared = ['time', 'strength', 'amp0', 'amp1', 'cx0', 'cy0', 'cx1', 'cy1'].every((u) =>
new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(rippleSrc));
return pushed.length === 8 && declared;
})());
check('ripple shader: keeps the build\'s filter conventions', (() => {
return (
rippleSrc.includes('uniform sampler2D uMainSampler;') &&
rippleSrc.includes('varying vec2 outTexCoord;') &&
rippleSrc.includes('boundedSampler(uMainSampler') &&
rippleSrc.includes('#pragma phaserTemplate(shaderName)') &&
rippleSrc.includes('#pragma phaserTemplate(fragmentHeader)')
);
})());
check('ripple shader: radial displacement from each center, clean at the center, calm at the corners', (() => {
return (
rippleSrc.includes('vec2 d0 = outTexCoord - vec2(cx0, cy0);') &&
rippleSrc.includes('smoothstep(0.0, 0.02, r0)') &&
rippleSrc.includes('0.8 + 0.2 * exp(-r0 * 0.25)') &&
rippleSrc.includes('w0 * amp0 * f0') &&
rippleSrc.includes('vec2 d1 = outTexCoord - vec2(cx1, cy1);')
);
})());
const gradeNode = new GradeClass({ renderer: {} });
const gradeSrc = gradeNode.fragmentSource;
check('grade shader: declares every uniform setupUniforms pushes', (() => {
gradeNode.setupUniforms({
uBright: 1, uSat: 1, uTintAmt: 0.3, uTintR: 1, uTintG: 1, uTintB: 1,
uSplitMix: 0.15, uSplitAR: 1, uSplitAG: 1, uSplitAB: 1,
uSplitBR: 1, uSplitBG: 1, uSplitBB: 1, uAxisX: 1, uAxisY: 0,
uFlash: 0.3, uFlashR: 1, uFlashG: 1, uFlashB: 1,
uLiftAmt: 0.2, uLiftR: 0.75, uLiftG: 0.22, uLiftB: 0.17,
uGrainAmt: 0.16, uGrainOffX: 12.3, uGrainOffY: -7.5,
}, {});
const pushed = Object.keys(gradeNode.uniforms);
const names = [
'uBright', 'uSat', 'uTintAmt', 'uTintR', 'uTintG', 'uTintB',
'uSplitMix', 'uSplitAR', 'uSplitAG', 'uSplitAB', 'uSplitBR', 'uSplitBG', 'uSplitBB',
'uAxisX', 'uAxisY', 'uFlash', 'uFlashR', 'uFlashG', 'uFlashB',
'uLiftAmt', 'uLiftR', 'uLiftG', 'uLiftB', 'uGrainAmt', 'uGrainOffX', 'uGrainOffY',
];
const declared = names.every((u) => new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(gradeSrc));
return pushed.length === 26 && declared;
})());
check('grade shader: keeps the build\'s filter conventions', (() => {
return (
gradeSrc.includes('uniform sampler2D uMainSampler;') &&
gradeSrc.includes('varying vec2 outTexCoord;') &&
gradeSrc.includes('boundedSampler(uMainSampler') &&
gradeSrc.includes('#pragma phaserTemplate(shaderName)') &&
gradeSrc.includes('#pragma phaserTemplate(fragmentHeader)')
);
})());
check('grade shader: brightness → saturation → tint → lift → split → flash, in order', (() => {
const order = (a, b) => gradeSrc.indexOf(a) !== -1 && gradeSrc.indexOf(b) !== -1 && gradeSrc.indexOf(a) < gradeSrc.indexOf(b);
return (
order('rgb *= uBright;', 'if (uSat != 1.0)') &&
order('if (uSat != 1.0)', 'if (uTintAmt > 0.0)') &&
order('if (uTintAmt > 0.0)', 'if (uLiftAmt > 0.0)') &&
order('if (uLiftAmt > 0.0)', 'if (uSplitMix > 0.0)') &&
order('if (uSplitMix > 0.0)', 'if (uFlash > 0.0)')
);
})());
console.log(`\n✓ system effects: ${pass} checks passed`);