diff --git a/assets/videos/ice-land-01.mp4 b/assets/videos/ice-land-01.mp4 new file mode 100644 index 0000000..90ab7fb Binary files /dev/null and b/assets/videos/ice-land-01.mp4 differ diff --git a/assets/videos/ice-land-02.mp4 b/assets/videos/ice-land-02.mp4 new file mode 100644 index 0000000..363c32a Binary files /dev/null and b/assets/videos/ice-land-02.mp4 differ diff --git a/assets/videos/ice-takeoff-01.mp4 b/assets/videos/ice-takeoff-01.mp4 new file mode 100644 index 0000000..b1efb30 Binary files /dev/null and b/assets/videos/ice-takeoff-01.mp4 differ diff --git a/assets/videos/ice-takeoff-02.mp4 b/assets/videos/ice-takeoff-02.mp4 new file mode 100644 index 0000000..ad0f668 Binary files /dev/null and b/assets/videos/ice-takeoff-02.mp4 differ diff --git a/data/landing.json b/data/landing.json index b3b2be0..88fb490 100644 --- a/data/landing.json +++ b/data/landing.json @@ -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" } ], diff --git a/data/systems.json b/data/systems.json index 6f2c2c4..e8c15b0 100644 --- a/data/systems.json +++ b/data/systems.json @@ -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 1–2 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 }, diff --git a/dev/system-effects.test.mjs b/dev/system-effects.test.mjs index d4f7ccf..9318e57 100644 --- a/dev/system-effects.test.mjs +++ b/dev/system-effects.test.mjs @@ -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 0→1→0 bell; the binary orbit centers are + * diametrically opposed on a seeded circle; the grade color chain + * (brightness → saturation → tint → split → flash) is exact and + * identity when the grade is empty; + * - the DATA CONTRACT (data/systems.json): every type carries an + * `effect` block; nebula/redDwarf/binary are live (kind "ripple"); + * the redDwarf bundle has a flare + grade + particles; the binary + * bundle has two centers + orbit + a split grade + a wanderer; padding + * covers the worst-case displacement (amplitude × flare boost × + * centers × width); * - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the * requested type wins, ties fall to roster order, missing type => null; - * - the SHADER CONTRACT (js/visuals/SystemEffects.js, 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)') ); })()); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 97df4b4..b49d720 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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..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..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` (1–2 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=` (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 (0–2), 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..effect`. +- **Demo:** `?fx=` (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 diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index 75db9e0..34ac54a 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -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 (1–3; [] 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) diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index c66215c..e7e64e6 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(); diff --git a/js/visuals/EmberField.js b/js/visuals/EmberField.js new file mode 100644 index 0000000..624954c --- /dev/null +++ b/js/visuals/EmberField.js @@ -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.15–0.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 = []; + } +} diff --git a/js/visuals/RedHaze.js b/js/visuals/RedHaze.js new file mode 100644 index 0000000..6e1483d --- /dev/null +++ b/js/visuals/RedHaze.js @@ -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 0–2), + * 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 (0–2) — 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; + } +} diff --git a/js/visuals/SystemEffects.js b/js/visuals/SystemEffects.js index 26e30bd..f2d09b7 100644 --- a/js/visuals/SystemEffects.js +++ b/js/visuals/SystemEffects.js @@ -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..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..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..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..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; } } diff --git a/js/visuals/SystemEffectsMath.js b/js/visuals/SystemEffectsMath.js index f28758c..8f9685a 100644 --- a/js/visuals/SystemEffectsMath.js +++ b/js/visuals/SystemEffectsMath.js @@ -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 (0→1→0) 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]; +} diff --git a/js/visuals/WandererStar.js b/js/visuals/WandererStar.js new file mode 100644 index 0000000..c94369a --- /dev/null +++ b/js/visuals/WandererStar.js @@ -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; + } +}