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