orbit/js/visuals/SystemEffectsMath.js

317 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Pure math for the per-system visual effects — NO Phaser import, so the
* dev harness (dev/system-effects.test.mjs) can run it in bare Node.
*
* The system effect is a full-screen composite over the WORLD camera
* (see SystemEffects.js): a fragment shader displaces the camera's
* screen-space UVs around the system's star. The star is never rendered
* (it is invisible flavor — data/systems.json), but it sits at the
* system origin (world 0,0), so the ripple center is "where world (0,0)
* projects to, in the camera's UV space".
*
* A camera's `matrixCombined` is the world->screen affine transform
* (a·x + c·y + tx, b·x + d·y + ty). Dividing the screen point by the
* camera's viewport size gives the UV: (0,0) = the camera's top-left,
* (1,1) = its bottom-right — exactly the space the shader's
* `outTexCoord` lives in.
*/
/**
* @typedef {{ a: number, b: number, c: number, d: number, tx: number, ty: number }} Affine2D
*
* Affine2D — the shape of a Phaser camera's matrixCombined (the
* world->screen transform). Duck-typed on purpose: the tests pass plain
* objects, the game passes the real matrix.
*/
/**
* Screen-space point of a world point under an affine transform.
*
* @param {Affine2D} m world->screen transform
* @param {number} [wx=0] world x
* @param {number} [wy=0] world y
* @returns {{ x: number, y: number }} screen point (camera viewport px)
*/
export function worldToScreen(m, wx = 0, wy = 0) {
return {
x: m.a * wx + m.c * wy + m.tx,
y: m.b * wx + m.d * wy + m.ty,
};
}
/**
* UV coordinate of a world point for a camera of the given viewport size
* (0,0 top-left -> 1,1 bottom-right).
*
* @param {Affine2D} m world->screen transform
* @param {number} width camera viewport width (px)
* @param {number} height camera viewport height (px)
* @param {number} [wx=0] world x
* @param {number} [wy=0] world y
* @returns {{ x: number, y: number }} UV
*/
export function worldToUV(m, width, height, wx = 0, wy = 0) {
const p = worldToScreen(m, wx, wy);
return { x: p.x / width, y: p.y / height };
}
/**
* The wave's phase (radians) at a moment — the shader's `time` uniform.
* Monotonic in the game clock, scaled by the configured speed, so the
* ripple keeps its pace across system entries without any per-frame
* accumulation to drift.
*
* @param {number} nowMs game-loop time (ms, monotonic)
* @param {number} [speed=1] angular speed multiplier (radians/second)
* @returns {number} phase, radians
*/
export function ripplePhase(nowMs, speed = 1) {
return (nowMs / 1000) * (Number(speed) || 0);
}
// ---------------------------------------------------------------------------
// Multi-center ripple, flares, and color grade — pure, Node-testable.
// ---------------------------------------------------------------------------
/**
* A star's FLARE intensity at a moment: 0 (quiet) .. 1 (peak), mostly 0
* with a smooth periodic surge. A raised-cosine bell (0→1→0) over
* `durationSec`, repeating every `intervalSec`, shifted by a per-system
* `phaseSec` so each system has its own flare rhythm. Deterministic (no
* Math.random) so the same system flares the same way every run.
*
* @param {number} nowMs game clock (ms)
* @param {number} intervalSec seconds between flares
* @param {number} durationSec seconds a flare lasts
* @param {number} [phaseSec=0] per-system phase offset (seconds)
* @returns {number} 0..1
*/
export function flareIntensity(nowMs, intervalSec, durationSec, phaseSec = 0) {
const period = Math.max(0.5, Number(intervalSec) || 18);
const dur = Math.max(0.2, Number(durationSec) || 1.5);
const t = nowMs / 1000 - (Number(phaseSec) || 0);
const u = ((t % period) + period) % period; // local phase, 0..period
if (u >= dur) return 0; // quiet
const x = u / dur; // 0..1 through the flare
return 0.5 * (1 - Math.cos(2 * Math.PI * x)); // 0 → 1 → 0, smooth
}
/**
* The two ORBITING shimmer centers (binary) at a moment: two points,
* diametrically opposed, circling the view's middle (0.5,0.5) at radius
* `radius` (in UV), one full lap every `periodSec`, seeded by `phase`
* (0..1 → starting angle). Returns both centers' UVs.
*
* @param {number} nowMs game clock (ms)
* @param {number} periodSec seconds per orbit lap
* @param {number} radius orbit radius in UV (fraction of screen)
* @param {number} [phase=0] per-system phase (0..1)
* @returns {{cx0:number,cy0:number,cx1:number,cy1:number}}
*/
export function orbitCenters(nowMs, periodSec, radius, phase = 0) {
const period = Math.max(2, Number(periodSec) || 36);
const r = Math.max(0, Number(radius) || 0);
const a = 2 * Math.PI * ((nowMs / 1000) / period + (Number(phase) || 0));
const dx = r * Math.cos(a);
const dy = r * Math.sin(a);
return { cx0: 0.5 + dx, cy0: 0.5 + dy, cx1: 0.5 - dx, cy1: 0.5 - dy };
}
/**
* A smoothstep on a 0..1 domain (mirrors the shader's).
*/
function sstep01(x) {
const t = Math.min(1, Math.max(0, x));
return t * t * (3 - 2 * t);
}
/**
* The HABITABLE storm's envelope at a moment: `{ charge, flash }`, both 0..1.
*
* The cycle (per cluster, seeded by its phase):
* CHARGE — a smooth rise over the last `chargeWindow` fraction of the
* cycle (the clouds gathering light before the strike);
* STRIKE — at the cycle boundary: a rapid flicker (real lightning
* strobes — a bright burst plus two decaying rebounds) and a
* soft afterglow that decays into the next cycle's quiet.
* Deterministic (no Math.random), so the same system storms the same way
* every run. The strike sits at the cycle boundary (t ≡ phase mod period).
*
* @param {number} nowMs game clock (ms)
* @param {number} intervalSec seconds per storm cycle
* @param {number} strikeDurSec the flicker's span (seconds)
* @param {number} [phaseSec=0] per-cluster phase offset (seconds)
* @param {number} [chargeWindow=0.45] fraction of the cycle over which charge rises
* @returns {{charge:number, flash:number}} both 0..1
*/
export function stormEnvelope(nowMs, intervalSec, strikeDurSec, phaseSec = 0, chargeWindow = 0.45) {
const period = Math.max(2, Number(intervalSec) || 12);
const W = Math.max(0.15, Number(strikeDurSec) || 0.5);
const cw = Math.min(1, Math.max(0.1, Number(chargeWindow) || 0.45));
const t = nowMs / 1000 - (Number(phaseSec) || 0);
const tLocal = ((t % period) + period) % period; // 0..period; strike at tLocal === 0 (≡ period)
// CHARGE: 0 early in the cycle, rising smoothly to 1 at the strike.
const u = tLocal / period;
const charge = sstep01((u - (1 - cw)) / cw);
// FLASH: time relative to the strike (negative = before it). The
// afterglow is clipped at 3× the flicker span (it is negligible there —
// a hard 0 keeps the envelope exact instead of an asymptote).
const dt = tLocal <= period / 2 ? tLocal : tLocal - period; // (-period/2, period/2]
let flash = 0;
if (dt >= 0 && dt <= 3 * W) {
const gauss = (x, sigma) => Math.exp(-(x * x) / (2 * sigma * sigma));
// A bright primary burst + two decaying rebounds (the strobe) +
// a soft afterglow (light that lingers in the cloud).
const b1 = 1.0 * gauss(dt, 0.12 * W);
const b2 = 0.55 * gauss(dt - 0.45 * W, 0.1 * W);
const b3 = 0.35 * gauss(dt - 0.85 * W, 0.09 * W);
const afterglow = 0.22 * Math.exp(-dt / (0.9 * W));
flash = Math.min(1, b1 + b2 + b3 + afterglow);
}
return { charge, flash };
}
// --- Color grade (pure; the shader does the same thing per-pixel) ---------
/**
* Parse a CSS hex color ('#rrggbb' or 'rrggbb') to [r,g,b] in 0..1.
* NO Phaser (Node-testable). Bad input → white.
*
* @param {string} hex
* @returns {[number, number, number]}
*/
export function hexToRgb01(hex) {
const m = String(hex).trim().match(/^#?([0-9a-f]{6})$/i);
if (!m) return [1, 1, 1];
const n = parseInt(m[1], 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
/**
* SHADOW LIFT — push the DARK part of a color toward a lift hue (the red
* void behind a redDwarf's stars), leaving bright pixels essentially
* untouched. Luminance-gated: the darker the pixel, the more it lifts, so
* near-black space becomes a thin red haze while the white stars still pop.
* Mirrors the grade shader's lift step exactly (the shader's `grain` is a
* per-pixel spatial effect and lives there only).
*
* @param {[number,number,number]} rgb 0..1
* @param {[number,number,number]} liftColor 0..1 (the hue to lift toward)
* @param {number} amount 0..1 (strength)
* @param {number} [cutoff=3] luma multiplier (higher = tighter to the blacks)
* @returns {[number,number,number]} lifted color (0..~1+)
*/
export function shadowLift(rgb, liftColor, amount, cutoff = 3) {
const a = Number(amount) || 0;
if (a <= 0 || !Array.isArray(liftColor)) return [rgb[0] ?? 0, rgb[1] ?? 0, rgb[2] ?? 0];
const r = rgb[0] ?? 0;
const g = rgb[1] ?? 0;
const b = rgb[2] ?? 0;
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
const dark = (1 - Math.min(1, Math.max(0, lum * (Number(cutoff) || 3)))) * a;
return [r + liftColor[0] * dark, g + liftColor[1] * dark, b + liftColor[2] * dark];
}
/**
* A tint color as a MULTIPLIER vec3, normalized so its average channel is
* 1.0 — i.e. a pure hue shift that neither brightens nor darkens on its
* own (the grade's `brightness` handles that separately). White → [1,1,1].
*
* @param {string} hex
* @returns {[number, number, number]}
*/
export function tintMultiplier(hex) {
const [r, g, b] = hexToRgb01(hex);
const avg = (r + g + b) / 3 || 1;
return [r / avg, g / avg, b / avg];
}
/** Safe numeric read: `Number(v)` if finite, else the default. */
function num(v, d) {
const x = Number(v);
return Number.isFinite(x) ? x : d;
}
/**
* Apply the full grade to an [r,g,b] color (0..1) — the exact per-pixel
* math the grade shader runs, factored out so it is testable and the
* shader stays a thin wrapper. Order: brightness → saturation → tint →
* directional split → flare flash.
*
* `grade` shape (all optional):
* bright (0..1+, default 1) — brightness multiplier
* sat (0..1+, default 1) — saturation (1 = none, <1 desaturates)
* tint ([r,g,b] 0..1) — hue multiplier (see tintMultiplier)
* tintAmt(0..1, default 0) — how strongly the tint is applied
* liftColor ([r,g,b] 0..1) — shadow-lift hue (the red void; redDwarf)
* liftAmt (0..1, default 0) — shadow-lift strength
* split ({ a:[r,g,b], b:[r,g,b], axis:[x,y], mix:0..1 }) — two-color
* directional wash (binary)
* flash (0..1) — flare flash intensity
* flashColor ([r,g,b]) — the flash's color
*
* @param {[number,number,number]} rgb input color
* @param {object} grade grade parameters
* @returns {[number,number,number]}
*/
export function gradeColor(rgb, grade = {}) {
let r = rgb[0] ?? 1;
let g = rgb[1] ?? 1;
let b = rgb[2] ?? 1;
// 1. brightness
const bright = num(grade.bright, 1);
r *= bright; g *= bright; b *= bright;
// 2. saturation (mix toward luma)
const sat = num(grade.sat, 1);
if (sat !== 1) {
const l = 0.299 * r + 0.587 * g + 0.114 * b;
r = l + (r - l) * sat; g = l + (g - l) * sat; b = l + (b - l) * sat;
}
// 3. tint (hue multiplier, blended by amount)
const amt = num(grade.tintAmt, 0);
if (amt > 0 && Array.isArray(grade.tint)) {
r = r + (r * grade.tint[0] - r) * amt;
g = g + (g * grade.tint[1] - g) * amt;
b = b + (b * grade.tint[2] - b) * amt;
}
// 3b. shadow lift (the red void — only the darkness moves)
const liftAmt = num(grade.liftAmt, 0);
if (liftAmt > 0 && Array.isArray(grade.liftColor)) {
[r, g, b] = shadowLift([r, g, b], grade.liftColor, liftAmt);
}
// 4. directional split (two-color wash across the screen — binary)
const sp = grade.split;
if (sp && num(sp.mix, 0) > 0) {
const px = grade.pos?.[0] ?? 0.5;
const py = grade.pos?.[1] ?? 0.5;
const ax = sp.axis?.[0] ?? 1;
const ay = sp.axis?.[1] ?? 0;
const alen = Math.hypot(ax, ay) || 1;
let s = ((px - 0.5) * (ax / alen) + (py - 0.5) * (ay / alen)) * 2;
s = Math.min(1, Math.max(0, s * 0.5 + 0.5));
const m = num(sp.mix, 0);
const wa = sp.a ?? [1, 1, 1];
const wb = sp.b ?? [1, 1, 1];
r = r + (r * (wb[0] + (wa[0] - wb[0]) * s) - r) * m;
g = g + (g * (wb[1] + (wa[1] - wb[1]) * s) - g) * m;
b = b + (b * (wb[2] + (wa[2] - wb[2]) * s) - b) * m;
}
// 5. flare flash (additive warm spike)
const fl = num(grade.flash, 0);
if (fl > 0 && Array.isArray(grade.flashColor)) {
r += grade.flashColor[0] * fl;
g += grade.flashColor[1] * fl;
b += grade.flashColor[2] * fl;
}
return [r, g, b];
}