Expand system effects to a composable bundle of sub-effects

- Replace the single `kind: ripple|none` effect with optional per-type blocks (ripple, grade, flare, haze, particles, wanderer) so each archetype wears exactly what it lists
- Add redDwarf visual identity: super-subtle star-anchored ripple that surges on a seeded flare, warm color grade with shadow lift + grain, a gritty red haze behind the stars, and drifting ember dust
- Add binary visual identity: two-color directional split grade plus a wandering companion star (no ripple)
- Generalize the ripple shader/filter to up to two orbiting centers and drive both the ripple amplitude and the grade flash from one shared flare signal
- Stamp per-system fx variation (phase/angle/drift) in SystemGenerator so each system's flare rhythm, split axis, and wanderer path are stable but distinct
- Add EmberField, RedHaze, WandererStar content layers wired into GameScene create/update/shutdown
- Update the data contract, 57-check test suite, project notes, and landing/systems JSON; add ice landing/takeoff video assets
This commit is contained in:
Brian Fertig 2026-09-08 20:03:41 -06:00
parent 125b1657ed
commit 627c7fd255
15 changed files with 1652 additions and 302 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -62,6 +62,9 @@
{ "land": "gasgiant-land-03.mp4", "surface": "gasgiant-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" },
{ "land": "rocky-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "rocky-takeoff-01.mp4", "shop": "rocky-shop-01.mp4" },
{ "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
{ "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" },
{ "land": "ice-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "rocky-shop-01.mp4" },
{ "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
{ "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" }
],

View File

@ -1,6 +1,6 @@
{
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 12 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the home→far density gradient, at most two — deepSpaceStation + waypoint), then planets = N stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is GLOBALLY; which types favor WHICH REGION of the galaxy (the home corner, the middle, the far/deep corner) is the per-zone mix in data/galaxy.json → distribution.zoneMix, keyed off each system's home→far diagonal zone (record.zone).",
"_effect": "Per-type VISUAL EFFECT (js/visuals/SystemEffects.js): the star's character, rendered as a full-screen composite over the WORLD camera (the starfield shimmers with it; the HUD draws on a separate unfiltered UI camera). kind: none | ripple (today). ripple parameters — center: screen (rings radiate from the middle of the view — always visible) | star (tracked to world 0,0 — wavefronts arrive from the star's, usually off-screen, direction); strength: wave cycles across the screen; amplitude: displacement in UV (fraction of the screen); speed: phase advance in radians/second; padding: extra framebuffer pixels the filter may sample (keep >= amplitude*width). Data-driven by design: tuning an effect is a JSON edit, not a code change. The star itself is never rendered (invisible flavor — its character is the shimmer).",
"_effect": "Per-type VISUAL EFFECT (js/visuals/SystemEffects.js): the star's character. Each sub-effect is an OPTIONAL block — a type wears exactly what it lists (an empty {} = no effect, renders untouched). CAMERA FILTERS (WebGL; the HUD is excluded by the UI-camera split) — 'ripple' { center: screen|star, strength (wave cycles), amplitude (displacement, fraction of screen), speed (rad/s), padding (framebuffer slack, keep >= amplitude*width*(1+flare.rippleBoost)*centers), centers: 1|2, orbit {radius, period} (two centers circling each other) }; 'grade' { tint (hex hue), amount (0..1), saturation (1=none), brightness, or split {a, b hex, mix 0..1, axis deg} (a two-color directional wash) }. 'flare' { interval, duration (seconds), rippleBoost x, flash 0..1, flashColor } is a SHARED seeded rhythm that surges the ripple's amplitude AND flashes the grade warm — the star flaring (a ripple with no flare just shimmers at its base amplitude). CONTENT (world-space sprites, Canvas-safe, depth 3, behind the art) — 'particles' (ember dust) and 'wanderer' (companion star). LIVE TODAY: nebula = a steady ripple; redDwarf = a SUPER-subtle ripple that SURGES on each flare + a warm grade + ember dust; binary = a two-color split grade + a wandering companion star (NO ripple — two stars, one light). Per-system variation (flare phase, split axis, wanderer path) is seeded from the system id in SystemGenerator (content.fx), so each system is stable but distinct. Tuning is a JSON edit.",
"objectCount": {
"barren": 0.10,
"objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 }
@ -11,7 +11,7 @@
"description": "An ordinary star and its worlds — the galaxy's working majority.",
"theme": { "color": "#9fb4e8" },
"distribution": { "weight": 34 },
"effect": { "kind": "none" },
"effect": {},
"attributes": {
"star": {
"classes": { "G": 30, "K": 40, "M": 30 },
@ -34,7 +34,13 @@
"description": "A small, long-lived M star with close-in, moon-rich worlds.",
"theme": { "color": "#e8927c" },
"distribution": { "weight": 26 },
"effect": { "kind": "none" },
"effect": {
"ripple": { "center": "star", "strength": 18, "amplitude": 0.001, "speed": 0.8, "padding": 18, "centers": 1 },
"flare": { "interval": 18, "duration": 2.6, "rippleBoost": 12, "flash": 0.35, "flashColor": "#ffd9a8" },
"grade": { "tint": "#ff9a5c", "amount": 0.30, "saturation": 0.90, "brightness": 0.96, "lift": { "amount": 0.20, "color": "#c0392b" }, "grain": { "amount": 0.16, "parallax": 0.08 } },
"haze": { "base": "#160b09", "tint": "#c0392b", "alpha": 0.9, "grain": 12, "blobs": 9, "scale": [0.18, 0.42] },
"particles": { "count": 30, "size": [2, 5], "alpha": [0.4, 0.85], "parallax": [0.25, 0.5], "speed": [8, 25], "color": "#ff8a4a", "blend": "add" }
},
"attributes": {
"star": {
"classes": { "M": 85, "K": 15 },
@ -57,7 +63,10 @@
"description": "Two stars, one system. Tangled orbits, wide spacings, rich debris.",
"theme": { "color": "#c9a7ff" },
"distribution": { "weight": 10 },
"effect": { "kind": "none" },
"effect": {
"grade": { "split": { "a": "#f2b05c", "b": "#5b9bf2", "mix": 0.15, "axis": 28 } },
"wanderer": { "color": "#9fd0ff", "coreSize": [40, 70], "haloSize": [120, 200], "parallax": 0.08, "speed": [3, 8] }
},
"attributes": {
"star": {
"classes": { "F": 25, "G": 40, "K": 35 },
@ -80,7 +89,7 @@
"description": "Temperate, well-lit, and quietly crowded with life. Rare.",
"theme": { "color": "#7ce8a4" },
"distribution": { "weight": 10 },
"effect": { "kind": "none" },
"effect": {},
"attributes": {
"star": {
"classes": { "G": 70, "K": 30 },
@ -104,12 +113,7 @@
"theme": { "color": "#5fd4d0" },
"distribution": { "weight": 12 },
"effect": {
"kind": "ripple",
"center": "screen",
"strength": 20,
"amplitude": 0.0042,
"speed": 1.0,
"padding": 20
"ripple": { "center": "screen", "strength": 20, "amplitude": 0.0042, "speed": 1.0, "padding": 20 }
},
"attributes": {
"star": {
@ -133,7 +137,7 @@
"description": "Old, cold, and mostly empty. The rim's quiet dead ends.",
"theme": { "color": "#7d88a8" },
"distribution": { "weight": 8 },
"effect": { "kind": "none" },
"effect": {},
"attributes": {
"star": {
"classes": { "M": 90, "K": 10 },

View File

@ -4,26 +4,31 @@
* 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 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 010 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, 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 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 (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.
* "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)
@ -45,9 +50,8 @@ for (const f of fs.readdirSync(dataDir)) {
}
config.init(configData);
const { worldToScreen, worldToUV, ripplePhase } = await import(
pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href
);
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
);
@ -65,7 +69,7 @@ function check(name, cond) {
pass++;
console.log(`${name}`);
}
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
// --- The ripple math -------------------------------------------------------
@ -81,7 +85,6 @@ check('translation: world origin lands on the camera offset', (() => {
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);
@ -92,45 +95,197 @@ check('zoom: world origin UV scales with the camera zoom', (() => {
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 (
ripplePhase(0) === 0 &&
ripplePhase(1000) === 1 &&
ripplePhase(1000, 2) === 2 &&
ripplePhase(2000) > ripplePhase(1000)
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.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('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('ripple parameters are sane numbers', (() => {
check('nebula: a steady ripple, nothing else', (() => {
const e = types.nebula.effect;
const n = (v) => typeof v === 'number' && Number.isFinite(v);
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 (
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')
!!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('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;
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 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')], {
@ -146,16 +301,13 @@ check('picker: more objects beat more gates; ties fall to roster order', (() =>
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] },
});
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 -----------------------------------------------------
// --- The camera split (js/visuals/UiCameras.js) + facade (fake scene) ------
const fakeRenderer = {
renderNodes: {
@ -168,6 +320,8 @@ 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: {
@ -202,11 +356,11 @@ const fakeScene = {
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
};
// 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: 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);
@ -218,64 +372,125 @@ check('assignUi: a late UI object joins the UI pass only', (lateUi.cameraFilter
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
assignUi({}, lateUi); // must not throw
check('assign*: no-op while the split does not exist (single-camera pipeline intact)', true);
// The facade:
// --- 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)', (() => {
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('nebula') === false && f.active === false;
return f.apply('redDwarf') === false && f.active === false;
})());
check('facade: apply(nebula) registers the node once + splits + attaches', (() => {
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' &&
fakeRenderer.renderNodes.hasNode(SE.RIPPLE_NODE) &&
list.length === 1 &&
list[0].renderNode === SE.RIPPLE_NODE &&
list[0].camera === fakeCamMain
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: config parameters land on the controller', (() => {
check('facade: nebula ripple reads the config (strength/amp/speed + padded)', (() => {
const c = fakeCamMain.filters.internal.list[0];
const e = types.nebula.effect;
const r = types.nebula.effect.ripple;
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))
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('nebula');
return fakeCamMain.filters.internal.list.length === 1;
fx.apply('redDwarf');
return fakeCamMain.filters.internal.list.length === 2;
})());
check('facade: update() advances the phase and keeps the screen anchor pinned', (() => {
const c = fakeCamMain.filters.internal.list[0];
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.centerX, 0.5) && approx(c.centerY, 0.5) && approx(c.time, 1.5 * (Number(types.nebula.effect.speed) || 1));
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', (() => {
const c = fakeCamMain.filters.internal.list[0];
c.center = 'star'; // exercise the tracking branch
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.centerX, 320 / 1280) && approx(c.centerY, 180 / 720);
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();
@ -284,35 +499,78 @@ check('facade: release() detaches cleanly (and twice)', (() => {
return first;
})());
// --- The shader source contract (through a live node instance) ---------------
// --- The shader contract (through live node instances) ----------------------
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;
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('shader: keeps the build\'s filter conventions (uMainSampler, outTexCoord, boundedSampler)', (() => {
check('ripple shader: keeps the build\'s filter conventions', (() => {
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)')
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('shader: displacement is radial from the center, clean at the center, calm at the corners', (() => {
check('ripple shader: radial displacement from each 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')
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)')
);
})());

View File

@ -1107,53 +1107,103 @@ The player holds a REPUTATION (standing) on each planet and space station:
- To upgrade: replace the vendored file + note the version here (and re-check
the quirks above — they may go away).
## System effects — the star's character (data-driven, WebGL)
## System effects — the star's character (data-driven)
- A system's visual character is a full-screen composite effect on the
WORLD camera, configured per archetype in `data/systems.json →
types.<type>.effect` (`kind: none | ripple`). Nebula systems wear the
RIPPLE — concentric rings radiating from the middle of the view
(`center: "screen"`; `"star"` tracks world 0,0 instead) — young,
bright, still messy: the light itself is unsettled. The star itself is
invisible flavor; the shimmer is its character.
- Files: `js/visuals/SystemEffects.js` (filter + facade),
`js/visuals/SystemEffectsMath.js` (pure UV math),
`js/visuals/UiCameras.js` (the UI camera split),
- A system's visual character is a **bundle of sub-effects** configured per
archetype in `data/systems.json → types.<type>.effect`. Each sub-effect is
an OPTIONAL block; a type wears exactly what it lists (an empty `{}` = no
effect). Three archetypes are live today; the rest render untouched:
- **nebula** — the RIPPLE (1 center, screen anchor): concentric rings,
the light unsettled.
- **redDwarf** — a SUPER-subtle RIPPLE (1 center, star anchor; the base
shimmer is barely there) that SURGES on each FLARE — the flare is a
seeded rhythm that spikes the ripple's amplitude AND flashes the grade
warm, together — plus a warm GRADE (tint + a SHADOW LIFT that turns the
darkness red), a gritty RED HAZE over the void (content, behind the
stars), and ember dust (content).
- **binary** — a two-color SPLIT grade (the two light sources washing the
view from opposite sides) + a wandering companion star (content). NO
ripple — two stars, one steady light.
The star itself is invisible flavor; the shimmer / glow / wander is its
character.
- Two kinds of sub-effect:
- **Camera filters** (WebGL; the HUD is excluded by the UI-camera split):
`ripple` (12 centers, `center: screen|star`, `strength/amplitude/speed/padding`,
`centers`, `orbit.radius/period`) and `grade` (`tint/amount/saturation/brightness`,
`lift: {amount,color}` — a SHADOW LIFT that pushes only the dark pixels
toward a hue (the red void), `grain: {amount,parallax}` — fine warm
grain in the dark, offset by the camera scroll so it sits at a depth
(it shifts slightly as you fly, not stuck to the lens), or
`split: {a,b,mix,axis}` — a two-color directional wash). The
`flare` (`interval/duration/rippleBoost/flash/flashColor`) is a shared
signal both filters read, so the wave and the light flash as one.
- **Content** (world-space sprites, Canvas-safe, behind the art):
`haze` (redDwarf red haze over the void → `js/visuals/RedHaze.js`, depth
1, BEHIND the stars), `particles` (redDwarf ember dust →
`js/visuals/EmberField.js`), and `wanderer` (binary companion star →
`js/visuals/WandererStar.js`).
- Files: `js/visuals/SystemEffects.js` (ripple + grade filters + facade),
`js/visuals/SystemEffectsMath.js` (pure UV / flare / orbit / grade / lift
math), `js/visuals/UiCameras.js` (the UI camera split),
`js/visuals/RedHaze.js` (the gritty red haze), `js/visuals/EmberField.js`
(ember dust), `js/visuals/WandererStar.js` (companion star),
`js/galaxy/FxSystems.js` (demo system picker),
`dev/system-effects.test.mjs` (35 checks, bare Node).
- **How it draws (Phaser 4.2.1):** the ripple is a
`Phaser.Filters.Controller` on `cameras.main`'s `filters.internal`
list — when any internal filter is active the camera pass
auto-composites (FBO + blit), so the WORLD (starfield included) is
displaced and the HUD is not. The UI pass is a second camera
(`fx-ui`, `forceComposite`) that draws the screen-pinned objects on
top WITHOUT clearing the world (a second direct pass would `clear()`
the canvas). Both directions are partitioned with `camera.ignore()`
(set-only — there is no un-ignore): screen-pinned roots are ignored by
MAIN, world roots (220+) by the UI pass, so nothing draws twice and
input still hits the UI first (hit-test walks cameras in reverse
array order). Verified in the browser: a scrollFactor-0 test ruler on
the UI pass is pixel-stable across frames while the world behind it
shimmers.
- **WebGL-only, Canvas-safe:** `apply()` requires `renderer.gl`; on the
Canvas fallback the system renders untouched (no crash, no effect).
The node constructor is registered once per renderer
(`ensureRippleNode`, `hasNode`-guarded — the registry is global and
throws on duplicates).
- **Tuning is a JSON edit:** `strength` (wave cycles across the screen),
`amplitude` (displacement, fraction of screen width), `speed` (phase
rad/s), `padding` (framebuffer slack — keep ≥ amplitude×width). The
displacement is `dir * sin(dist*strength phase) * amplitude * fade`,
sampled through the injected `boundedSampler` (out-of-bounds reads
return transparent — a too-big amplitude punches holes; the padding is
the guard).
- **Demo:** `?fx=<type>` (e.g. `dev/test-game.html?fx=nebula`) retargets
the run to the richest system of that archetype
(`pickFxSystem`), force-discovers everything
(`Discovery.markAllDiscovered`) and activates the gates — a clean
minute of flying to judge the star. No param → ordinary play, and
`kind: none` systems never split the cameras (the single-camera
pipeline renders exactly as before).
`dev/system-effects.test.mjs` (57 checks, bare Node).
- **How the filters draw (Phaser 4.2.1):** each filter is a
`Phaser.Filters.Controller` on `cameras.main`'s `filters.internal` list —
when any internal filter is active the camera pass auto-composites (FBO +
blit), so the WORLD (starfield included) is displaced/graded and the HUD
is not. Multiple filters chain on the SAME pass (ripple then grade). The UI
pass is a second camera (`fx-ui`, `forceComposite`) that draws the
screen-pinned objects on top WITHOUT clearing the world. Both directions
are partitioned with `camera.ignore()` (set-only — no un-ignore):
screen-pinned roots ignored by MAIN, world roots (220+) by the UI pass,
so nothing draws twice and input still hits the UI first (hit-test walks
cameras in reverse array order). Verified in the browser: a scrollFactor-0
test ruler on the UI pass is pixel-stable across frames while the world
behind it shimmers.
- **WebGL-only for the filters, Canvas-safe for content:** the filters need
`renderer.gl`; on the Canvas fallback they are skipped (no crash). The
CONTENT sub-effects are plain sprites and render regardless. The node
constructors are registered once per renderer (`ensureRippleNode` /
`ensureGradeNode`, `hasNode`-guarded — the registry is global and throws
on duplicates).
- **The ripple math:** up to two centers, each contributing a traveling
radial wave; the total displacement is the sum. Per center:
`dir * sin(dist*strength phase) * amplitude * fade`, sampled through the
injected `boundedSampler` (out-of-bounds reads return transparent — a
too-big amplitude punches holes; the padding is the guard). A center with
amplitude 0 is inert, so the 1-center systems are the degenerate case.
`padding` must cover the worst case: `amplitude × (1+flare.rippleBoost) ×
centers × max(width,height)` (the test enforces it).
- **The grade math** (pure in `gradeColor`, mirrored in the shader):
brightness → saturation (pull to luma) → tint (a hue multiplier
normalized to mean 1, blended by `amount`) → SHADOW LIFT (push the dark
pixels toward `lift.color` by `lift.amount`, luminance-gated so the bright
stars stay put — `shadowLift`, pure + tested) → split (a two-color wash
across the view along a seeded axis) → flash (an additive warm spike
during a flare). The `grain` is a per-pixel spatial effect and lives in
the shader only. All values are scalars (no vec packing) for portability.
- **The red haze (content, not a filter):** the darkness of space is the
renderer's BACKGROUND (config `backgroundColor`), which a camera filter
does NOT reach — the clear color bypasses the composite. So the red void
is a CONTENT layer: one large, canvas-generated gritty red texture (dark
base + soft blotches + fine grain) drawn at depth 1, BEHIND the
starfield (02), recentered on the camera each frame so it always fills
the view. The white stars and all art sit in front and stay bright — only
the void turns a dusty red. Canvas-safe, never touches the HUD.
- **Per-system variation:** `SystemGenerator` stamps `content.fx =
{phase, angle, drift}` (a dedicated fork `(seed,'system',id,'fx')`, so
lazy === eager) — the flare rhythm, the split axis, and the companion's
wander are all seeded per system, so each system is stable but distinct.
- **Tuning is a JSON edit**`data/systems.json → types.<type>.effect`.
- **Demo:** `?fx=<type>` (e.g. `dev/test-game.html?fx=redDwarf`) retargets
the run to the richest system of that archetype (`pickFxSystem`,
case-insensitive), force-discovers everything
(`Discovery.markAllDiscovered`) and activates the gates — a clean minute
of flying to judge the star. No param → ordinary play, and an empty-bundle
system never splits the cameras (the single-camera pipeline renders
exactly as before).
## Nebula atmosphere — the gas behind the art

View File

@ -279,6 +279,21 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
? nebulaPalette[atmosphereRng.int(0, nebulaPalette.length - 1)]
: null;
// --- Per-system FX variation (js/visuals/SystemEffects.js) -------------
// A few stable 0..1 numbers the live effects (redDwarf flare, binary
// two-star orbit / split axis / companion star) use for their per-system
// variation: phase (flare rhythm / orbit start), angle (split axis /
// wanderer heading), drift (wanderer speed). A dedicated fork
// (seed, 'system', id, 'fx') keeps it independent of the draws above,
// so lazy === eager. Stamped on every record (cheap) — only the live
// effect families read it.
const fxRng = Rng.derive(galaxy.seed, 'system', record.id, 'fx');
const fx = {
phase: fxRng.next(),
angle: fxRng.next(),
drift: fxRng.next(),
};
const content = {
name: record.name,
type: record.type,
@ -290,6 +305,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
hazard,
jumps, // the system's jump gates (13; [] for a one-system galaxy)
atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems)
fx, // per-system effect variation (flare phase / orbit / split / wanderer)
};
if (isHome) {
content.homeName = homeName; // the player's home world (starting system only)

View File

@ -19,6 +19,9 @@ import { JumpGate } from '../entities/JumpGate.js';
import { jumpArrival } from '../galaxy/JumpTravel.js';
import { Starfield } from '../visuals/Starfield.js';
import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js';
import { EmberField } from '../visuals/EmberField.js';
import { WandererStar } from '../visuals/WandererStar.js';
import { RedHaze } from '../visuals/RedHaze.js';
import { SystemEffects } from '../visuals/SystemEffects.js';
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
import { pickFxSystem } from '../galaxy/FxSystems.js';
@ -613,6 +616,25 @@ export class GameScene extends Phaser.Scene {
this.nebula.create();
}
// Per-system CONTENT effects (world-space sprites, Canvas-safe):
// - redDwarf ember dust (effect.particles) → EmberField (depth 3)
// - redDwarf red haze (effect.haze) → RedHaze (depth -1)
// - binary companion star (effect.wanderer) → WandererStar (depth 3)
// (The camera FILTERS — ripple + grade — are SystemEffects' job.)
const _fx = config.get(`systems.types.${this.systemRecord?.type}.effect`, null);
if (_fx?.haze) {
this.haze = new RedHaze(this, _fx.haze, this.systemContent?.fx ?? {});
this.haze.create();
}
if (_fx?.particles) {
this.embers = new EmberField(this, _fx.particles.color, _fx.particles, this.systemContent?.fx ?? {});
this.embers.create();
}
if (_fx?.wanderer) {
this.wanderer = new WandererStar(this, _fx.wanderer.color, _fx.wanderer, this.systemContent?.fx ?? {});
this.wanderer.create();
}
// The TETHER — the player's range. In the STARTING system it starts
// as one level-1 tether anchored on the home world (data/tether.json):
// the ship may fly anywhere within its rim (level 1 = 5120 px from the
@ -1294,9 +1316,11 @@ export class GameScene extends Phaser.Scene {
/**
* The effects demo's archetype (js/visuals/SystemEffects.js): read the
* `?fx=` URL parameter (browser only no-op in Node/tests) and keep it
* only if it names a known system type (data/systems.json types).
* `?fx` alone defaults to `nebula` the one effect family wired up so
* far. Example: http://localhost:8080/?fx=nebula
* only if it names a known system type (data/systems.json types),
* matched case-insensitively (so `?fx=redDwarf` resolves to the camelCase
* `redDwarf` key). `?fx` alone defaults to `nebula`. The live families
* today: nebula, redDwarf, binary.
* Example: http://localhost:8080/?fx=redDwarf
*
* @returns {string | null} a systems.types key, or null
*/
@ -1304,12 +1328,16 @@ export class GameScene extends Phaser.Scene {
const loc = typeof globalThis !== 'undefined' ? globalThis.location : null;
if (!loc?.search) return null;
try {
const raw = new URLSearchParams(loc.search).get('fx') ?? '';
const type = (raw.trim() || 'nebula').toLowerCase();
const raw = (new URLSearchParams(loc.search).get('fx') ?? '').trim() || 'nebula';
const types = config.section('systems.types', {});
return types && typeof types === 'object' && Object.prototype.hasOwnProperty.call(types, type)
? type
: null;
if (!types || typeof types !== 'object') return null;
// Case-insensitive match on the config keys, so `?fx=redDwarf`,
// `?fx=reddwarf`, `?fx=REDWARf` all resolve to the `redDwarf` type.
const wanted = raw.toLowerCase();
for (const key of Object.keys(types)) {
if (key.toLowerCase() === wanted) return key;
}
return null;
} catch {
return null;
}
@ -1660,6 +1688,9 @@ export class GameScene extends Phaser.Scene {
this.updateCamera(delta);
this.starfield.update(); // after the camera, so it sees this frame's motion
this.nebula?.update(); // the gas: parallax + slow self-swirl
this.haze?.update(_time, delta); // redDwarf red haze: follow the camera
this.embers?.update(_time, delta); // redDwarf ember dust: drift + parallax
this.wanderer?.update(_time, delta); // binary companion star: slow wander
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
this.tetherField.draw(_time); // the barrier (on-screen dots only)
this.actionBar?.update(_time, delta); // the deck's living details
@ -4268,6 +4299,9 @@ export class GameScene extends Phaser.Scene {
this.stopGameMusic(); // neither can the soundtrack
this.starfield?.destroy();
this.nebula?.destroy();
this.haze?.destroy();
this.embers?.destroy();
this.wanderer?.destroy();
this.compass?.destroy();
this.actionBar?.destroy();
this.menuSubBar?.destroy();

154
js/visuals/EmberField.js Normal file
View File

@ -0,0 +1,154 @@
/**
* redDwarf ember field a slow drift of tiny warm sparks behind the art.
*
* Content layer (world-space sprites), NOT a filter: it works in the
* Canvas fallback and can never touch the HUD (it is a sprite at depth 3,
* like the starfield, so the camera split is irrelevant to it). The sparks
* are small, warm, additively blended (they glow like embers) and sit at a
* parallax BETWEEN the starfield (0.150.85) and the ship dust in the
* air, closer than the stars, farther than you.
*
* The field FILLs the view and parallaxes like the starfield (the proven
* pattern in Starfield.js): embers start in a window around the CURRENT
* camera, each frame they shift by (1 p) of the camera delta (so on
* screen they stream opposite to travel, near sparks faster than far
* ones) plus their own slow drift, and are wrapped back into the current
* view. That keeps them on screen no matter how far the ship flies.
*
* Config: data/systems.json types.redDwarf.effect.particles.
* Node-testable color/shape math is inline (this file imports Phaser and
* is exercised in the browser via dev/server.mjs).
*/
import Phaser from '../vendor/phaser.js';
import { canvasTexture } from '../utils/Textures.js';
import { toColor } from '../utils/Color.js';
const DEPTH = 3; // behind the art (planets 5, ship 10), in front of starfield
const MARGIN = 80; // embers may start/exit this far past the viewport
/** A small, bright, soft dot — an ember. White so a tint reads as color. */
function drawSpark(ctx, size) {
const c = size / 2;
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
g.addColorStop(0.35, 'rgba(255,255,255,0.85)');
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
}
/** Maps any coordinate into [left, left + span) — safe for huge deltas too. */
function wrapIn(v, left, span) {
return left + ((((v - left) % span) + span) % span);
}
/**
* The ember layer.
*
* @param {object} scene Phaser scene (add / camera / renderer / scale)
* @param {string} color ember color (CSS hex)
* @param {object} cfg data/systems.json effect.particles
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
*/
export class EmberField {
constructor(scene, color, cfg, fx = {}) {
this.scene = scene;
this.color = toColor(color, 0xff8a4a);
this.cfg = cfg ?? {};
this.fx = fx ?? {};
this.sprites = [];
this.lastScrollX = 0;
this.lastScrollY = 0;
}
/** Build the spark texture + one sprite per ember (idempotent). */
create() {
const scene = this.scene;
if (this.sprites.length) return;
const {
count = 40,
size = [2, 6],
alpha = [0.4, 0.9],
parallax = [0.25, 0.5],
speed = [8, 25],
} = this.cfg;
const texKey = canvasTexture(scene, 'fx-ember-spark', 32, 32, (ctx) => drawSpark(ctx, 32));
const w = scene.scale.width;
const h = scene.scale.height;
const cam = scene.cameras.main;
// Embers start in a window around the CURRENT camera view (like the
// starfield); update() keeps them wrapped into it as the camera flies.
const left = cam.scrollX - MARGIN;
const top = cam.scrollY - MARGIN;
// Seeded per-ember variation (xorshift32 from the system fx, so the
// same system looks the same every run).
let s = (Math.imul(0x9e3779b1, 2654435761) ^ Math.round((this.fx.angle ?? 0.5) * 1e9)) >>> 0;
const rand = () => {
s ^= s << 13; s >>>= 0;
s ^= s >> 17; s >>>= 0;
s ^= s << 5; s >>>= 0;
return (s >>> 0) / 4294967296;
};
const range = (a, b) => a + (b - a) * rand();
for (let i = 0; i < count; i++) {
const bx = left + rand() * (w + 2 * MARGIN);
const by = top + rand() * (h + 2 * MARGIN);
const ang = rand() * Math.PI * 2;
const spd = range(speed[0], speed[1]);
const sp = scene.add
.image(bx, by, texKey)
.setDepth(DEPTH)
.setTint(this.color)
.setAlpha(range(alpha[0], alpha[1]))
.setBlendMode(Phaser.BlendModes.ADD);
const sz = range(size[0], size[1]);
sp.setScale(sz / 16); // 32px texture, scale to ~size px
this.sprites.push({
sp,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
p: range(parallax[0], parallax[1]),
});
}
this.lastScrollX = cam.scrollX;
this.lastScrollY = cam.scrollY;
}
/** Per-frame: parallax (opposite the camera, like the starfield) + drift. */
update(nowMs, dtMs) {
if (!this.sprites.length) return;
const dt = (dtMs || 0) / 1000;
const cam = this.scene.cameras.main;
const dx = cam.scrollX - this.lastScrollX;
const dy = cam.scrollY - this.lastScrollY;
this.lastScrollX = cam.scrollX;
this.lastScrollY = cam.scrollY;
const w = this.scene.scale.width;
const h = this.scene.scale.height;
const spanX = w + 2 * MARGIN;
const spanY = h + 2 * MARGIN;
const left = cam.scrollX - MARGIN;
const top = cam.scrollY - MARGIN;
for (const e of this.sprites) {
// Move by (1 p) of the camera delta (→ on screen p of it, opposite
// to travel, near sparks faster), plus the ember's own slow drift.
e.sp.x += dx * (1 - e.p) + e.vx * dt;
e.sp.y += dy * (1 - e.p) + e.vy * dt;
// Keep the field full: wrap into the current camera window.
e.sp.x = wrapIn(e.sp.x, left, spanX);
e.sp.y = wrapIn(e.sp.y, top, spanY);
}
}
/** Tear down. */
destroy() {
for (const e of this.sprites) e.sp.destroy();
this.sprites = [];
}
}

140
js/visuals/RedHaze.js Normal file
View File

@ -0,0 +1,140 @@
/**
* redDwarf "red haze" a thin, gritty red cast over the DARKNESS of space
* behind the stars.
*
* The darkness is the renderer's background (config backgroundColor), which
* a camera filter does NOT reach (the clear color bypasses the composite).
* So this is a CONTENT layer, not a filter: one large, canvas-generated
* gritty red-haze texture drawn at depth -1 (BEHIND the starfield's 02),
* recentered on the camera each frame so it always fills the view. The
* white stars and all the art sit in front and stay bright only the void
* turns a dusty red.
*
* The texture (generated once, seeded per system) is: a dark warm-red base
* + a few large soft blotches (the "haze") + fine per-pixel grain (the
* "grit"). It is a plain sprite, so it is Canvas-safe and can never touch
* the HUD (it is world-space, behind everything, independent of the UI split).
*
* Config: data/systems.json types.redDwarf.effect.haze.
*/
import Phaser from '../vendor/phaser.js';
import { canvasTexture } from '../utils/Textures.js';
import { hexToRgb01 } from './SystemEffectsMath.js';
const DEPTH = -1; // behind the starfield (02) — the very back
const MARGIN = 320; // the haze extends this far past the viewport (covers drift)
/** A seeded 32-bit PRNG (mulberry32) so a system's haze is stable per run. */
function makeRng(seedNum) {
let a = (seedNum >>> 0) || 0x9e3779b9;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Paint one haze tile (SIZE×SIZE): dark base + soft blotches + fine grain.
* All from the config; deterministic under `rng`.
*/
function paintHaze(ctx, size, cfg, rng) {
const [br, bg, bb] = hexToRgb01(cfg.base ?? '#160b09');
const [tr, tg, tb] = hexToRgb01(cfg.tint ?? '#c0392b');
const to255 = (v) => Math.max(0, Math.min(255, Math.round(v * 255)));
// 1. dark warm-red base (this IS the "darkness", so it reads as space).
ctx.fillStyle = `rgb(${to255(br)}, ${to255(bg)}, ${to255(bb)})`;
ctx.fillRect(0, 0, size, size);
// 2. soft atmospheric blotches — the "haze" (large, low-contrast).
const blobs = Math.max(3, Math.round(cfg.blobs ?? 9));
const [sMin, sMax] = Array.isArray(cfg.scale) ? cfg.scale : [0.18, 0.42];
for (let i = 0; i < blobs; i++) {
const x = rng() * size;
const y = rng() * size;
const r = size * (sMin + (sMax - sMin) * rng());
const warm = 0.55 + 0.45 * rng();
const a = 0.10 + 0.16 * rng();
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(${to255(tr * warm)}, ${to255(tg * warm)}, ${to255(tb * warm)}, ${a.toFixed(3)})`);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
}
// 3. fine grain — the "grit" (per-pixel, warm-biased).
const amp = Math.max(0, cfg.grain ?? 20);
if (amp > 0) {
const img = ctx.getImageData(0, 0, size, size);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const n = (rng() - 0.5) * 2 * amp;
d[i] = Math.max(0, Math.min(255, d[i] + n));
d[i + 1] = Math.max(0, Math.min(255, d[i + 1] + n * 0.62));
d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + n * 0.46));
}
ctx.putImageData(img, 0, 0);
}
}
/**
* The red-haze layer (a single full-screen sprite behind the stars).
*
* @param {object} scene Phaser scene (add / camera / scale / textures)
* @param {object} cfg data/systems.json effect.haze
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
*/
export class RedHaze {
constructor(scene, cfg, fx = {}) {
this.scene = scene;
this.cfg = cfg ?? {};
this.fx = fx ?? {};
this.sp = null;
this.tSec = 0;
}
/** Build the texture + sprite (idempotent). */
create() {
const scene = this.scene;
if (this.sp) return;
const SIZE = 1024;
// Fixed seed: the haze is a shared, subtle background, so it is
// consistent across systems and runs (the texture is cached by key).
const texKey = canvasTexture(scene, 'fx-redhaze', SIZE, SIZE, (ctx) => {
paintHaze(ctx, SIZE, this.cfg, makeRng(0x51ab1234));
});
const w = scene.scale.width;
const h = scene.scale.height;
const cam = scene.cameras.main;
// One large sprite, viewport + margin, centered on the camera.
this.sp = scene.add
.image(cam.scrollX + w / 2, cam.scrollY + h / 2, texKey)
.setDepth(DEPTH)
.setAlpha(this.cfg.alpha ?? 0.9)
.setScale((w + 2 * MARGIN) / SIZE, (h + 2 * MARGIN) / SIZE);
}
/** Per-frame: keep the haze centered on the camera (with a slow drift). */
update(nowMs, dtMs) {
if (!this.sp) return;
this.tSec += (dtMs || 0) / 1000;
const cam = this.scene.cameras.main;
const w = this.scene.scale.width;
const h = this.scene.scale.height;
const t = this.tSec;
// A very slow drift so the haze feels alive (amplitude < MARGIN, so it
// always still covers the view).
const dx = Math.sin(t * 0.05 + this.fx.phase * 6.28) * 120;
const dy = Math.cos(t * 0.04 + this.fx.drift * 6.28) * 120;
this.sp.x = cam.scrollX + w / 2 + dx;
this.sp.y = cam.scrollY + h / 2 + dy;
}
/** Tear down. */
destroy() {
this.sp?.destroy();
this.sp = null;
}
}

View File

@ -1,59 +1,67 @@
/**
* Per-system visual effects the star's "character", rendered as a
* full-screen composite over the WORLD camera.
* Per-system visual effects the star's "character".
*
* An effect is a BUNDLE of sub-effects, all data-driven off
* data/systems.json types.<type>.effect (tuning = a JSON edit). Two kinds:
*
* CAMERA FILTERS (WebGL; the HUD is excluded by the UI-camera split):
* - ripple: concentric waves, 1 or 2 centers. One center (nebula,
* redDwarf) radiates from the view middle ("screen") or from the
* star's direction ("star"); two centers can orbit each other
* (orbit.radius/period). Every sub-effect is OPTIONAL a type wears
* exactly what it lists (binary, for example, has only the grade +
* the wandering star no ripple at all).
* - grade: a per-pixel color grade tint + saturation + brightness,
* or a two-color directional split, plus a flare flash (redDwarf).
*
* CONTENT (world-space sprites, Canvas-safe, behind the art at depth 3):
* - particles (redDwarf ember dust) js/visuals/EmberField.js
* - wanderer (binary companion star) js/visuals/WandererStar.js
* (Those are created by GameScene, not here this file owns the
* camera filters.)
*
* Why camera-level (not per-object): a system is a PLACE. Its effect
* should bend the light of everything in it the starfield included
* as one continuous spacetime distortion. Per-body filters would need
* one composite pass per body (200+ starfield sprites, no) and the
* stars could never warp. One camera filter = one full-screen pass,
* as one continuous distortion. Per-body filters would need one composite
* pass per body (200+ starfield sprites, no) and the stars could never
* warp. One or two camera filters = a couple of full-screen passes,
* constant cost, whole scene coheres. The HUD is excluded by drawing it
* on a separate, unfiltered camera (js/visuals/UiCameras.js).
*
* The effect itself is DATA-DRIVEN: every system type carries an
* `effect` block in data/systems.json types.<id>.effect:
* "effect": {
* "kind": "ripple", // effect family (today: "ripple" | "none")
* "strength": 90, // wave cycles across the screen (1/UV)
* "amplitude": 0.010, // displacement, in UV (fraction of screen)
* "speed": 1.0, // phase advance, radians/second
* "padding": 16 // px of extra framebuffer the filter may sample
* }
* `kind: "none"` (or a missing block) renders the system untouched
* the common case, zero cost (no filter attached, no extra pass).
* The "flare" (redDwarf) is a shared signal: one seeded rhythm drives BOTH
* the ripple's amplitude surge AND the grade's brightness flash, so the
* wave and the light flash together the star flaring.
*
* The RIPPLE: concentric waves radiating from a CENTER and traveling
* outward. Two anchors (data-driven, `effect.center`):
* "screen" (default) the middle of the view: the sandbox look,
* always visible while you fly; the star itself is invisible
* flavor, so the shimmer is the star's character, not a map of
* where it sits;
* "star" the system origin (world 0,0), tracked each frame: the
* wavefronts arrive from the star's direction, which is usually
* off-screen (the ship spawns at a world, not the star) a
* subtler, directional shimmer.
* Nebula systems wear it: young, bright, still messy the light
* itself is unsettled.
*
* WebGL only. On the canvas renderer apply() is a no-op the game
* degrades to no effect, never to a crash.
* WebGL only for the filters. On the canvas renderer the filters are a
* no-op (the game degrades to no filter, never to a crash); the CONTENT
* sub-effects still render (they are plain sprites).
*
* Node-testable math lives in SystemEffectsMath.js (this file imports
* Phaser and is exercised in the browser via dev/server.mjs).
*/
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { ripplePhase, worldToUV } from './SystemEffectsMath.js';
import {
ripplePhase,
worldToUV,
flareIntensity,
orbitCenters,
hexToRgb01,
tintMultiplier,
} from './SystemEffectsMath.js';
import { ensureUiCameras } from './UiCameras.js';
/** Registered render-node name for the ripple effect (renderer-global). */
/** Registered render-node names (renderer-global). */
export const RIPPLE_NODE = 'FilterRippleEffect';
export const GRADE_NODE = 'FilterGradeEffect';
/**
* The ripple fragment shader Phaser 4 filter-shader conventions
* (verified against the vendored build's own filter shaders):
* `uMainSampler` is the input frame, `outTexCoord` the screen UV, and
* `boundedSampler` is auto-injected via the BoundedSampler addition.
* The ripple fragment shader generalized to up to TWO centers. Each
* center contributes a traveling radial wave (phase moves outward); the
* total displacement is the sum. A center with amplitude 0 is inert, so a
* one-center system (nebula, redDwarf) is the degenerate case. Phaser 4
* filter-shader conventions: `uMainSampler` is the input frame,
* `outTexCoord` the screen UV, `boundedSampler` auto-injected.
*/
const RIPPLE_FRAGMENT = [
'#pragma phaserTemplate(shaderName)',
@ -61,34 +69,130 @@ const RIPPLE_FRAGMENT = [
'uniform sampler2D uMainSampler;',
'uniform float time;',
'uniform float strength;',
'uniform float amplitude;',
'uniform float centerX;',
'uniform float centerY;',
'uniform float amp0;',
'uniform float amp1;',
'uniform float cx0;',
'uniform float cy0;',
'uniform float cx1;',
'uniform float cy1;',
'varying vec2 outTexCoord;',
'#pragma phaserTemplate(fragmentHeader)',
'void main()',
'{',
' vec2 center = vec2(centerX, centerY);',
' vec2 delta = outTexCoord - center;',
' float dist = length(delta);',
' float invDist = dist > 0.0001 ? 1.0 / dist : 0.0;',
' vec2 dir = delta * invDist;',
' // Traveling rings: phase moves OUTWARD from the center (screen',
' // middle by default — the star itself is invisible flavor).',
' float wave = sin(dist * strength - time);',
' // Attenuation: zero displacement exactly on the center, a gentle',
' // calm-down toward the corners. Screen-anchored: dist stays within',
' // ~0..0.7, so the rings read strong across the view.',
' float fade = smoothstep(0.0, 0.02, dist) * (0.8 + 0.2 * exp(-dist * 0.25));',
' vec2 uv = outTexCoord + dir * (wave * amplitude * fade);',
' gl_FragColor = boundedSampler(uMainSampler, uv);',
' vec2 disp = vec2(0.0);',
// center 0
' vec2 d0 = outTexCoord - vec2(cx0, cy0);',
' float r0 = length(d0);',
' if (amp0 > 0.0001 && r0 > 0.0001) {',
' vec2 dir0 = d0 / r0;',
' float w0 = sin(r0 * strength - time);',
' float f0 = smoothstep(0.0, 0.02, r0) * (0.8 + 0.2 * exp(-r0 * 0.25));',
' disp += dir0 * (w0 * amp0 * f0);',
' }',
// center 1
' vec2 d1 = outTexCoord - vec2(cx1, cy1);',
' float r1 = length(d1);',
' if (amp1 > 0.0001 && r1 > 0.0001) {',
' vec2 dir1 = d1 / r1;',
' float w1 = sin(r1 * strength - time);',
' float f1 = smoothstep(0.0, 0.02, r1) * (0.8 + 0.2 * exp(-r1 * 0.25));',
' disp += dir1 * (w1 * amp1 * f1);',
' }',
' gl_FragColor = boundedSampler(uMainSampler, outTexCoord + disp);',
'}',
].join('\n');
/**
* The filter's state carrier (Phaser.Filters.Controller) the values
* the shader reads each pass. Plain data: time (radians), strength
* (cycles/UV), amplitude (UV), and the star's screen UV.
* The grade fragment shader per-pixel color grade (the exact math of
* gradeColor in SystemEffectsMath.js, as a thin GLSL wrapper):
* brightness saturation tint directional split flare flash.
* All uniforms are scalars (no vec packing) for portability.
*/
const GRADE_FRAGMENT = [
'#pragma phaserTemplate(shaderName)',
'precision mediump float;',
'uniform sampler2D uMainSampler;',
'uniform float uBright;',
'uniform float uSat;',
'uniform float uTintAmt;',
'uniform float uTintR;',
'uniform float uTintG;',
'uniform float uTintB;',
'uniform float uSplitMix;',
'uniform float uSplitAR;',
'uniform float uSplitAG;',
'uniform float uSplitAB;',
'uniform float uSplitBR;',
'uniform float uSplitBG;',
'uniform float uSplitBB;',
'uniform float uAxisX;',
'uniform float uAxisY;',
'uniform float uFlash;',
'uniform float uFlashR;',
'uniform float uFlashG;',
'uniform float uFlashB;',
'uniform float uLiftAmt;',
'uniform float uLiftR;',
'uniform float uLiftG;',
'uniform float uLiftB;',
'uniform float uGrainAmt;',
'uniform float uGrainOffX;',
'uniform float uGrainOffY;',
'varying vec2 outTexCoord;',
'#pragma phaserTemplate(fragmentHeader)',
'void main()',
'{',
' vec4 col = boundedSampler(uMainSampler, outTexCoord);',
' vec3 rgb = col.rgb;',
' rgb *= uBright;',
' if (uSat != 1.0) {',
' float l = dot(rgb, vec3(0.299, 0.587, 0.114));',
' rgb = mix(vec3(l), rgb, uSat);',
' }',
' if (uTintAmt > 0.0) {',
' vec3 tint = vec3(uTintR, uTintG, uTintB);',
' rgb = mix(rgb, rgb * tint, uTintAmt);',
' }',
' // Shadow lift: push the DARKNESS toward the lift hue (the red void),',
' // leaving bright pixels (the stars) alone. Luminance-gated.',
' if (uLiftAmt > 0.0) {',
' float lum2 = dot(rgb, vec3(0.299, 0.587, 0.114));',
' float darkAmt = (1.0 - clamp(lum2 * 3.0, 0.0, 1.0)) * uLiftAmt;',
' rgb += vec3(uLiftR, uLiftG, uLiftB) * darkAmt;',
' }',
' // Gritty grain: fine, warm, only in the dark (a thin red haze). The',
' // coordinate is offset by the camera scroll (uGrainOff) so the grain',
' // sits at a DEPTH in the air — it shifts slightly as you fly, instead',
' // of being stuck to the lens (a subtle parallax).',
' if (uGrainAmt > 0.0) {',
' float lum3 = dot(rgb, vec3(0.299, 0.587, 0.114));',
' float gdark = 1.0 - clamp(lum3 * 3.0, 0.0, 1.0);',
' vec2 gp = gl_FragCoord.xy + vec2(uGrainOffX, uGrainOffY);',
' float gnoise = fract(sin(dot(gp, vec2(12.9898, 78.233))) * 43758.5453);',
' rgb += (gnoise - 0.5) * uGrainAmt * gdark * vec3(1.0, 0.55, 0.45);',
' }',
' if (uSplitMix > 0.0) {',
' float alen = length(vec2(uAxisX, uAxisY));',
' vec2 an = alen > 0.0001 ? vec2(uAxisX, uAxisY) / alen : vec2(1.0, 0.0);',
' float s = dot(outTexCoord - vec2(0.5, 0.5), an) * 2.0;',
' s = clamp(s * 0.5 + 0.5, 0.0, 1.0);',
' vec3 a = vec3(uSplitAR, uSplitAG, uSplitAB);',
' vec3 b = vec3(uSplitBR, uSplitBG, uSplitBB);',
' rgb = mix(rgb, rgb * (b + (a - b) * s), uSplitMix);',
' }',
' if (uFlash > 0.0) rgb += vec3(uFlashR, uFlashG, uFlashB) * uFlash;',
' gl_FragColor = vec4(rgb, col.a);',
'}',
].join('\n');
// ---------------------------------------------------------------------------
// RIPPLE
// ---------------------------------------------------------------------------
/**
* The ripple's state carrier (Phaser.Filters.Controller) the values the
* shader reads each pass: time (radians), strength (cycles/UV), and the
* two centers' UVs + amplitudes. Plain data.
*
* @extends {Phaser.Filters.Controller}
*/
@ -96,19 +200,30 @@ class RippleController extends Phaser.Filters.Controller {
/** @param {object} camera the world camera */
constructor(camera) {
super(camera, RIPPLE_NODE);
// uniforms
this.time = 0;
this.strength = 90;
this.amplitude = 0.01;
this.strength = 18;
this.amp0 = 0.004;
this.amp1 = 0;
this.cx0 = 0.5;
this.cy0 = 0.5;
this.cx1 = 0.5;
this.cy1 = 0.5;
// parameters (set at apply(), read in update())
this.baseAmp = 0.004;
this.speed = 1;
this.centerX = 0.5;
this.centerY = 0.5;
this.anchor = 'screen';
this.centers = 1;
this.orbit = null; // { radius, period } (binary)
this.flare = null; // { interval, duration, rippleBoost } (redDwarf)
this.phase = 0; // per-system 0..1 (flare rhythm / orbit start)
}
}
/**
* The ripple render node (Phaser.Renderer.WebGL.RenderNodes.
* BaseFilterShader) a full-screen quad through which the camera's
* composited frame flows, displaced per the controller's uniforms.
* The ripple render node (BaseFilterShader) a full-screen quad through
* which the camera's composited frame flows, displaced per the
* controller's uniforms.
*
* @extends {Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader}
*/
@ -123,70 +238,206 @@ class FilterRippleEffect extends Phaser.Renderer.WebGL.RenderNodes.BaseFilterSha
const pm = this.programManager;
pm.setUniform('time', controller.time);
pm.setUniform('strength', controller.strength);
pm.setUniform('amplitude', controller.amplitude);
pm.setUniform('centerX', controller.centerX);
pm.setUniform('centerY', controller.centerY);
pm.setUniform('amp0', controller.amp0);
pm.setUniform('amp1', controller.amp1);
pm.setUniform('cx0', controller.cx0);
pm.setUniform('cy0', controller.cy0);
pm.setUniform('cx1', controller.cx1);
pm.setUniform('cy1', controller.cy1);
}
}
/**
* Register the ripple node with the renderer (idempotent the registry
* is renderer-global and throws on a duplicate constructor).
* Register the ripple node (idempotent the registry is renderer-global
* and throws on a duplicate constructor).
*
* @param {object} renderer Phaser renderer
* @returns {boolean} true if the node is registered after the call
*/
export function ensureRippleNode(renderer) {
const nodes = renderer?.renderNodes;
if (!nodes || typeof nodes.hasNode !== 'function') return false;
if (nodes.hasNode(RIPPLE_NODE)) return true;
try {
nodes.addNodeConstructor(RIPPLE_NODE, FilterRippleEffect);
return true;
} catch (err) {
console.warn(`[orbit] could not register the ripple filter node: ${err}`);
return false;
return registerNode(renderer, RIPPLE_NODE, FilterRippleEffect);
}
// ---------------------------------------------------------------------------
// GRADE
// ---------------------------------------------------------------------------
/**
* The grade's state carrier the per-pixel color-grade values the shader
* reads. The static parts (bright/sat/tint/split) are set once at apply();
* the flare flash (uFlash) is advanced each frame from the shared flare.
*
* @extends {Phaser.Filters.Controller}
*/
class GradeController extends Phaser.Filters.Controller {
/** @param {object} camera the world camera */
constructor(camera) {
super(camera, GRADE_NODE);
// uniforms (all scalars)
this.uBright = 1;
this.uSat = 1;
this.uTintAmt = 0;
this.uTintR = 1;
this.uTintG = 1;
this.uTintB = 1;
this.uSplitMix = 0;
this.uSplitAR = 1;
this.uSplitAG = 1;
this.uSplitAB = 1;
this.uSplitBR = 1;
this.uSplitBG = 1;
this.uSplitBB = 1;
this.uAxisX = 1;
this.uAxisY = 0;
this.uFlash = 0;
this.uFlashR = 1;
this.uFlashG = 1;
this.uFlashB = 1;
// shadow lift (the red void) + gritty grain — redDwarf; 0 = off
this.uLiftAmt = 0;
this.uLiftR = 0.75;
this.uLiftG = 0.22;
this.uLiftB = 0.17;
this.uGrainAmt = 0;
this.uGrainOffX = 0;
this.uGrainOffY = 0;
// grain parallax (0 = stuck to the lens; >0 = shifts as the camera moves)
this.grainParallax = 0;
// parameter for update(): the flare's base flash strength (0..1)
this.flareFlash = 0;
}
}
/**
* The facade GameScene drives: one effect per system type, applied on
* entry, released on exit. Holds NO scene-lifetime assumptions of its
* own (the scene's shutdown() calls release()).
* The grade render node (BaseFilterShader).
*
* @extends {Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader}
*/
class FilterGradeEffect extends Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader {
/** @param {object} manager the RenderNodes manager */
constructor(manager) {
super(GRADE_NODE, manager, null, GRADE_FRAGMENT);
}
/** Push this pass's controller values into the shader. */
setupUniforms(controller, _drawingContext) {
const pm = this.programManager;
const c = controller;
pm.setUniform('uBright', c.uBright);
pm.setUniform('uSat', c.uSat);
pm.setUniform('uTintAmt', c.uTintAmt);
pm.setUniform('uTintR', c.uTintR);
pm.setUniform('uTintG', c.uTintG);
pm.setUniform('uTintB', c.uTintB);
pm.setUniform('uSplitMix', c.uSplitMix);
pm.setUniform('uSplitAR', c.uSplitAR);
pm.setUniform('uSplitAG', c.uSplitAG);
pm.setUniform('uSplitAB', c.uSplitAB);
pm.setUniform('uSplitBR', c.uSplitBR);
pm.setUniform('uSplitBG', c.uSplitBG);
pm.setUniform('uSplitBB', c.uSplitBB);
pm.setUniform('uAxisX', c.uAxisX);
pm.setUniform('uAxisY', c.uAxisY);
pm.setUniform('uFlash', c.uFlash);
pm.setUniform('uFlashR', c.uFlashR);
pm.setUniform('uFlashG', c.uFlashG);
pm.setUniform('uFlashB', c.uFlashB);
pm.setUniform('uLiftAmt', c.uLiftAmt);
pm.setUniform('uLiftR', c.uLiftR);
pm.setUniform('uLiftG', c.uLiftG);
pm.setUniform('uLiftB', c.uLiftB);
pm.setUniform('uGrainAmt', c.uGrainAmt);
pm.setUniform('uGrainOffX', c.uGrainOffX);
pm.setUniform('uGrainOffY', c.uGrainOffY);
}
}
/** Register the grade node (idempotent). */
export function ensureGradeNode(renderer) {
return registerNode(renderer, GRADE_NODE, FilterGradeEffect);
}
// ---------------------------------------------------------------------------
// Facade
// ---------------------------------------------------------------------------
/**
* Register a render-node constructor (shared by ripple + grade).
*
* @param {object} renderer
* @param {string} name
* @param {Function} ctor
* @returns {boolean}
*/
function registerNode(renderer, name, ctor) {
const nodes = renderer?.renderNodes;
if (!nodes || typeof nodes.hasNode !== 'function') return false;
if (nodes.hasNode(name)) return true;
try {
nodes.addNodeConstructor(name, ctor);
return true;
} catch (err) {
console.warn(`[orbit] could not register the ${name} filter node: ${err}`);
return false;
}
}
/** Read a finite number from `src[key]`, else `fallback`. */
function num(src, key, fallback) {
const x = Number(src?.[key]);
return Number.isFinite(x) ? x : fallback;
}
/**
* The facade GameScene drives: apply the system type's effect bundle on
* entry, advance it per frame, release on exit. Holds NO scene-lifetime
* assumptions of its own (the scene's shutdown() calls release()).
*
* Attaches to the WORLD camera (each optional the type wears what it
* lists): the ripple (when the bundle has a `ripple`) and the grade (when
* it has a `grade`), both in this file. The content sub-effects
* (particles / wanderer) are GameScene's job.
*/
export class SystemEffects {
/** @param {object} scene Phaser scene */
constructor(scene) {
this.scene = scene;
this.controller = null;
this.controller = null; // ripple
this.grade = null; // grade
this.kind = null;
this.flare = null; // the shared flare params (or null)
this.phase = 0; // per-system 0..1
}
/** True while a filter is attached to the world camera. */
/** True while any filter is attached to the world camera. */
get active() {
return this.controller !== null;
return this.controller !== null || this.grade !== null;
}
/**
* Apply the system type's effect (data/systems.json
* types.<type>.effect). Replaces any current effect. No-op for
* `kind: "none"` / missing blocks and for non-WebGL renderers.
* Apply the system type's effect bundle (data/systems.json
* types.<type>.effect). Replaces any current effect. No-op for an empty
* bundle and for non-WebGL renderers (the content sub-effects are
* independent and still render).
*
* @param {string} systemType one of systems.types keys
* @returns {boolean} whether an effect ended up active
* @returns {boolean} whether a filter ended up active
*/
apply(systemType) {
this.release();
const eff = config.get(`systems.types.${systemType}.effect`, null);
const kind = eff?.kind ?? 'none';
if (kind === 'none' || !eff || typeof eff !== 'object') {
if (!eff || typeof eff !== 'object') {
this.kind = null;
return false;
}
if (kind !== 'ripple') {
// A family we have not implemented yet — degrade to none.
console.warn(`[orbit] system effect "${kind}" is not implemented yet — rendering none.`);
// Every sub-effect is optional — a type wears exactly what it lists
// (an empty {} bundle means no filter at all).
const ripple = eff.ripple;
const hasRipple = !!(ripple && Number(ripple.amplitude) > 0);
const hasGrade = !!eff.grade;
if (!hasRipple && !hasGrade) {
this.kind = null;
return false;
}
@ -194,73 +445,182 @@ export class SystemEffects {
const scene = this.scene;
const renderer = scene.renderer;
if (!renderer || !renderer.gl) {
// Canvas fallback: no GLSL, no composite — the system renders
// untouched rather than broken.
// Canvas fallback: no GLSL, no composite — the filters are skipped
// (the content sub-effects still render).
return false;
}
if (!ensureRippleNode(renderer)) return false;
// The UI camera split must exist before the first filtered pass:
// main draws the world (and the filter displaces it); fx-ui draws
// the HUD on top, unfiltered.
// main draws the world (and the filters act on it); fx-ui draws the
// HUD on top, unfiltered.
if (!ensureUiCameras(scene)) {
console.warn('[orbit] UI camera split failed — the effect would warp the HUD; rendering none.');
return false;
}
const cam = scene.cameras.main;
const c = new RippleController(cam);
c.strength = Number(eff.strength) || 90;
c.amplitude = Number(eff.amplitude) || 0.01;
c.speed = Number(eff.speed) || 1;
const pad = Math.max(4, Math.ceil(Number(eff.padding) || 16));
c.setPaddingOverride(-pad, -pad, pad, pad);
// Anchor (data-driven): "screen" (default) pins the center to the
// middle of the view — the rings are always visible while you fly.
// "star" tracks the system origin (world 0,0) — the wavefronts
// arrive from the star's (usually off-screen) direction.
c.center = eff.center === 'star' ? 'star' : 'screen';
if (c.center === 'screen') {
c.centerX = 0.5;
c.centerY = 0.5;
}
cam.filters.internal.add(c);
// Per-system variation (SystemGenerator stamps content.fx).
this.phase = Number(scene.systemContent?.fx?.phase) || 0;
this.flare = eff.flare && eff.flare.interval ? eff.flare : null;
this.controller = c;
this.kind = kind;
return true;
const cam = scene.cameras.main;
let attached = false;
// --- ripple (when the bundle has one) ---------------------------------
if (hasRipple && ensureRippleNode(renderer)) {
const c = new RippleController(cam);
c.strength = num(ripple, 'strength', 18);
c.baseAmp = num(ripple, 'amplitude', 0.004);
c.speed = num(ripple, 'speed', 1);
c.anchor = ripple.center === 'star' ? 'star' : 'screen';
c.centers = num(ripple, 'centers', 1) >= 2 ? 2 : 1;
c.orbit =
c.centers === 2 && ripple.orbit
? { radius: num(ripple.orbit, 'radius', 0.16), period: num(ripple.orbit, 'period', 36) }
: null;
c.flare = this.flare ? { rippleBoost: num(this.flare, 'rippleBoost', 0) } : null;
c.phase = this.phase;
// padding must cover the worst-case displacement: baseAmp, boosted by
// the flare, summed over the active centers, across the widest side.
const boost = this.flare ? 1 + num(this.flare, 'rippleBoost', 0) : 1;
const centers = c.centers;
const maxDim = Math.max(scene.scale.width, scene.scale.height);
const need = c.baseAmp * boost * centers * maxDim;
const pad = Math.max(4, Math.ceil(num(ripple, 'padding', 20)), Math.ceil(need));
c.setPaddingOverride(-pad, -pad, pad, pad);
// Initial center(s) (update() refines every frame).
if (c.centers === 2 && c.orbit) {
const o = orbitCenters(0, c.orbit.period, c.orbit.radius, c.phase);
c.cx0 = o.cx0; c.cy0 = o.cy0; c.cx1 = o.cx1; c.cy1 = o.cy1;
c.amp0 = c.baseAmp; c.amp1 = c.baseAmp;
} else if (c.anchor === 'star') {
const m = cam?.matrixCombined;
if (m && cam.width > 0 && cam.height > 0) {
const uv = worldToUV(m, cam.width, cam.height, 0, 0);
c.cx0 = uv.x; c.cy0 = uv.y;
} else {
c.cx0 = 0.5; c.cy0 = 0.5;
}
c.amp0 = c.baseAmp; c.amp1 = 0;
} else {
c.cx0 = 0.5; c.cy0 = 0.5; c.amp0 = c.baseAmp; c.amp1 = 0;
}
cam.filters.internal.add(c);
this.controller = c;
attached = true;
}
// --- grade (when the bundle has one) ---------------------------------
if (eff.grade && ensureGradeNode(renderer)) {
const g = new GradeController(cam);
const gc = eff.grade;
g.uBright = num(gc, 'brightness', 1);
g.uSat = num(gc, 'saturation', 1);
if (gc.tint) {
const [r, gr, b] = tintMultiplier(gc.tint);
g.uTintR = r; g.uTintG = gr; g.uTintB = b;
g.uTintAmt = num(gc, 'amount', 0.3);
}
// Shadow lift (the red void) — lift only the darkness toward a hue;
// and the gritty grain that gives the haze its texture (shader-only).
if (gc.lift) {
const [lr, lg, lb] = hexToRgb01(gc.lift.color ?? '#c0392b');
g.uLiftR = lr; g.uLiftG = lg; g.uLiftB = lb;
g.uLiftAmt = num(gc.lift, 'amount', 0.18);
}
if (gc.grain) {
g.uGrainAmt = num(gc.grain, 'amount', 0);
g.grainParallax = num(gc.grain, 'parallax', 0);
}
if (gc.split) {
const [ar, ag, ab] = tintMultiplier(gc.split.a ?? '#ffffff');
const [br, bg, bb] = tintMultiplier(gc.split.b ?? '#ffffff');
g.uSplitAR = ar; g.uSplitAG = ag; g.uSplitAB = ab;
g.uSplitBR = br; g.uSplitBG = bg; g.uSplitBB = bb;
g.uSplitMix = num(gc.split, 'mix', 0.15);
const deg = num(gc.split, 'axis', 0);
const a = (deg * Math.PI) / 180;
// seed the split axis per system (angle 0..1 → 0..2π) so each
// binary's two light sources come from its own direction.
const ax2 = Math.cos(a + this.phase * Math.PI * 2);
const ay2 = Math.sin(a + this.phase * Math.PI * 2);
g.uAxisX = ax2;
g.uAxisY = ay2;
}
if (this.flare) {
const [fr, fg, fb] = hexToRgb01(this.flare.flashColor ?? '#ffffff');
g.uFlashR = fr; g.uFlashG = fg; g.uFlashB = fb;
g.flareFlash = num(this.flare, 'flash', 0.3);
}
cam.filters.internal.add(g);
this.grade = g;
attached = true;
}
this.kind =
hasRipple && hasGrade ? 'ripple+grade' : hasRipple ? 'ripple' : 'grade';
return attached;
}
/**
* Per-frame advance: the wave phase, and for the "star" anchor
* the star's screen UV (the camera scrolls with the ship, so the
* center tracks it and the distortion stays pinned to the star).
* Screen-anchored effects only advance the phase.
* Per-frame advance (call with the game-loop time, ms):
* - the ripple phase, its center(s) (orbit, or the star's screen UV),
* and during a flare the amplitude surge;
* - the grade's flare flash (same shared flare signal).
*
* @param {number} nowMs game-loop time (ms)
*/
update(nowMs) {
// The shared flare (0..1), computed once for both filters.
const fl = this.flare;
const flareVal = fl
? flareIntensity(nowMs, fl.interval, fl.duration, this.phase * (Number(fl.interval) || 18))
: 0;
const c = this.controller;
if (!c) return;
c.time = ripplePhase(nowMs, c.speed);
if (c.center !== 'star') return;
const cam = this.scene.cameras.main;
const m = cam?.matrixCombined;
if (m && cam.width > 0 && cam.height > 0) {
const uv = worldToUV(m, cam.width, cam.height, 0, 0);
c.centerX = uv.x;
c.centerY = uv.y;
if (c) {
c.time = ripplePhase(nowMs, c.speed);
const amp = c.baseAmp * (1 + (c.flare?.rippleBoost ?? 0) * flareVal);
if (c.centers === 2 && c.orbit) {
const o = orbitCenters(nowMs, c.orbit.period, c.orbit.radius, c.phase);
c.cx0 = o.cx0; c.cy0 = o.cy0; c.cx1 = o.cx1; c.cy1 = o.cy1;
c.amp0 = amp; c.amp1 = amp;
} else {
if (c.anchor === 'star') {
const cam = this.scene.cameras.main;
const m = cam?.matrixCombined;
if (m && cam.width > 0 && cam.height > 0) {
const uv = worldToUV(m, cam.width, cam.height, 0, 0);
c.cx0 = uv.x; c.cy0 = uv.y;
}
}
c.amp0 = amp; c.amp1 = 0;
}
}
const g = this.grade;
if (g) {
g.uFlash = g.flareFlash * flareVal;
// Grain parallax: offset the grain by the camera scroll so it shifts
// slightly as you fly (sits at a depth, not on the lens).
if (g.grainParallax > 0) {
const cam = this.scene.cameras.main;
g.uGrainOffX = (cam?.scrollX ?? 0) * g.grainParallax;
g.uGrainOffY = (cam?.scrollY ?? 0) * g.grainParallax;
}
}
}
/** Detach and destroy the current effect (safe to call repeatedly). */
release() {
const c = this.controller;
if (!c) return;
for (const c of [this.controller, this.grade]) {
if (!c) continue;
const list = c?.camera?.filters?.internal;
if (list?.remove) list.remove(c);
else if (typeof c?.destroy === 'function') c.destroy();
}
this.controller = null;
this.grade = null;
this.kind = null;
const list = c?.camera?.filters?.internal;
if (list?.remove) list.remove(c);
else if (typeof c?.destroy === 'function') c.destroy();
this.flare = null;
}
}

View File

@ -68,3 +68,193 @@ export function worldToUV(m, width, height, wx = 0, wy = 0) {
export function ripplePhase(nowMs, speed = 1) {
return (nowMs / 1000) * (Number(speed) || 0);
}
// ---------------------------------------------------------------------------
// Multi-center ripple, flares, and color grade — pure, Node-testable.
// ---------------------------------------------------------------------------
/**
* A star's FLARE intensity at a moment: 0 (quiet) .. 1 (peak), mostly 0
* with a smooth periodic surge. A raised-cosine bell (010) over
* `durationSec`, repeating every `intervalSec`, shifted by a per-system
* `phaseSec` so each system has its own flare rhythm. Deterministic (no
* Math.random) so the same system flares the same way every run.
*
* @param {number} nowMs game clock (ms)
* @param {number} intervalSec seconds between flares
* @param {number} durationSec seconds a flare lasts
* @param {number} [phaseSec=0] per-system phase offset (seconds)
* @returns {number} 0..1
*/
export function flareIntensity(nowMs, intervalSec, durationSec, phaseSec = 0) {
const period = Math.max(0.5, Number(intervalSec) || 18);
const dur = Math.max(0.2, Number(durationSec) || 1.5);
const t = nowMs / 1000 - (Number(phaseSec) || 0);
const u = ((t % period) + period) % period; // local phase, 0..period
if (u >= dur) return 0; // quiet
const x = u / dur; // 0..1 through the flare
return 0.5 * (1 - Math.cos(2 * Math.PI * x)); // 0 → 1 → 0, smooth
}
/**
* The two ORBITING shimmer centers (binary) at a moment: two points,
* diametrically opposed, circling the view's middle (0.5,0.5) at radius
* `radius` (in UV), one full lap every `periodSec`, seeded by `phase`
* (0..1 starting angle). Returns both centers' UVs.
*
* @param {number} nowMs game clock (ms)
* @param {number} periodSec seconds per orbit lap
* @param {number} radius orbit radius in UV (fraction of screen)
* @param {number} [phase=0] per-system phase (0..1)
* @returns {{cx0:number,cy0:number,cx1:number,cy1:number}}
*/
export function orbitCenters(nowMs, periodSec, radius, phase = 0) {
const period = Math.max(2, Number(periodSec) || 36);
const r = Math.max(0, Number(radius) || 0);
const a = 2 * Math.PI * ((nowMs / 1000) / period + (Number(phase) || 0));
const dx = r * Math.cos(a);
const dy = r * Math.sin(a);
return { cx0: 0.5 + dx, cy0: 0.5 + dy, cx1: 0.5 - dx, cy1: 0.5 - dy };
}
// --- Color grade (pure; the shader does the same thing per-pixel) ---------
/**
* Parse a CSS hex color ('#rrggbb' or 'rrggbb') to [r,g,b] in 0..1.
* NO Phaser (Node-testable). Bad input white.
*
* @param {string} hex
* @returns {[number, number, number]}
*/
export function hexToRgb01(hex) {
const m = String(hex).trim().match(/^#?([0-9a-f]{6})$/i);
if (!m) return [1, 1, 1];
const n = parseInt(m[1], 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
/**
* SHADOW LIFT push the DARK part of a color toward a lift hue (the red
* void behind a redDwarf's stars), leaving bright pixels essentially
* untouched. Luminance-gated: the darker the pixel, the more it lifts, so
* near-black space becomes a thin red haze while the white stars still pop.
* Mirrors the grade shader's lift step exactly (the shader's `grain` is a
* per-pixel spatial effect and lives there only).
*
* @param {[number,number,number]} rgb 0..1
* @param {[number,number,number]} liftColor 0..1 (the hue to lift toward)
* @param {number} amount 0..1 (strength)
* @param {number} [cutoff=3] luma multiplier (higher = tighter to the blacks)
* @returns {[number,number,number]} lifted color (0..~1+)
*/
export function shadowLift(rgb, liftColor, amount, cutoff = 3) {
const a = Number(amount) || 0;
if (a <= 0 || !Array.isArray(liftColor)) return [rgb[0] ?? 0, rgb[1] ?? 0, rgb[2] ?? 0];
const r = rgb[0] ?? 0;
const g = rgb[1] ?? 0;
const b = rgb[2] ?? 0;
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
const dark = (1 - Math.min(1, Math.max(0, lum * (Number(cutoff) || 3)))) * a;
return [r + liftColor[0] * dark, g + liftColor[1] * dark, b + liftColor[2] * dark];
}
/**
* A tint color as a MULTIPLIER vec3, normalized so its average channel is
* 1.0 i.e. a pure hue shift that neither brightens nor darkens on its
* own (the grade's `brightness` handles that separately). White [1,1,1].
*
* @param {string} hex
* @returns {[number, number, number]}
*/
export function tintMultiplier(hex) {
const [r, g, b] = hexToRgb01(hex);
const avg = (r + g + b) / 3 || 1;
return [r / avg, g / avg, b / avg];
}
/** Safe numeric read: `Number(v)` if finite, else the default. */
function num(v, d) {
const x = Number(v);
return Number.isFinite(x) ? x : d;
}
/**
* Apply the full grade to an [r,g,b] color (0..1) the exact per-pixel
* math the grade shader runs, factored out so it is testable and the
* shader stays a thin wrapper. Order: brightness saturation tint
* directional split flare flash.
*
* `grade` shape (all optional):
* bright (0..1+, default 1) brightness multiplier
* sat (0..1+, default 1) saturation (1 = none, <1 desaturates)
* tint ([r,g,b] 0..1) hue multiplier (see tintMultiplier)
* tintAmt(0..1, default 0) how strongly the tint is applied
* liftColor ([r,g,b] 0..1) shadow-lift hue (the red void; redDwarf)
* liftAmt (0..1, default 0) shadow-lift strength
* split ({ a:[r,g,b], b:[r,g,b], axis:[x,y], mix:0..1 }) two-color
* directional wash (binary)
* flash (0..1) flare flash intensity
* flashColor ([r,g,b]) the flash's color
*
* @param {[number,number,number]} rgb input color
* @param {object} grade grade parameters
* @returns {[number,number,number]}
*/
export function gradeColor(rgb, grade = {}) {
let r = rgb[0] ?? 1;
let g = rgb[1] ?? 1;
let b = rgb[2] ?? 1;
// 1. brightness
const bright = num(grade.bright, 1);
r *= bright; g *= bright; b *= bright;
// 2. saturation (mix toward luma)
const sat = num(grade.sat, 1);
if (sat !== 1) {
const l = 0.299 * r + 0.587 * g + 0.114 * b;
r = l + (r - l) * sat; g = l + (g - l) * sat; b = l + (b - l) * sat;
}
// 3. tint (hue multiplier, blended by amount)
const amt = num(grade.tintAmt, 0);
if (amt > 0 && Array.isArray(grade.tint)) {
r = r + (r * grade.tint[0] - r) * amt;
g = g + (g * grade.tint[1] - g) * amt;
b = b + (b * grade.tint[2] - b) * amt;
}
// 3b. shadow lift (the red void — only the darkness moves)
const liftAmt = num(grade.liftAmt, 0);
if (liftAmt > 0 && Array.isArray(grade.liftColor)) {
[r, g, b] = shadowLift([r, g, b], grade.liftColor, liftAmt);
}
// 4. directional split (two-color wash across the screen — binary)
const sp = grade.split;
if (sp && num(sp.mix, 0) > 0) {
const px = grade.pos?.[0] ?? 0.5;
const py = grade.pos?.[1] ?? 0.5;
const ax = sp.axis?.[0] ?? 1;
const ay = sp.axis?.[1] ?? 0;
const alen = Math.hypot(ax, ay) || 1;
let s = ((px - 0.5) * (ax / alen) + (py - 0.5) * (ay / alen)) * 2;
s = Math.min(1, Math.max(0, s * 0.5 + 0.5));
const m = num(sp.mix, 0);
const wa = sp.a ?? [1, 1, 1];
const wb = sp.b ?? [1, 1, 1];
r = r + (r * (wb[0] + (wa[0] - wb[0]) * s) - r) * m;
g = g + (g * (wb[1] + (wa[1] - wb[1]) * s) - g) * m;
b = b + (b * (wb[2] + (wa[2] - wb[2]) * s) - b) * m;
}
// 5. flare flash (additive warm spike)
const fl = num(grade.flash, 0);
if (fl > 0 && Array.isArray(grade.flashColor)) {
r += grade.flashColor[0] * fl;
g += grade.flashColor[1] * fl;
b += grade.flashColor[2] * fl;
}
return [r, g, b];
}

141
js/visuals/WandererStar.js Normal file
View File

@ -0,0 +1,141 @@
/**
* binary companion star a distant "other star" wandering slowly.
*
* Content layer (two world-space sprites: a bright core + a soft halo),
* NOT a filter: Canvas-safe and immune to the HUD. It sits FAR away
* (very low parallax ~0.08, so it barely moves as you fly a distant
* light source) and drifts along a slow elliptical arc, seeded per system
* so each binary's companion wanders its own way. Both sprites use
* additive blend it is a source of light, so it glows, never occludes.
* Depth 3 keeps it behind the art (planets 5, ship 10).
*
* Config: data/systems.json types.binary.effect.wanderer.
*/
import Phaser from '../vendor/phaser.js';
import { canvasTexture } from '../utils/Textures.js';
import { toColor } from '../utils/Color.js';
const DEPTH = 3; // behind the art, in front of the starfield
/** A tight, bright core — the star's body. White (tint gives the color). */
function drawCore(ctx, size) {
const c = size / 2;
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
g.addColorStop(0.5, 'rgba(255,255,255,0.9)');
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
}
/** A wide, soft halo — the star's corona. White (tint gives the color). */
function drawHalo(ctx, size) {
const c = size / 2;
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
g.addColorStop(0.0, 'rgba(255,255,255,0.55)');
g.addColorStop(0.4, 'rgba(255,255,255,0.28)');
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
}
/**
* The companion star (core + halo) wandering a slow seeded arc.
*
* @param {object} scene Phaser scene
* @param {string} color star color (CSS hex)
* @param {object} cfg data/systems.json effect.wanderer
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
*/
export class WandererStar {
constructor(scene, color, cfg, fx = {}) {
this.scene = scene;
this.color = toColor(color, 0x9fd0ff);
this.cfg = cfg ?? {};
this.fx = fx ?? {};
this.core = null;
this.halo = null;
this.tSec = 0;
}
/** Build the core + halo (idempotent). */
create() {
const scene = this.scene;
if (this.core) return;
const {
coreSize = [40, 70],
haloSize = [120, 200],
parallax = 0.08,
speed = [3, 8],
} = this.cfg;
const coreKey = canvasTexture(scene, 'fx-wanderer-core', 64, 64, (ctx) => drawCore(ctx, 64));
const haloKey = canvasTexture(scene, 'fx-wanderer-halo', 128, 128, (ctx) => drawHalo(ctx, 128));
// Seeded starting point (upper third of the view — a star in the sky)
// and a slow elliptical arc (the "wander").
const fx = this.fx;
const phase = fx.phase ?? 0.5;
const angle = fx.angle ?? 0.5;
const drift = fx.drift ?? 0.5;
const w = scene.scale.width;
const h = scene.scale.height;
const baseX = w * (0.2 + 0.6 * phase);
const baseY = h * (0.15 + 0.3 * angle);
const ampX = w * (0.05 + 0.12 * drift);
const ampY = h * (0.04 + 0.10 * drift);
const omega = ((speed[0] + (speed[1] - speed[0]) * drift) / w) * Math.PI * 2; // rad/s (slow)
this.core = scene.add
.image(baseX, baseY, coreKey)
.setDepth(DEPTH)
.setTint(this.color)
.setAlpha(0.95)
.setBlendMode(Phaser.BlendModes.ADD);
const cs = coreSize[0] + (coreSize[1] - coreSize[0]) * drift;
this.core.setScale(cs / 64);
this.halo = scene.add
.image(baseX, baseY, haloKey)
.setDepth(DEPTH)
.setTint(this.color)
.setAlpha(0.8)
.setBlendMode(Phaser.BlendModes.ADD);
const hs = haloSize[0] + (haloSize[1] - haloSize[0]) * drift;
this.halo.setScale(hs / 128);
this.baseX = baseX;
this.baseY = baseY;
this.ampX = ampX;
this.ampY = ampY;
this.omega = omega;
this.phase0 = angle * Math.PI * 2;
this.p = parallax;
}
/** Per-frame: slow elliptical wander + camera parallax. */
update(nowMs, dtMs) {
if (!this.core) return;
this.tSec += (dtMs || 0) / 1000;
const t = this.tSec;
const a = this.omega * t + this.phase0;
let x = this.baseX + this.ampX * Math.cos(a);
let y = this.baseY + this.ampY * Math.sin(a);
// very low parallax (distant): shifts a little as the camera flies
const cam = this.scene.cameras.main;
const camX = cam?.worldView?.x ?? 0;
const camY = cam?.worldView?.y ?? 0;
x += (camX - this.scene.scale.width / 2) * this.p;
y += (camY - this.scene.scale.height / 2) * this.p;
this.core.setPosition(x, y);
this.halo.setPosition(x, y);
}
/** Tear down. */
destroy() {
this.core?.destroy();
this.halo?.destroy();
this.core = null;
this.halo = null;
}
}