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