orbit/js/visuals/SystemEffects.js

267 lines
9.9 KiB
JavaScript

/**
* 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.<id>.effect:
* "effect": {
* "kind": "ripple", // effect family (today: "ripple" | "none")
* "strength": 90, // wave cycles across the screen (1/UV)
* "amplitude": 0.010, // displacement, in UV (fraction of screen)
* "speed": 1.0, // phase advance, radians/second
* "padding": 16 // px of extra framebuffer the filter may sample
* }
* `kind: "none"` (or a missing block) renders the system untouched —
* the common case, zero cost (no filter attached, no extra pass).
*
* The 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.<type>.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();
}
}