/** * 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 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 "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. * * 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, flareIntensity, orbitCenters, hexToRgb01, tintMultiplier, } from './SystemEffectsMath.js'; import { ensureUiCameras } from './UiCameras.js'; /** Registered render-node names (renderer-global). */ export const RIPPLE_NODE = 'FilterRippleEffect'; export const GRADE_NODE = 'FilterGradeEffect'; /** * 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)', 'precision mediump float;', 'uniform sampler2D uMainSampler;', 'uniform float time;', 'uniform float strength;', '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 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 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} */ class RippleController extends Phaser.Filters.Controller { /** @param {object} camera the world camera */ constructor(camera) { super(camera, RIPPLE_NODE); // uniforms this.time = 0; 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.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 (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} */ class FilterRippleEffect extends Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader { /** @param {object} manager the RenderNodes manager */ constructor(manager) { super(RIPPLE_NODE, manager, null, RIPPLE_FRAGMENT); } /** Push this pass's controller values into the shader. */ setupUniforms(controller, _drawingContext) { const pm = this.programManager; pm.setUniform('time', controller.time); pm.setUniform('strength', controller.strength); 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 (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) { 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 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; // 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 any filter is attached to the world camera. */ get active() { return this.controller !== null || this.grade !== null; } /** * 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 a filter ended up active */ apply(systemType) { this.release(); const eff = config.get(`systems.types.${systemType}.effect`, null); if (!eff || typeof eff !== 'object') { this.kind = null; return false; } // 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; } const scene = this.scene; const renderer = scene.renderer; if (!renderer || !renderer.gl) { // Canvas fallback: no GLSL, no composite — the filters are skipped // (the content sub-effects still render). return false; } // The UI camera split must exist before the first filtered pass: // 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; } // 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; 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 (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) { 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() { 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; this.flare = null; } }