/** * Per-system visual effects — the star's "character", rendered as a * full-screen composite over the WORLD camera. * * 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, * 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 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. * * 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 { ensureUiCameras } from './UiCameras.js'; /** Registered render-node name for the ripple effect (renderer-global). */ export const RIPPLE_NODE = 'FilterRippleEffect'; /** * 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. */ const RIPPLE_FRAGMENT = [ '#pragma phaserTemplate(shaderName)', 'precision mediump float;', 'uniform sampler2D uMainSampler;', 'uniform float time;', 'uniform float strength;', 'uniform float amplitude;', 'uniform float centerX;', 'uniform float centerY;', '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);', '}', ].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. * * @extends {Phaser.Filters.Controller} */ class RippleController extends Phaser.Filters.Controller { /** @param {object} camera the world camera */ constructor(camera) { super(camera, RIPPLE_NODE); this.time = 0; this.strength = 90; this.amplitude = 0.01; this.speed = 1; this.centerX = 0.5; this.centerY = 0.5; } } /** * 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. * * @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('amplitude', controller.amplitude); pm.setUniform('centerX', controller.centerX); pm.setUniform('centerY', controller.centerY); } } /** * Register the ripple node with the renderer (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; } } /** * 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()). */ export class SystemEffects { /** @param {object} scene Phaser scene */ constructor(scene) { this.scene = scene; this.controller = null; this.kind = null; } /** True while a filter is attached to the world camera. */ get active() { return this.controller !== 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. * * @param {string} systemType one of systems.types keys * @returns {boolean} whether an effect 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') { 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.`); this.kind = null; return false; } 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. 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. 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); this.controller = c; this.kind = kind; return true; } /** * 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. * * @param {number} nowMs game-loop time (ms) */ update(nowMs) { 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; } } /** Detach and destroy the current effect (safe to call repeatedly). */ release() { const c = this.controller; if (!c) return; this.controller = null; this.kind = null; const list = c?.camera?.filters?.internal; if (list?.remove) list.remove(c); else if (typeof c?.destroy === 'function') c.destroy(); } }