578 lines
27 KiB
JavaScript
578 lines
27 KiB
JavaScript
/**
|
||
* 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`);
|