orbit/js/visuals/SystemEffectsMath.js

261 lines
9.9 KiB
JavaScript

/**
* 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 };
}
// --- 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];
}