317 lines
12 KiB
JavaScript
317 lines
12 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 };
|
||
}
|
||
|
||
/**
|
||
* 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];
|
||
}
|