627 lines
23 KiB
JavaScript
627 lines
23 KiB
JavaScript
/**
|
|
* Per-system visual effects — the star's "character".
|
|
*
|
|
* An effect is a BUNDLE of sub-effects, all data-driven off
|
|
* data/systems.json → types.<type>.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.<type>.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;
|
|
}
|
|
}
|