267 lines
9.9 KiB
JavaScript
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();
|
|
}
|
|
}
|