Expand system effects to a composable bundle of sub-effects
- Replace the single `kind: ripple|none` effect with optional per-type blocks (ripple, grade, flare, haze, particles, wanderer) so each archetype wears exactly what it lists - Add redDwarf visual identity: super-subtle star-anchored ripple that surges on a seeded flare, warm color grade with shadow lift + grain, a gritty red haze behind the stars, and drifting ember dust - Add binary visual identity: two-color directional split grade plus a wandering companion star (no ripple) - Generalize the ripple shader/filter to up to two orbiting centers and drive both the ripple amplitude and the grade flash from one shared flare signal - Stamp per-system fx variation (phase/angle/drift) in SystemGenerator so each system's flare rhythm, split axis, and wanderer path are stable but distinct - Add EmberField, RedHaze, WandererStar content layers wired into GameScene create/update/shutdown - Update the data contract, 57-check test suite, project notes, and landing/systems JSON; add ice landing/takeoff video assets
This commit is contained in:
parent
125b1657ed
commit
627c7fd255
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -62,6 +62,9 @@
|
||||||
{ "land": "gasgiant-land-03.mp4", "surface": "gasgiant-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" },
|
{ "land": "gasgiant-land-03.mp4", "surface": "gasgiant-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" },
|
||||||
{ "land": "rocky-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "rocky-takeoff-01.mp4", "shop": "rocky-shop-01.mp4" },
|
{ "land": "rocky-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "rocky-takeoff-01.mp4", "shop": "rocky-shop-01.mp4" },
|
||||||
{ "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
|
{ "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
|
||||||
|
{ "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" },
|
||||||
|
{ "land": "ice-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "rocky-shop-01.mp4" },
|
||||||
|
{ "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
|
||||||
{ "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" }
|
{ "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" }
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 1–2 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the home→far density gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is GLOBALLY; which types favor WHICH REGION of the galaxy (the home corner, the middle, the far/deep corner) is the per-zone mix in data/galaxy.json → distribution.zoneMix, keyed off each system's home→far diagonal zone (record.zone).",
|
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 1–2 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the home→far density gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is GLOBALLY; which types favor WHICH REGION of the galaxy (the home corner, the middle, the far/deep corner) is the per-zone mix in data/galaxy.json → distribution.zoneMix, keyed off each system's home→far diagonal zone (record.zone).",
|
||||||
"_effect": "Per-type VISUAL EFFECT (js/visuals/SystemEffects.js): the star's character, rendered as a full-screen composite over the WORLD camera (the starfield shimmers with it; the HUD draws on a separate unfiltered UI camera). kind: none | ripple (today). ripple parameters — center: screen (rings radiate from the middle of the view — always visible) | star (tracked to world 0,0 — wavefronts arrive from the star's, usually off-screen, direction); strength: wave cycles across the screen; amplitude: displacement in UV (fraction of the screen); speed: phase advance in radians/second; padding: extra framebuffer pixels the filter may sample (keep >= amplitude*width). Data-driven by design: tuning an effect is a JSON edit, not a code change. The star itself is never rendered (invisible flavor — its character is the shimmer).",
|
"_effect": "Per-type VISUAL EFFECT (js/visuals/SystemEffects.js): the star's character. Each sub-effect is an OPTIONAL block — a type wears exactly what it lists (an empty {} = no effect, renders untouched). CAMERA FILTERS (WebGL; the HUD is excluded by the UI-camera split) — 'ripple' { center: screen|star, strength (wave cycles), amplitude (displacement, fraction of screen), speed (rad/s), padding (framebuffer slack, keep >= amplitude*width*(1+flare.rippleBoost)*centers), centers: 1|2, orbit {radius, period} (two centers circling each other) }; 'grade' { tint (hex hue), amount (0..1), saturation (1=none), brightness, or split {a, b hex, mix 0..1, axis deg} (a two-color directional wash) }. 'flare' { interval, duration (seconds), rippleBoost x, flash 0..1, flashColor } is a SHARED seeded rhythm that surges the ripple's amplitude AND flashes the grade warm — the star flaring (a ripple with no flare just shimmers at its base amplitude). CONTENT (world-space sprites, Canvas-safe, depth 3, behind the art) — 'particles' (ember dust) and 'wanderer' (companion star). LIVE TODAY: nebula = a steady ripple; redDwarf = a SUPER-subtle ripple that SURGES on each flare + a warm grade + ember dust; binary = a two-color split grade + a wandering companion star (NO ripple — two stars, one light). Per-system variation (flare phase, split axis, wanderer path) is seeded from the system id in SystemGenerator (content.fx), so each system is stable but distinct. Tuning is a JSON edit.",
|
||||||
"objectCount": {
|
"objectCount": {
|
||||||
"barren": 0.10,
|
"barren": 0.10,
|
||||||
"objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 }
|
"objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 }
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
"description": "An ordinary star and its worlds — the galaxy's working majority.",
|
"description": "An ordinary star and its worlds — the galaxy's working majority.",
|
||||||
"theme": { "color": "#9fb4e8" },
|
"theme": { "color": "#9fb4e8" },
|
||||||
"distribution": { "weight": 34 },
|
"distribution": { "weight": 34 },
|
||||||
"effect": { "kind": "none" },
|
"effect": {},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"star": {
|
"star": {
|
||||||
"classes": { "G": 30, "K": 40, "M": 30 },
|
"classes": { "G": 30, "K": 40, "M": 30 },
|
||||||
|
|
@ -34,7 +34,13 @@
|
||||||
"description": "A small, long-lived M star with close-in, moon-rich worlds.",
|
"description": "A small, long-lived M star with close-in, moon-rich worlds.",
|
||||||
"theme": { "color": "#e8927c" },
|
"theme": { "color": "#e8927c" },
|
||||||
"distribution": { "weight": 26 },
|
"distribution": { "weight": 26 },
|
||||||
"effect": { "kind": "none" },
|
"effect": {
|
||||||
|
"ripple": { "center": "star", "strength": 18, "amplitude": 0.001, "speed": 0.8, "padding": 18, "centers": 1 },
|
||||||
|
"flare": { "interval": 18, "duration": 2.6, "rippleBoost": 12, "flash": 0.35, "flashColor": "#ffd9a8" },
|
||||||
|
"grade": { "tint": "#ff9a5c", "amount": 0.30, "saturation": 0.90, "brightness": 0.96, "lift": { "amount": 0.20, "color": "#c0392b" }, "grain": { "amount": 0.16, "parallax": 0.08 } },
|
||||||
|
"haze": { "base": "#160b09", "tint": "#c0392b", "alpha": 0.9, "grain": 12, "blobs": 9, "scale": [0.18, 0.42] },
|
||||||
|
"particles": { "count": 30, "size": [2, 5], "alpha": [0.4, 0.85], "parallax": [0.25, 0.5], "speed": [8, 25], "color": "#ff8a4a", "blend": "add" }
|
||||||
|
},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"star": {
|
"star": {
|
||||||
"classes": { "M": 85, "K": 15 },
|
"classes": { "M": 85, "K": 15 },
|
||||||
|
|
@ -57,7 +63,10 @@
|
||||||
"description": "Two stars, one system. Tangled orbits, wide spacings, rich debris.",
|
"description": "Two stars, one system. Tangled orbits, wide spacings, rich debris.",
|
||||||
"theme": { "color": "#c9a7ff" },
|
"theme": { "color": "#c9a7ff" },
|
||||||
"distribution": { "weight": 10 },
|
"distribution": { "weight": 10 },
|
||||||
"effect": { "kind": "none" },
|
"effect": {
|
||||||
|
"grade": { "split": { "a": "#f2b05c", "b": "#5b9bf2", "mix": 0.15, "axis": 28 } },
|
||||||
|
"wanderer": { "color": "#9fd0ff", "coreSize": [40, 70], "haloSize": [120, 200], "parallax": 0.08, "speed": [3, 8] }
|
||||||
|
},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"star": {
|
"star": {
|
||||||
"classes": { "F": 25, "G": 40, "K": 35 },
|
"classes": { "F": 25, "G": 40, "K": 35 },
|
||||||
|
|
@ -80,7 +89,7 @@
|
||||||
"description": "Temperate, well-lit, and quietly crowded with life. Rare.",
|
"description": "Temperate, well-lit, and quietly crowded with life. Rare.",
|
||||||
"theme": { "color": "#7ce8a4" },
|
"theme": { "color": "#7ce8a4" },
|
||||||
"distribution": { "weight": 10 },
|
"distribution": { "weight": 10 },
|
||||||
"effect": { "kind": "none" },
|
"effect": {},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"star": {
|
"star": {
|
||||||
"classes": { "G": 70, "K": 30 },
|
"classes": { "G": 70, "K": 30 },
|
||||||
|
|
@ -104,12 +113,7 @@
|
||||||
"theme": { "color": "#5fd4d0" },
|
"theme": { "color": "#5fd4d0" },
|
||||||
"distribution": { "weight": 12 },
|
"distribution": { "weight": 12 },
|
||||||
"effect": {
|
"effect": {
|
||||||
"kind": "ripple",
|
"ripple": { "center": "screen", "strength": 20, "amplitude": 0.0042, "speed": 1.0, "padding": 20 }
|
||||||
"center": "screen",
|
|
||||||
"strength": 20,
|
|
||||||
"amplitude": 0.0042,
|
|
||||||
"speed": 1.0,
|
|
||||||
"padding": 20
|
|
||||||
},
|
},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"star": {
|
"star": {
|
||||||
|
|
@ -133,7 +137,7 @@
|
||||||
"description": "Old, cold, and mostly empty. The rim's quiet dead ends.",
|
"description": "Old, cold, and mostly empty. The rim's quiet dead ends.",
|
||||||
"theme": { "color": "#7d88a8" },
|
"theme": { "color": "#7d88a8" },
|
||||||
"distribution": { "weight": 8 },
|
"distribution": { "weight": 8 },
|
||||||
"effect": { "kind": "none" },
|
"effect": {},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"star": {
|
"star": {
|
||||||
"classes": { "M": 90, "K": 10 },
|
"classes": { "M": 90, "K": 10 },
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,31 @@
|
||||||
* node dev/system-effects.test.mjs
|
* node dev/system-effects.test.mjs
|
||||||
*
|
*
|
||||||
* Asserts:
|
* Asserts:
|
||||||
* - the RIPPLE MATH (js/visuals/SystemEffectsMath.js): world->screen->UV
|
* - the RIPPLE / GRADE / FLARE MATH (js/visuals/SystemEffectsMath.js):
|
||||||
* under identity, translation, rotation and zoom transforms; the
|
* world->screen->UV under identity / translation / rotation / zoom;
|
||||||
* phase clock is monotonic and speed-scaled;
|
* the phase clock is monotonic and speed-scaled; the flare envelope is
|
||||||
* - the DATA CONTRACT (data/systems.json): every system type carries an
|
* a smooth periodic 0→1→0 bell; the binary orbit centers are
|
||||||
* `effect` block; `nebula` is the one live family (ripple) with
|
* diametrically opposed on a seeded circle; the grade color chain
|
||||||
* sane numeric parameters (and padding that covers the max
|
* (brightness → saturation → tint → split → flash) is exact and
|
||||||
* displacement); the other types render untouched (none);
|
* identity when the grade is empty;
|
||||||
|
* - the DATA CONTRACT (data/systems.json): every type carries an
|
||||||
|
* `effect` block; nebula/redDwarf/binary are live (kind "ripple");
|
||||||
|
* the redDwarf bundle has a flare + grade + particles; the binary
|
||||||
|
* bundle has two centers + orbit + a split grade + a wanderer; padding
|
||||||
|
* covers the worst-case displacement (amplitude × flare boost ×
|
||||||
|
* centers × width);
|
||||||
* - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the
|
* - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the
|
||||||
* requested type wins, ties fall to roster order, missing type => null;
|
* requested type wins, ties fall to roster order, missing type => null;
|
||||||
* - the SHADER CONTRACT (js/visuals/SystemEffects.js, exercised against
|
* - the SHADER CONTRACT (js/visuals/SystemEffects.js, against the Phaser
|
||||||
* the Phaser stub): every uniform setupUniforms pushes is declared in
|
* stub): every uniform each filter's setupUniforms pushes is declared
|
||||||
* the fragment source, and the fragment keeps the build's filter-shader
|
* in the fragment source, and both fragments keep the build's
|
||||||
* conventions (uMainSampler / outTexCoord / boundedSampler);
|
* filter-shader conventions (uMainSampler / outTexCoord /
|
||||||
|
* boundedSampler);
|
||||||
* - the FACADE (SystemEffects.apply/update/release) on a fake scene:
|
* - the FACADE (SystemEffects.apply/update/release) on a fake scene:
|
||||||
* "none" touches nothing (no camera, no filter); "ripple" needs WebGL
|
* "none" touches nothing; the filters require WebGL (canvas degrades
|
||||||
* and registers the node once, splits the cameras (UI roots off the
|
* to none); nebula → 1 filter (ripple); redDwarf → 2 (ripple + grade,
|
||||||
* world pass, world roots off the UI pass), attaches a parameterized
|
* with a flare); binary → 2 (ripple with two orbiting centers + split
|
||||||
* controller to the world camera's internal filter list, advances the
|
* grade); the star anchor tracks world 0,0; release detaches cleanly.
|
||||||
* phase in update() (screen anchor pinned, star anchor tracking
|
|
||||||
* world 0,0), and releases cleanly.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
|
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
|
||||||
|
|
@ -45,9 +50,8 @@ for (const f of fs.readdirSync(dataDir)) {
|
||||||
}
|
}
|
||||||
config.init(configData);
|
config.init(configData);
|
||||||
|
|
||||||
const { worldToScreen, worldToUV, ripplePhase } = await import(
|
const M = await import(pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href);
|
||||||
pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href
|
const { worldToScreen, worldToUV, ripplePhase } = M;
|
||||||
);
|
|
||||||
const { pickFxSystem } = await import(
|
const { pickFxSystem } = await import(
|
||||||
pathToFileURL(join(__dirname, '../js/galaxy/FxSystems.js')).href
|
pathToFileURL(join(__dirname, '../js/galaxy/FxSystems.js')).href
|
||||||
);
|
);
|
||||||
|
|
@ -65,7 +69,7 @@ function check(name, cond) {
|
||||||
pass++;
|
pass++;
|
||||||
console.log(`✓ ${name}`);
|
console.log(`✓ ${name}`);
|
||||||
}
|
}
|
||||||
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
||||||
|
|
||||||
// --- The ripple math -------------------------------------------------------
|
// --- The ripple math -------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -81,7 +85,6 @@ check('translation: world origin lands on the camera offset', (() => {
|
||||||
return p.x === 100 && p.y === 40;
|
return p.x === 100 && p.y === 40;
|
||||||
})());
|
})());
|
||||||
check('rotation: world->screen rotates (90° about the origin)', (() => {
|
check('rotation: world->screen rotates (90° about the origin)', (() => {
|
||||||
// 90° CCW in screen space (y-down): (1,0) -> (0,1).
|
|
||||||
const m = { a: 0, b: 1, c: -1, d: 0, tx: 0, ty: 0 };
|
const m = { a: 0, b: 1, c: -1, d: 0, tx: 0, ty: 0 };
|
||||||
const p = worldToScreen(m, 1, 0);
|
const p = worldToScreen(m, 1, 0);
|
||||||
return approx(p.x, 0) && approx(p.y, 1);
|
return approx(p.x, 0) && approx(p.y, 1);
|
||||||
|
|
@ -92,45 +95,197 @@ check('zoom: world origin UV scales with the camera zoom', (() => {
|
||||||
return approx(uv.x, 0.2) && approx(uv.y, 0.1);
|
return approx(uv.x, 0.2) && approx(uv.y, 0.1);
|
||||||
})());
|
})());
|
||||||
check('phase: monotonic and speed-scaled (rad at t, from a ms clock)', (() => {
|
check('phase: monotonic and speed-scaled (rad at t, from a ms clock)', (() => {
|
||||||
|
return ripplePhase(0) === 0 && ripplePhase(1000) === 1 && ripplePhase(1000, 2) === 2 && ripplePhase(2000) > ripplePhase(1000);
|
||||||
|
})());
|
||||||
|
|
||||||
|
// --- The flare / orbit / grade math ---------------------------------------
|
||||||
|
|
||||||
|
check('flare: quiet outside the window, a smooth 0→1→0 bell inside', (() => {
|
||||||
|
const f = (ms) => M.flareIntensity(ms, 18, 1.6, 0);
|
||||||
|
const quiet = f(0) === 0 && f(5000) === 0 && f(10000) === 0;
|
||||||
|
const peak = approx(f(800), 1, 1e-3); // mid of the 1.6 s flare
|
||||||
|
const edges = f(0) === 0 && f(1600) === 0;
|
||||||
|
const mid = f(500) > 0.5;
|
||||||
|
return quiet && peak && edges && mid;
|
||||||
|
})());
|
||||||
|
check('flare: per-system phase shifts the rhythm (deterministic)', (() => {
|
||||||
|
const a = M.flareIntensity(1000, 18, 1.6, 0);
|
||||||
|
const b = M.flareIntensity(1000, 18, 1.6, 9);
|
||||||
|
return Number.isFinite(a) && Number.isFinite(b) && a !== b;
|
||||||
|
})());
|
||||||
|
check('orbit: two centers, diametrically opposed on a seeded circle', (() => {
|
||||||
|
const o = M.orbitCenters(0, 36, 0.16, 0);
|
||||||
|
const mid0 = (o.cx0 + o.cx1) / 2;
|
||||||
|
const midY = (o.cy0 + o.cy1) / 2;
|
||||||
|
const dist = Math.hypot(o.cx0 - 0.5, o.cy0 - 0.5);
|
||||||
return (
|
return (
|
||||||
ripplePhase(0) === 0 &&
|
approx(mid0, 0.5) && approx(midY, 0.5) && // both orbit the view center
|
||||||
ripplePhase(1000) === 1 &&
|
approx(o.cx0 + o.cx1, 1) && approx(o.cy0 + o.cy1, 1) && // opposite
|
||||||
ripplePhase(1000, 2) === 2 &&
|
approx(dist, 0.16) // on the radius
|
||||||
ripplePhase(2000) > ripplePhase(1000)
|
|
||||||
);
|
);
|
||||||
})());
|
})());
|
||||||
|
check('orbit: advancing time moves the pair (a full lap per period)', (() => {
|
||||||
|
const t0 = M.orbitCenters(0, 36, 0.16, 0);
|
||||||
|
const t9 = M.orbitCenters(9000, 36, 0.16, 0); // quarter lap
|
||||||
|
const t36 = M.orbitCenters(36000, 36, 0.16, 0); // full lap → back
|
||||||
|
return (
|
||||||
|
(t0.cx0 !== t9.cx0 || t0.cy0 !== t9.cy0) &&
|
||||||
|
(approx(t36.cx0, t0.cx0, 1e-6) && approx(t36.cy0, t0.cy0, 1e-6))
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('hexToRgb01: parses #rrggbb and bad input falls to white', (() => {
|
||||||
|
const w = M.hexToRgb01('#ffffff');
|
||||||
|
const b = M.hexToRgb01('#0000ff');
|
||||||
|
const bad = M.hexToRgb01('nope');
|
||||||
|
return (
|
||||||
|
approx(w[0], 1) && approx(w[1], 1) && approx(w[2], 1) &&
|
||||||
|
approx(b[0], 0) && approx(b[1], 0) && approx(b[2], 1) &&
|
||||||
|
bad[0] === 1 && bad[1] === 1 && bad[2] === 1
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('tintMultiplier: a hue shift normalized to mean 1 (white → [1,1,1])', (() => {
|
||||||
|
const w = M.tintMultiplier('#ffffff');
|
||||||
|
const orange = M.tintMultiplier('#ff9a5c');
|
||||||
|
return (
|
||||||
|
w.every((v) => approx(v, 1)) &&
|
||||||
|
approx((orange[0] + orange[1] + orange[2]) / 3, 1, 1e-6) && // normalized
|
||||||
|
orange[0] > orange[2] // warm: red channel above blue
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('grade: empty grade is identity', (() => {
|
||||||
|
const out = M.gradeColor([0.5, 0.5, 0.5], {});
|
||||||
|
return out.every((v, i) => approx(v, 0.5));
|
||||||
|
})());
|
||||||
|
check('grade: brightness scales, saturation pulls to luma, tint warms', (() => {
|
||||||
|
const bright = M.gradeColor([0.5, 0.5, 0.5], { bright: 2 });
|
||||||
|
const sat = M.gradeColor([1, 0, 0], { sat: 0.5 }); // red → toward gray
|
||||||
|
const tint = M.gradeColor([0.5, 0.5, 0.5], { tint: M.tintMultiplier('#ff9a5c'), tintAmt: 0.3 });
|
||||||
|
return (
|
||||||
|
approx(bright[0], 1) &&
|
||||||
|
(sat[0] < 1 && sat[0] > 0.5) && // red desaturated toward gray
|
||||||
|
(tint[0] > tint[2]) // warmed: red above blue
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('grade: the split is a two-color directional wash (opposite sides differ)', (() => {
|
||||||
|
const sp = { split: { a: M.hexToRgb01('#f2b05c'), b: M.hexToRgb01('#5b9bf2'), axis: [1, 0], mix: 0.15 } };
|
||||||
|
const left = M.gradeColor([0.5, 0.5, 0.5], { ...sp, pos: [0.1, 0.5] });
|
||||||
|
const right = M.gradeColor([0.5, 0.5, 0.5], { ...sp, pos: [0.9, 0.5] });
|
||||||
|
return (
|
||||||
|
(left[2] > left[0]) && // left leans cool (b = blue)
|
||||||
|
(right[0] > right[2]) && // right leans warm (a = amber)
|
||||||
|
(left[2] - left[0]) > (right[2] - right[0]) // the sides actually differ
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('grade: the flare flash is an additive warm spike', (() => {
|
||||||
|
const base = M.gradeColor([0.5, 0.5, 0.5], {});
|
||||||
|
const flash = M.gradeColor([0.5, 0.5, 0.5], { flash: 0.35, flashColor: M.hexToRgb01('#ffd9a8') });
|
||||||
|
return flash.every((v) => v > base[0]) && flash[0] > flash[2];
|
||||||
|
})());
|
||||||
|
check('shadowLift: lifts the darkness toward red, leaves bright pixels alone', (() => {
|
||||||
|
const lift = M.hexToRgb01('#c0392b');
|
||||||
|
const black = M.shadowLift([0, 0, 0], lift, 0.2);
|
||||||
|
const white = M.shadowLift([0.9, 0.9, 0.9], lift, 0.2);
|
||||||
|
const none = M.shadowLift([0, 0, 0], lift, 0);
|
||||||
|
return (
|
||||||
|
black[0] > 0 && black[0] > black[2] && // black gains a red cast
|
||||||
|
black[0] < lift[0] && // but stays subtle (a haze, not a flood)
|
||||||
|
white.every((v) => v < 0.92) && // bright pixels barely move
|
||||||
|
none.every((v) => v === 0) // amount 0 = identity
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('grade: the shadow lift integrates into gradeColor (red void)', (() => {
|
||||||
|
const dark = M.gradeColor([0.02, 0.02, 0.02], { liftColor: M.hexToRgb01('#c0392b'), liftAmt: 0.2 });
|
||||||
|
const noLift = M.gradeColor([0.02, 0.02, 0.02], {});
|
||||||
|
return dark[0] > noLift[0] && dark[0] > dark[2]; // the dark space turns red
|
||||||
|
})());
|
||||||
|
|
||||||
// --- The data contract (data/systems.json) ---------------------------------
|
// --- The data contract (data/systems.json) ---------------------------------
|
||||||
|
|
||||||
const types = config.section('systems.types', {});
|
const types = config.section('systems.types', {});
|
||||||
const typeIds = Object.keys(types);
|
const typeIds = Object.keys(types);
|
||||||
|
const LIVE = ['nebula', 'redDwarf', 'binary'];
|
||||||
|
const num_ = (v) => typeof v === 'number' && Number.isFinite(v);
|
||||||
check('all six archetypes are present', typeIds.length === 6 && ['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void'].every((t) => typeIds.includes(t)));
|
check('all six archetypes are present', typeIds.length === 6 && ['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void'].every((t) => typeIds.includes(t)));
|
||||||
check('every type carries an effect block (data-driven by rule)', typeIds.every((t) => types[t].effect && typeof types[t].effect.kind === 'string'));
|
check('every type carries an effect block (data-driven by rule)', typeIds.every((t) => types[t].effect && typeof types[t].effect === 'object'));
|
||||||
check('nebula wears the ripple; the other five render untouched', (() => {
|
check('the three live types wear sub-effect blocks; the rest render untouched', (() => {
|
||||||
const kinds = Object.fromEntries(typeIds.map((t) => [t, types[t].effect.kind]));
|
const live = LIVE.every((t) => Object.keys(types[t].effect).length > 0);
|
||||||
return kinds.nebula === 'ripple' && typeIds.filter((t) => t !== 'nebula').every((t) => kinds[t] === 'none');
|
const still = ['main', 'habitable', 'void'].every((t) => Object.keys(types[t].effect || {}).length === 0);
|
||||||
|
return live && still;
|
||||||
})());
|
})());
|
||||||
check('ripple parameters are sane numbers', (() => {
|
check('nebula: a steady ripple, nothing else', (() => {
|
||||||
const e = types.nebula.effect;
|
const e = types.nebula.effect;
|
||||||
const n = (v) => typeof v === 'number' && Number.isFinite(v);
|
return !!e.ripple && num_(e.ripple.amplitude) && e.ripple.amplitude > 0 &&
|
||||||
|
e.flare == null && e.grade == null && e.particles == null && e.wanderer == null;
|
||||||
|
})());
|
||||||
|
check('redDwarf: a SUPER-subtle ripple that SURGES on a flare', (() => {
|
||||||
|
const e = types.redDwarf.effect;
|
||||||
|
const baseAmp = e.ripple?.amplitude;
|
||||||
|
const boost = e.flare?.rippleBoost;
|
||||||
return (
|
return (
|
||||||
n(e.strength) && e.strength > 0 && n(e.amplitude) && e.amplitude > 0 &&
|
!!e.ripple && num_(baseAmp) && baseAmp > 0 && baseAmp <= 0.0015 && // super-subtle baseline
|
||||||
n(e.speed) && e.speed > 0 && n(e.padding) && e.padding > 0 &&
|
num_(boost) && boost >= 4 && baseAmp * (1 + boost) > 0.008 // the surge is clearly visible
|
||||||
(e.center === undefined || e.center === 'screen' || e.center === 'star')
|
|
||||||
);
|
);
|
||||||
})());
|
})());
|
||||||
check('padding covers the max displacement (no out-of-range UV sampling)', (() => {
|
check('binary: a two-color split grade + a wandering star, and NO ripple', (() => {
|
||||||
const e = types.nebula.effect;
|
const e = types.binary.effect;
|
||||||
const maxDispPx = Math.max(config.get('game.width', 1280), config.get('game.height', 720)) * e.amplitude;
|
const sp = e.grade?.split;
|
||||||
return e.padding >= maxDispPx;
|
const w = e.wanderer;
|
||||||
|
return (
|
||||||
|
e.ripple == null &&
|
||||||
|
!!sp && typeof sp.a === 'string' && typeof sp.b === 'string' && num_(sp.mix) && sp.mix > 0 &&
|
||||||
|
!!w && typeof w.color === 'string' && num_(w.parallax) && w.parallax > 0 &&
|
||||||
|
Array.isArray(w.coreSize) && Array.isArray(w.haloSize)
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('every ripple bundle has sane numbers + padding covering the worst case', (() => {
|
||||||
|
const maxDim = Math.max(1280, 720);
|
||||||
|
return ['nebula', 'redDwarf'].every((t) => {
|
||||||
|
const e = types[t].effect;
|
||||||
|
const r = e.ripple;
|
||||||
|
const boost = e.flare ? 1 + (e.flare.rippleBoost || 0) : 1;
|
||||||
|
const centers = num_(r.centers) ? r.centers : 1;
|
||||||
|
const need = r.amplitude * boost * centers * maxDim;
|
||||||
|
return (
|
||||||
|
num_(r.strength) && r.strength > 0 && num_(r.amplitude) && r.amplitude > 0 &&
|
||||||
|
num_(r.speed) && r.speed > 0 && num_(r.padding) && r.padding >= need &&
|
||||||
|
(r.center === undefined || r.center === 'screen' || r.center === 'star')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})());
|
||||||
|
check('redDwarf bundle: a flare (drives ripple + grade flash), a warm grade + red void, a red haze, ember particles', (() => {
|
||||||
|
const e = types.redDwarf.effect;
|
||||||
|
const f = e.flare ?? {};
|
||||||
|
const g = e.grade ?? {};
|
||||||
|
const h = e.haze ?? {};
|
||||||
|
const p = e.particles ?? {};
|
||||||
|
const n = (v) => typeof v === 'number' && Number.isFinite(v);
|
||||||
|
return (
|
||||||
|
n(f.interval) && f.interval > 0 && n(f.duration) && f.duration > 0 &&
|
||||||
|
n(f.rippleBoost) && f.rippleBoost > 0 && n(f.flash) && f.flash > 0 &&
|
||||||
|
typeof f.flashColor === 'string' &&
|
||||||
|
typeof g.tint === 'string' && n(g.amount) && g.amount > 0 && n(g.saturation) && n(g.brightness) &&
|
||||||
|
n(g.lift?.amount) && g.lift.amount > 0 && typeof g.lift.color === 'string' &&
|
||||||
|
n(g.grain?.amount) && g.grain.amount > 0 && n(g.grain.parallax) && g.grain.parallax > 0 &&
|
||||||
|
typeof h.base === 'string' && typeof h.tint === 'string' && n(h.alpha) && h.alpha > 0 && n(h.grain) && h.grain > 0 &&
|
||||||
|
n(p.count) && p.count > 0 && typeof p.color === 'string' &&
|
||||||
|
p.blend === 'add'
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('binary bundle: a two-color split grade + a companion star (no ripple)', (() => {
|
||||||
|
const e = types.binary.effect;
|
||||||
|
const sp = e.grade?.split ?? {};
|
||||||
|
const w = e.wanderer ?? {};
|
||||||
|
return (
|
||||||
|
e.ripple == null && // the binary wears NO ripple
|
||||||
|
typeof sp.a === 'string' && typeof sp.b === 'string' && num_(sp.mix) && sp.mix > 0 &&
|
||||||
|
typeof w.color === 'string' && num_(w.parallax) && w.parallax > 0 &&
|
||||||
|
Array.isArray(w.coreSize) && Array.isArray(w.haloSize)
|
||||||
|
);
|
||||||
})());
|
})());
|
||||||
|
|
||||||
// --- The demo picker (js/galaxy/FxSystems.js) -------------------------------
|
// --- The demo picker (js/galaxy/FxSystems.js) -------------------------------
|
||||||
|
|
||||||
const fakeGalaxy = (records, contents = {}) => ({
|
const fakeGalaxy = (records, contents = {}) => ({ records, contentCache: new Map(Object.entries(contents)) });
|
||||||
records,
|
|
||||||
contentCache: new Map(Object.entries(contents)),
|
|
||||||
});
|
|
||||||
const rec = (id, type) => ({ id, type });
|
const rec = (id, type) => ({ id, type });
|
||||||
check('picker: richest system of the type wins', (() => {
|
check('picker: richest system of the type wins', (() => {
|
||||||
const g = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula'), rec('C', 'main')], {
|
const g = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula'), rec('C', 'main')], {
|
||||||
|
|
@ -146,16 +301,13 @@ check('picker: more objects beat more gates; ties fall to roster order', (() =>
|
||||||
B: { planets: [1, 2], jumps: [1] },
|
B: { planets: [1, 2], jumps: [1] },
|
||||||
});
|
});
|
||||||
const first = pickFxSystem(g, 'nebula')?.id;
|
const first = pickFxSystem(g, 'nebula')?.id;
|
||||||
const g2 = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula')], {
|
const g2 = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula')], { A: { planets: [1] }, B: { planets: [1] } });
|
||||||
A: { planets: [1] },
|
|
||||||
B: { planets: [1] },
|
|
||||||
});
|
|
||||||
return first === 'B' && pickFxSystem(g2, 'nebula')?.id === 'A';
|
return first === 'B' && pickFxSystem(g2, 'nebula')?.id === 'A';
|
||||||
})());
|
})());
|
||||||
check('picker: no system of the type => null', pickFxSystem(fakeGalaxy([rec('A', 'void')]), 'nebula') === null);
|
check('picker: no system of the type => null', pickFxSystem(fakeGalaxy([rec('A', 'void')]), 'nebula') === null);
|
||||||
check('picker: empty roster => null', pickFxSystem(fakeGalaxy([]), 'nebula') === null);
|
check('picker: empty roster => null', pickFxSystem(fakeGalaxy([]), 'nebula') === null);
|
||||||
|
|
||||||
// --- The shader contract -----------------------------------------------------
|
// --- The camera split (js/visuals/UiCameras.js) + facade (fake scene) ------
|
||||||
|
|
||||||
const fakeRenderer = {
|
const fakeRenderer = {
|
||||||
renderNodes: {
|
renderNodes: {
|
||||||
|
|
@ -168,6 +320,8 @@ const makeCamera = (id) => ({
|
||||||
id,
|
id,
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 720,
|
height: 720,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0,
|
||||||
matrixCombined: { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 40 },
|
matrixCombined: { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 40 },
|
||||||
filters: {
|
filters: {
|
||||||
internal: {
|
internal: {
|
||||||
|
|
@ -202,11 +356,11 @@ const fakeScene = {
|
||||||
cameras: fakeCameras,
|
cameras: fakeCameras,
|
||||||
sys: { displayList: { getChildren: () => [uiRoot, worldRoot, mkObject('world-2', 1)] } },
|
sys: { displayList: { getChildren: () => [uiRoot, worldRoot, mkObject('world-2', 1)] } },
|
||||||
renderer: { gl: {}, renderNodes: fakeRenderer.renderNodes },
|
renderer: { gl: {}, renderNodes: fakeRenderer.renderNodes },
|
||||||
|
systemContent: { fx: { phase: 0.5, angle: 0.3, drift: 0.7 } }, // per-system variation
|
||||||
};
|
};
|
||||||
|
|
||||||
// The real split:
|
|
||||||
const split = ensureUiCameras(fakeScene);
|
const split = ensureUiCameras(fakeScene);
|
||||||
check('split: exactly two passes, main first (world under UI), UI pass force-composited', split && split.main === fakeCamMain && fakeCameras.cameras.length === 2 && fakeCameras.cameras[1] === split.ui && split.ui.forceComposite === true);
|
check('split: exactly two passes, main first, UI pass force-composited', split && split.main === fakeCamMain && fakeCameras.cameras.length === 2 && fakeCameras.cameras[1] === split.ui && split.ui.forceComposite === true);
|
||||||
check('split: screen-pinned roots are ignored by the world pass', (uiRoot.cameraFilter & fakeCamMain.id) !== 0);
|
check('split: screen-pinned roots are ignored by the world pass', (uiRoot.cameraFilter & fakeCamMain.id) !== 0);
|
||||||
check('split: world roots are ignored by the UI pass', (worldRoot.cameraFilter & split.ui.id) !== 0);
|
check('split: world roots are ignored by the UI pass', (worldRoot.cameraFilter & split.ui.id) !== 0);
|
||||||
check('split: idempotent (same split object, no third camera)', ensureUiCameras(fakeScene) === split && fakeCameras.cameras.length === 2);
|
check('split: idempotent (same split object, no third camera)', ensureUiCameras(fakeScene) === split && fakeCameras.cameras.length === 2);
|
||||||
|
|
@ -218,64 +372,125 @@ check('assignUi: a late UI object joins the UI pass only', (lateUi.cameraFilter
|
||||||
const lateWorld = mkObject('late-world', 1);
|
const lateWorld = mkObject('late-world', 1);
|
||||||
assignWorld(fakeScene, lateWorld);
|
assignWorld(fakeScene, lateWorld);
|
||||||
check('assignWorld: a late world object stays on the world pass only', (lateWorld.cameraFilter & split.ui.id) !== 0 && (lateWorld.cameraFilter & fakeCamMain.id) === 0);
|
check('assignWorld: a late world object stays on the world pass only', (lateWorld.cameraFilter & split.ui.id) !== 0 && (lateWorld.cameraFilter & fakeCamMain.id) === 0);
|
||||||
const noSplitScene = { };
|
assignUi({}, lateUi); // must not throw
|
||||||
assignUi(noSplitScene, lateUi); // must not throw
|
|
||||||
check('assign*: no-op while the split does not exist (single-camera pipeline intact)', true);
|
check('assign*: no-op while the split does not exist (single-camera pipeline intact)', true);
|
||||||
|
|
||||||
// The facade:
|
// --- The facade -------------------------------------------------------------
|
||||||
|
|
||||||
const fx = new SE.SystemEffects(fakeScene);
|
const fx = new SE.SystemEffects(fakeScene);
|
||||||
check('facade: "none" (main) attaches nothing — no filter, no new camera', (() => {
|
check('facade: "none" (main) attaches nothing — no filter, no new camera', (() => {
|
||||||
const camsBefore = fakeCameras.cameras.length;
|
const camsBefore = fakeCameras.cameras.length;
|
||||||
const active = fx.apply('main');
|
const active = fx.apply('main');
|
||||||
return active === false && fx.active === false && fakeCamMain.filters.internal.list.length === 0 && fakeCameras.cameras.length === camsBefore;
|
return active === false && fx.active === false && fakeCamMain.filters.internal.list.length === 0 && fakeCameras.cameras.length === camsBefore;
|
||||||
})());
|
})());
|
||||||
check('facade: ripple requires WebGL (canvas degrades to none)', (() => {
|
check('facade: the filters require WebGL (canvas degrades to none)', (() => {
|
||||||
const canvasScene = { ...fakeScene, renderer: { gl: null, renderNodes: fakeRenderer.renderNodes } };
|
const canvasScene = { ...fakeScene, renderer: { gl: null, renderNodes: fakeRenderer.renderNodes } };
|
||||||
const f = new SE.SystemEffects(canvasScene);
|
const f = new SE.SystemEffects(canvasScene);
|
||||||
return f.apply('nebula') === false && f.active === false;
|
return f.apply('redDwarf') === false && f.active === false;
|
||||||
})());
|
})());
|
||||||
check('facade: apply(nebula) registers the node once + splits + attaches', (() => {
|
check('facade: apply(nebula) → one filter (ripple only)', (() => {
|
||||||
fakeCamMain.filters.internal.list.length = 0;
|
fakeCamMain.filters.internal.list.length = 0;
|
||||||
const active = fx.apply('nebula');
|
const active = fx.apply('nebula');
|
||||||
const list = fakeCamMain.filters.internal.list;
|
const list = fakeCamMain.filters.internal.list;
|
||||||
return (
|
return (
|
||||||
active === true &&
|
active === true && fx.active === true && fx.kind === 'ripple' &&
|
||||||
fx.active === true &&
|
list.length === 1 && list[0].renderNode === SE.RIPPLE_NODE && list[0].camera === fakeCamMain &&
|
||||||
fx.kind === 'ripple' &&
|
list[0].centers === 1 && list[0].anchor === 'screen'
|
||||||
fakeRenderer.renderNodes.hasNode(SE.RIPPLE_NODE) &&
|
|
||||||
list.length === 1 &&
|
|
||||||
list[0].renderNode === SE.RIPPLE_NODE &&
|
|
||||||
list[0].camera === fakeCamMain
|
|
||||||
);
|
);
|
||||||
})());
|
})());
|
||||||
check('facade: config parameters land on the controller', (() => {
|
check('facade: nebula ripple reads the config (strength/amp/speed + padded)', (() => {
|
||||||
const c = fakeCamMain.filters.internal.list[0];
|
const c = fakeCamMain.filters.internal.list[0];
|
||||||
const e = types.nebula.effect;
|
const r = types.nebula.effect.ripple;
|
||||||
return (
|
return (
|
||||||
c.strength === e.strength &&
|
c.strength === r.strength && c.baseAmp === r.amplitude && c.speed === r.speed &&
|
||||||
c.amplitude === e.amplitude &&
|
c.paddingOverride && c.paddingOverride.x < 0 && c.paddingOverride.width > 0
|
||||||
c.speed === e.speed &&
|
);
|
||||||
c.paddingOverride &&
|
})());
|
||||||
c.paddingOverride.x === -Math.max(4, Math.ceil(e.padding)) &&
|
check('facade: apply(redDwarf) → ripple (star anchor, flare) + grade (warm tint + flash)', (() => {
|
||||||
c.paddingOverride.width === 2 * Math.max(4, Math.ceil(e.padding))
|
fakeCamMain.filters.internal.list.length = 0;
|
||||||
|
const active = fx.apply('redDwarf');
|
||||||
|
const list = fakeCamMain.filters.internal.list;
|
||||||
|
const ripple = list.find((c) => c.renderNode === SE.RIPPLE_NODE);
|
||||||
|
const grade = list.find((c) => c.renderNode === SE.GRADE_NODE);
|
||||||
|
return (
|
||||||
|
active === true && list.length === 2 &&
|
||||||
|
ripple && ripple.centers === 1 && ripple.anchor === 'star' && ripple.flare?.rippleBoost > 0 &&
|
||||||
|
grade && grade.uTintAmt > 0 && grade.uSat < 1 && grade.uBright < 1 &&
|
||||||
|
grade.uLiftAmt > 0 && grade.uLiftR > grade.uLiftB && grade.uGrainAmt > 0 &&
|
||||||
|
grade.grainParallax > 0 &&
|
||||||
|
grade.uFlashR > 0 && grade.flareFlash > 0
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('facade: update() parallaxes the grain by the camera scroll', (() => {
|
||||||
|
fakeCamMain.filters.internal.list.length = 0;
|
||||||
|
fx.apply('redDwarf');
|
||||||
|
const grade = fakeCamMain.filters.internal.list.find((c) => c.renderNode === SE.GRADE_NODE);
|
||||||
|
const p = grade.grainParallax;
|
||||||
|
fakeCamMain.scrollX = 500; fakeCamMain.scrollY = -250;
|
||||||
|
fx.update(1000);
|
||||||
|
const offX = grade.uGrainOffX;
|
||||||
|
const offY = grade.uGrainOffY;
|
||||||
|
// offset = scroll * parallax, and it changes as the camera moves
|
||||||
|
fakeCamMain.scrollX = 700;
|
||||||
|
fx.update(1100);
|
||||||
|
return (
|
||||||
|
p > 0 &&
|
||||||
|
offX === 500 * p && offY === -250 * p &&
|
||||||
|
grade.uGrainOffX === 700 * p
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('facade: apply(binary) → a split grade ONLY (no ripple)', (() => {
|
||||||
|
fakeCamMain.filters.internal.list.length = 0;
|
||||||
|
const active = fx.apply('binary');
|
||||||
|
const list = fakeCamMain.filters.internal.list;
|
||||||
|
const ripple = list.find((c) => c.renderNode === SE.RIPPLE_NODE);
|
||||||
|
const grade = list.find((c) => c.renderNode === SE.GRADE_NODE);
|
||||||
|
return (
|
||||||
|
active === true && fx.kind === 'grade' && list.length === 1 &&
|
||||||
|
!ripple && // no ripple on the binary
|
||||||
|
grade && grade.uSplitMix > 0 && (grade.uSplitAR !== grade.uSplitBR || grade.uSplitAG !== grade.uSplitBG)
|
||||||
);
|
);
|
||||||
})());
|
})());
|
||||||
check('facade: apply() twice replaces (no duplicate filters)', (() => {
|
check('facade: apply() twice replaces (no duplicate filters)', (() => {
|
||||||
fx.apply('nebula');
|
fx.apply('redDwarf');
|
||||||
return fakeCamMain.filters.internal.list.length === 1;
|
return fakeCamMain.filters.internal.list.length === 2;
|
||||||
})());
|
})());
|
||||||
check('facade: update() advances the phase and keeps the screen anchor pinned', (() => {
|
check('facade: update() advances the phase and keeps the screen anchor pinned', (() => {
|
||||||
const c = fakeCamMain.filters.internal.list[0];
|
fx.apply('nebula');
|
||||||
|
const c = fakeCamMain.filters.internal.list.find((x) => x.renderNode === SE.RIPPLE_NODE);
|
||||||
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 }; // camera moved
|
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 }; // camera moved
|
||||||
fx.update(1500);
|
fx.update(1500);
|
||||||
return approx(c.centerX, 0.5) && approx(c.centerY, 0.5) && approx(c.time, 1.5 * (Number(types.nebula.effect.speed) || 1));
|
return approx(c.cx0, 0.5) && approx(c.cy0, 0.5) && approx(c.time, 1.5 * (Number(types.nebula.effect.ripple.speed) || 1));
|
||||||
})());
|
})());
|
||||||
check('facade: the "star" anchor tracks world 0,0 in screen UV', (() => {
|
check('facade: the "star" anchor tracks world 0,0 in screen UV', (() => {
|
||||||
const c = fakeCamMain.filters.internal.list[0];
|
fx.apply('redDwarf');
|
||||||
c.center = 'star'; // exercise the tracking branch
|
const c = fakeCamMain.filters.internal.list.find((x) => x.renderNode === SE.RIPPLE_NODE);
|
||||||
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 };
|
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 };
|
||||||
fx.update(1500);
|
fx.update(1500);
|
||||||
return approx(c.centerX, 320 / 1280) && approx(c.centerY, 180 / 720);
|
return approx(c.cx0, 320 / 1280) && approx(c.cy0, 180 / 720);
|
||||||
|
})());
|
||||||
|
check('facade: the flare drives BOTH the ripple amplitude AND the grade flash', (() => {
|
||||||
|
fx.apply('redDwarf');
|
||||||
|
const list = fakeCamMain.filters.internal.list;
|
||||||
|
const ripple = list.find((x) => x.renderNode === SE.RIPPLE_NODE);
|
||||||
|
const grade = list.find((x) => x.renderNode === SE.GRADE_NODE);
|
||||||
|
// quiet moment → baseline amplitude, no flash
|
||||||
|
const quietAmp = M.flareIntensity(0, 18, 1.6, 0.5 * 18) === 0;
|
||||||
|
fx.update(0);
|
||||||
|
const baseAmp = ripple.amp0;
|
||||||
|
const baseFlash = grade.uFlash;
|
||||||
|
// find a flare peak for this phase (scan the first interval)
|
||||||
|
let peakMs = 0;
|
||||||
|
for (let ms = 0; ms < 18000; ms += 10) {
|
||||||
|
if (M.flareIntensity(ms, 18, 1.6, 0.5 * 18) > 0.999) { peakMs = ms; break; }
|
||||||
|
}
|
||||||
|
fx.update(peakMs);
|
||||||
|
return (
|
||||||
|
quietAmp && baseAmp > 0 && baseFlash === 0 &&
|
||||||
|
ripple.amp0 > baseAmp && // the ripple surges during the flare
|
||||||
|
grade.uFlash > 0 && // and the grade flashes
|
||||||
|
ripple.amp0 < ripple.baseAmp * (1 + types.redDwarf.effect.flare.rippleBoost) + 1e-9
|
||||||
|
);
|
||||||
})());
|
})());
|
||||||
check('facade: release() detaches cleanly (and twice)', (() => {
|
check('facade: release() detaches cleanly (and twice)', (() => {
|
||||||
fx.release();
|
fx.release();
|
||||||
|
|
@ -284,35 +499,78 @@ check('facade: release() detaches cleanly (and twice)', (() => {
|
||||||
return first;
|
return first;
|
||||||
})());
|
})());
|
||||||
|
|
||||||
// --- The shader source contract (through a live node instance) ---------------
|
// --- The shader contract (through live node instances) ----------------------
|
||||||
|
|
||||||
const NodeClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE];
|
const RippleClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE];
|
||||||
check('node: the registered constructor exists and names itself', typeof NodeClass === 'function' && SE.RIPPLE_NODE === 'FilterRippleEffect');
|
const GradeClass = fakeRenderer.renderNodes._ctors[SE.GRADE_NODE];
|
||||||
const liveNode = new NodeClass({ renderer: {} });
|
check('nodes: both registered constructors exist and name themselves', typeof RippleClass === 'function' && typeof GradeClass === 'function' && SE.RIPPLE_NODE === 'FilterRippleEffect' && SE.GRADE_NODE === 'FilterGradeEffect');
|
||||||
const fragSrc = liveNode.fragmentSource;
|
|
||||||
check('shader: declares every uniform setupUniforms pushes', (() => {
|
const rippleNode = new RippleClass({ renderer: {} });
|
||||||
const ctrl = { time: 1, strength: 90, amplitude: 0.01, centerX: 0.5, centerY: 0.5 };
|
const rippleSrc = rippleNode.fragmentSource;
|
||||||
liveNode.setupUniforms(ctrl, {});
|
check('ripple shader: declares every uniform setupUniforms pushes', (() => {
|
||||||
const pushed = Object.keys(liveNode.uniforms);
|
rippleNode.setupUniforms({ time: 1, strength: 90, amp0: 0.01, amp1: 0, cx0: 0.5, cy0: 0.5, cx1: 0.5, cy1: 0.5 }, {});
|
||||||
const declared = ['time', 'strength', 'amplitude', 'centerX', 'centerY'].every((u) =>
|
const pushed = Object.keys(rippleNode.uniforms);
|
||||||
new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(fragSrc));
|
const declared = ['time', 'strength', 'amp0', 'amp1', 'cx0', 'cy0', 'cx1', 'cy1'].every((u) =>
|
||||||
return pushed.length === 5 && declared;
|
new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(rippleSrc));
|
||||||
|
return pushed.length === 8 && declared;
|
||||||
})());
|
})());
|
||||||
check('shader: keeps the build\'s filter conventions (uMainSampler, outTexCoord, boundedSampler)', (() => {
|
check('ripple shader: keeps the build\'s filter conventions', (() => {
|
||||||
return (
|
return (
|
||||||
fragSrc.includes('uniform sampler2D uMainSampler;') &&
|
rippleSrc.includes('uniform sampler2D uMainSampler;') &&
|
||||||
fragSrc.includes('varying vec2 outTexCoord;') &&
|
rippleSrc.includes('varying vec2 outTexCoord;') &&
|
||||||
fragSrc.includes('boundedSampler(uMainSampler') &&
|
rippleSrc.includes('boundedSampler(uMainSampler') &&
|
||||||
fragSrc.includes('#pragma phaserTemplate(shaderName)') &&
|
rippleSrc.includes('#pragma phaserTemplate(shaderName)') &&
|
||||||
fragSrc.includes('#pragma phaserTemplate(fragmentHeader)')
|
rippleSrc.includes('#pragma phaserTemplate(fragmentHeader)')
|
||||||
);
|
);
|
||||||
})());
|
})());
|
||||||
check('shader: displacement is radial from the center, clean at the center, calm at the corners', (() => {
|
check('ripple shader: radial displacement from each center, clean at the center, calm at the corners', (() => {
|
||||||
return (
|
return (
|
||||||
fragSrc.includes('length(delta)') &&
|
rippleSrc.includes('vec2 d0 = outTexCoord - vec2(cx0, cy0);') &&
|
||||||
fragSrc.includes('smoothstep(0.0, 0.02, dist)') &&
|
rippleSrc.includes('smoothstep(0.0, 0.02, r0)') &&
|
||||||
fragSrc.includes('0.8 + 0.2 * exp(-dist * 0.25)') &&
|
rippleSrc.includes('0.8 + 0.2 * exp(-r0 * 0.25)') &&
|
||||||
fragSrc.includes('wave * amplitude * fade')
|
rippleSrc.includes('w0 * amp0 * f0') &&
|
||||||
|
rippleSrc.includes('vec2 d1 = outTexCoord - vec2(cx1, cy1);')
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
|
||||||
|
const gradeNode = new GradeClass({ renderer: {} });
|
||||||
|
const gradeSrc = gradeNode.fragmentSource;
|
||||||
|
check('grade shader: declares every uniform setupUniforms pushes', (() => {
|
||||||
|
gradeNode.setupUniforms({
|
||||||
|
uBright: 1, uSat: 1, uTintAmt: 0.3, uTintR: 1, uTintG: 1, uTintB: 1,
|
||||||
|
uSplitMix: 0.15, uSplitAR: 1, uSplitAG: 1, uSplitAB: 1,
|
||||||
|
uSplitBR: 1, uSplitBG: 1, uSplitBB: 1, uAxisX: 1, uAxisY: 0,
|
||||||
|
uFlash: 0.3, uFlashR: 1, uFlashG: 1, uFlashB: 1,
|
||||||
|
uLiftAmt: 0.2, uLiftR: 0.75, uLiftG: 0.22, uLiftB: 0.17,
|
||||||
|
uGrainAmt: 0.16, uGrainOffX: 12.3, uGrainOffY: -7.5,
|
||||||
|
}, {});
|
||||||
|
const pushed = Object.keys(gradeNode.uniforms);
|
||||||
|
const names = [
|
||||||
|
'uBright', 'uSat', 'uTintAmt', 'uTintR', 'uTintG', 'uTintB',
|
||||||
|
'uSplitMix', 'uSplitAR', 'uSplitAG', 'uSplitAB', 'uSplitBR', 'uSplitBG', 'uSplitBB',
|
||||||
|
'uAxisX', 'uAxisY', 'uFlash', 'uFlashR', 'uFlashG', 'uFlashB',
|
||||||
|
'uLiftAmt', 'uLiftR', 'uLiftG', 'uLiftB', 'uGrainAmt', 'uGrainOffX', 'uGrainOffY',
|
||||||
|
];
|
||||||
|
const declared = names.every((u) => new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(gradeSrc));
|
||||||
|
return pushed.length === 26 && declared;
|
||||||
|
})());
|
||||||
|
check('grade shader: keeps the build\'s filter conventions', (() => {
|
||||||
|
return (
|
||||||
|
gradeSrc.includes('uniform sampler2D uMainSampler;') &&
|
||||||
|
gradeSrc.includes('varying vec2 outTexCoord;') &&
|
||||||
|
gradeSrc.includes('boundedSampler(uMainSampler') &&
|
||||||
|
gradeSrc.includes('#pragma phaserTemplate(shaderName)') &&
|
||||||
|
gradeSrc.includes('#pragma phaserTemplate(fragmentHeader)')
|
||||||
|
);
|
||||||
|
})());
|
||||||
|
check('grade shader: brightness → saturation → tint → lift → split → flash, in order', (() => {
|
||||||
|
const order = (a, b) => gradeSrc.indexOf(a) !== -1 && gradeSrc.indexOf(b) !== -1 && gradeSrc.indexOf(a) < gradeSrc.indexOf(b);
|
||||||
|
return (
|
||||||
|
order('rgb *= uBright;', 'if (uSat != 1.0)') &&
|
||||||
|
order('if (uSat != 1.0)', 'if (uTintAmt > 0.0)') &&
|
||||||
|
order('if (uTintAmt > 0.0)', 'if (uLiftAmt > 0.0)') &&
|
||||||
|
order('if (uLiftAmt > 0.0)', 'if (uSplitMix > 0.0)') &&
|
||||||
|
order('if (uSplitMix > 0.0)', 'if (uFlash > 0.0)')
|
||||||
);
|
);
|
||||||
})());
|
})());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1107,53 +1107,103 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
||||||
- To upgrade: replace the vendored file + note the version here (and re-check
|
- To upgrade: replace the vendored file + note the version here (and re-check
|
||||||
the quirks above — they may go away).
|
the quirks above — they may go away).
|
||||||
|
|
||||||
## System effects — the star's character (data-driven, WebGL)
|
## System effects — the star's character (data-driven)
|
||||||
|
|
||||||
- A system's visual character is a full-screen composite effect on the
|
- A system's visual character is a **bundle of sub-effects** configured per
|
||||||
WORLD camera, configured per archetype in `data/systems.json →
|
archetype in `data/systems.json → types.<type>.effect`. Each sub-effect is
|
||||||
types.<type>.effect` (`kind: none | ripple`). Nebula systems wear the
|
an OPTIONAL block; a type wears exactly what it lists (an empty `{}` = no
|
||||||
RIPPLE — concentric rings radiating from the middle of the view
|
effect). Three archetypes are live today; the rest render untouched:
|
||||||
(`center: "screen"`; `"star"` tracks world 0,0 instead) — young,
|
- **nebula** — the RIPPLE (1 center, screen anchor): concentric rings,
|
||||||
bright, still messy: the light itself is unsettled. The star itself is
|
the light unsettled.
|
||||||
invisible flavor; the shimmer is its character.
|
- **redDwarf** — a SUPER-subtle RIPPLE (1 center, star anchor; the base
|
||||||
- Files: `js/visuals/SystemEffects.js` (filter + facade),
|
shimmer is barely there) that SURGES on each FLARE — the flare is a
|
||||||
`js/visuals/SystemEffectsMath.js` (pure UV math),
|
seeded rhythm that spikes the ripple's amplitude AND flashes the grade
|
||||||
`js/visuals/UiCameras.js` (the UI camera split),
|
warm, together — plus a warm GRADE (tint + a SHADOW LIFT that turns the
|
||||||
|
darkness red), a gritty RED HAZE over the void (content, behind the
|
||||||
|
stars), and ember dust (content).
|
||||||
|
- **binary** — a two-color SPLIT grade (the two light sources washing the
|
||||||
|
view from opposite sides) + a wandering companion star (content). NO
|
||||||
|
ripple — two stars, one steady light.
|
||||||
|
The star itself is invisible flavor; the shimmer / glow / wander is its
|
||||||
|
character.
|
||||||
|
- Two kinds of sub-effect:
|
||||||
|
- **Camera filters** (WebGL; the HUD is excluded by the UI-camera split):
|
||||||
|
`ripple` (1–2 centers, `center: screen|star`, `strength/amplitude/speed/padding`,
|
||||||
|
`centers`, `orbit.radius/period`) and `grade` (`tint/amount/saturation/brightness`,
|
||||||
|
`lift: {amount,color}` — a SHADOW LIFT that pushes only the dark pixels
|
||||||
|
toward a hue (the red void), `grain: {amount,parallax}` — fine warm
|
||||||
|
grain in the dark, offset by the camera scroll so it sits at a depth
|
||||||
|
(it shifts slightly as you fly, not stuck to the lens), or
|
||||||
|
`split: {a,b,mix,axis}` — a two-color directional wash). The
|
||||||
|
`flare` (`interval/duration/rippleBoost/flash/flashColor`) is a shared
|
||||||
|
signal both filters read, so the wave and the light flash as one.
|
||||||
|
- **Content** (world-space sprites, Canvas-safe, behind the art):
|
||||||
|
`haze` (redDwarf red haze over the void → `js/visuals/RedHaze.js`, depth
|
||||||
|
−1, BEHIND the stars), `particles` (redDwarf ember dust →
|
||||||
|
`js/visuals/EmberField.js`), and `wanderer` (binary companion star →
|
||||||
|
`js/visuals/WandererStar.js`).
|
||||||
|
- Files: `js/visuals/SystemEffects.js` (ripple + grade filters + facade),
|
||||||
|
`js/visuals/SystemEffectsMath.js` (pure UV / flare / orbit / grade / lift
|
||||||
|
math), `js/visuals/UiCameras.js` (the UI camera split),
|
||||||
|
`js/visuals/RedHaze.js` (the gritty red haze), `js/visuals/EmberField.js`
|
||||||
|
(ember dust), `js/visuals/WandererStar.js` (companion star),
|
||||||
`js/galaxy/FxSystems.js` (demo system picker),
|
`js/galaxy/FxSystems.js` (demo system picker),
|
||||||
`dev/system-effects.test.mjs` (35 checks, bare Node).
|
`dev/system-effects.test.mjs` (57 checks, bare Node).
|
||||||
- **How it draws (Phaser 4.2.1):** the ripple is a
|
- **How the filters draw (Phaser 4.2.1):** each filter is a
|
||||||
`Phaser.Filters.Controller` on `cameras.main`'s `filters.internal`
|
`Phaser.Filters.Controller` on `cameras.main`'s `filters.internal` list —
|
||||||
list — when any internal filter is active the camera pass
|
when any internal filter is active the camera pass auto-composites (FBO +
|
||||||
auto-composites (FBO + blit), so the WORLD (starfield included) is
|
blit), so the WORLD (starfield included) is displaced/graded and the HUD
|
||||||
displaced and the HUD is not. The UI pass is a second camera
|
is not. Multiple filters chain on the SAME pass (ripple then grade). The UI
|
||||||
(`fx-ui`, `forceComposite`) that draws the screen-pinned objects on
|
pass is a second camera (`fx-ui`, `forceComposite`) that draws the
|
||||||
top WITHOUT clearing the world (a second direct pass would `clear()`
|
screen-pinned objects on top WITHOUT clearing the world. Both directions
|
||||||
the canvas). Both directions are partitioned with `camera.ignore()`
|
are partitioned with `camera.ignore()` (set-only — no un-ignore):
|
||||||
(set-only — there is no un-ignore): screen-pinned roots are ignored by
|
screen-pinned roots ignored by MAIN, world roots (220+) by the UI pass,
|
||||||
MAIN, world roots (220+) by the UI pass, so nothing draws twice and
|
so nothing draws twice and input still hits the UI first (hit-test walks
|
||||||
input still hits the UI first (hit-test walks cameras in reverse
|
cameras in reverse array order). Verified in the browser: a scrollFactor-0
|
||||||
array order). Verified in the browser: a scrollFactor-0 test ruler on
|
test ruler on the UI pass is pixel-stable across frames while the world
|
||||||
the UI pass is pixel-stable across frames while the world behind it
|
behind it shimmers.
|
||||||
shimmers.
|
- **WebGL-only for the filters, Canvas-safe for content:** the filters need
|
||||||
- **WebGL-only, Canvas-safe:** `apply()` requires `renderer.gl`; on the
|
`renderer.gl`; on the Canvas fallback they are skipped (no crash). The
|
||||||
Canvas fallback the system renders untouched (no crash, no effect).
|
CONTENT sub-effects are plain sprites and render regardless. The node
|
||||||
The node constructor is registered once per renderer
|
constructors are registered once per renderer (`ensureRippleNode` /
|
||||||
(`ensureRippleNode`, `hasNode`-guarded — the registry is global and
|
`ensureGradeNode`, `hasNode`-guarded — the registry is global and throws
|
||||||
throws on duplicates).
|
on duplicates).
|
||||||
- **Tuning is a JSON edit:** `strength` (wave cycles across the screen),
|
- **The ripple math:** up to two centers, each contributing a traveling
|
||||||
`amplitude` (displacement, fraction of screen width), `speed` (phase
|
radial wave; the total displacement is the sum. Per center:
|
||||||
rad/s), `padding` (framebuffer slack — keep ≥ amplitude×width). The
|
`dir * sin(dist*strength − phase) * amplitude * fade`, sampled through the
|
||||||
displacement is `dir * sin(dist*strength − phase) * amplitude * fade`,
|
injected `boundedSampler` (out-of-bounds reads return transparent — a
|
||||||
sampled through the injected `boundedSampler` (out-of-bounds reads
|
too-big amplitude punches holes; the padding is the guard). A center with
|
||||||
return transparent — a too-big amplitude punches holes; the padding is
|
amplitude 0 is inert, so the 1-center systems are the degenerate case.
|
||||||
the guard).
|
`padding` must cover the worst case: `amplitude × (1+flare.rippleBoost) ×
|
||||||
- **Demo:** `?fx=<type>` (e.g. `dev/test-game.html?fx=nebula`) retargets
|
centers × max(width,height)` (the test enforces it).
|
||||||
the run to the richest system of that archetype
|
- **The grade math** (pure in `gradeColor`, mirrored in the shader):
|
||||||
(`pickFxSystem`), force-discovers everything
|
brightness → saturation (pull to luma) → tint (a hue multiplier
|
||||||
(`Discovery.markAllDiscovered`) and activates the gates — a clean
|
normalized to mean 1, blended by `amount`) → SHADOW LIFT (push the dark
|
||||||
minute of flying to judge the star. No param → ordinary play, and
|
pixels toward `lift.color` by `lift.amount`, luminance-gated so the bright
|
||||||
`kind: none` systems never split the cameras (the single-camera
|
stars stay put — `shadowLift`, pure + tested) → split (a two-color wash
|
||||||
pipeline renders exactly as before).
|
across the view along a seeded axis) → flash (an additive warm spike
|
||||||
|
during a flare). The `grain` is a per-pixel spatial effect and lives in
|
||||||
|
the shader only. All values are scalars (no vec packing) for portability.
|
||||||
|
- **The red haze (content, not a filter):** the darkness of space is the
|
||||||
|
renderer's BACKGROUND (config `backgroundColor`), which a camera filter
|
||||||
|
does NOT reach — the clear color bypasses the composite. So the red void
|
||||||
|
is a CONTENT layer: one large, canvas-generated gritty red texture (dark
|
||||||
|
base + soft blotches + fine grain) drawn at depth −1, BEHIND the
|
||||||
|
starfield (0–2), recentered on the camera each frame so it always fills
|
||||||
|
the view. The white stars and all art sit in front and stay bright — only
|
||||||
|
the void turns a dusty red. Canvas-safe, never touches the HUD.
|
||||||
|
- **Per-system variation:** `SystemGenerator` stamps `content.fx =
|
||||||
|
{phase, angle, drift}` (a dedicated fork `(seed,'system',id,'fx')`, so
|
||||||
|
lazy === eager) — the flare rhythm, the split axis, and the companion's
|
||||||
|
wander are all seeded per system, so each system is stable but distinct.
|
||||||
|
- **Tuning is a JSON edit** — `data/systems.json → types.<type>.effect`.
|
||||||
|
- **Demo:** `?fx=<type>` (e.g. `dev/test-game.html?fx=redDwarf`) retargets
|
||||||
|
the run to the richest system of that archetype (`pickFxSystem`,
|
||||||
|
case-insensitive), force-discovers everything
|
||||||
|
(`Discovery.markAllDiscovered`) and activates the gates — a clean minute
|
||||||
|
of flying to judge the star. No param → ordinary play, and an empty-bundle
|
||||||
|
system never splits the cameras (the single-camera pipeline renders
|
||||||
|
exactly as before).
|
||||||
|
|
||||||
## Nebula atmosphere — the gas behind the art
|
## Nebula atmosphere — the gas behind the art
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,21 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
? nebulaPalette[atmosphereRng.int(0, nebulaPalette.length - 1)]
|
? nebulaPalette[atmosphereRng.int(0, nebulaPalette.length - 1)]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// --- Per-system FX variation (js/visuals/SystemEffects.js) -------------
|
||||||
|
// A few stable 0..1 numbers the live effects (redDwarf flare, binary
|
||||||
|
// two-star orbit / split axis / companion star) use for their per-system
|
||||||
|
// variation: phase (flare rhythm / orbit start), angle (split axis /
|
||||||
|
// wanderer heading), drift (wanderer speed). A dedicated fork
|
||||||
|
// (seed, 'system', id, 'fx') keeps it independent of the draws above,
|
||||||
|
// so lazy === eager. Stamped on every record (cheap) — only the live
|
||||||
|
// effect families read it.
|
||||||
|
const fxRng = Rng.derive(galaxy.seed, 'system', record.id, 'fx');
|
||||||
|
const fx = {
|
||||||
|
phase: fxRng.next(),
|
||||||
|
angle: fxRng.next(),
|
||||||
|
drift: fxRng.next(),
|
||||||
|
};
|
||||||
|
|
||||||
const content = {
|
const content = {
|
||||||
name: record.name,
|
name: record.name,
|
||||||
type: record.type,
|
type: record.type,
|
||||||
|
|
@ -290,6 +305,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
hazard,
|
hazard,
|
||||||
jumps, // the system's jump gates (1–3; [] for a one-system galaxy)
|
jumps, // the system's jump gates (1–3; [] for a one-system galaxy)
|
||||||
atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems)
|
atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems)
|
||||||
|
fx, // per-system effect variation (flare phase / orbit / split / wanderer)
|
||||||
};
|
};
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
content.homeName = homeName; // the player's home world (starting system only)
|
content.homeName = homeName; // the player's home world (starting system only)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ import { JumpGate } from '../entities/JumpGate.js';
|
||||||
import { jumpArrival } from '../galaxy/JumpTravel.js';
|
import { jumpArrival } from '../galaxy/JumpTravel.js';
|
||||||
import { Starfield } from '../visuals/Starfield.js';
|
import { Starfield } from '../visuals/Starfield.js';
|
||||||
import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js';
|
import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js';
|
||||||
|
import { EmberField } from '../visuals/EmberField.js';
|
||||||
|
import { WandererStar } from '../visuals/WandererStar.js';
|
||||||
|
import { RedHaze } from '../visuals/RedHaze.js';
|
||||||
import { SystemEffects } from '../visuals/SystemEffects.js';
|
import { SystemEffects } from '../visuals/SystemEffects.js';
|
||||||
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
|
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
|
||||||
import { pickFxSystem } from '../galaxy/FxSystems.js';
|
import { pickFxSystem } from '../galaxy/FxSystems.js';
|
||||||
|
|
@ -613,6 +616,25 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.nebula.create();
|
this.nebula.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-system CONTENT effects (world-space sprites, Canvas-safe):
|
||||||
|
// - redDwarf ember dust (effect.particles) → EmberField (depth 3)
|
||||||
|
// - redDwarf red haze (effect.haze) → RedHaze (depth -1)
|
||||||
|
// - binary companion star (effect.wanderer) → WandererStar (depth 3)
|
||||||
|
// (The camera FILTERS — ripple + grade — are SystemEffects' job.)
|
||||||
|
const _fx = config.get(`systems.types.${this.systemRecord?.type}.effect`, null);
|
||||||
|
if (_fx?.haze) {
|
||||||
|
this.haze = new RedHaze(this, _fx.haze, this.systemContent?.fx ?? {});
|
||||||
|
this.haze.create();
|
||||||
|
}
|
||||||
|
if (_fx?.particles) {
|
||||||
|
this.embers = new EmberField(this, _fx.particles.color, _fx.particles, this.systemContent?.fx ?? {});
|
||||||
|
this.embers.create();
|
||||||
|
}
|
||||||
|
if (_fx?.wanderer) {
|
||||||
|
this.wanderer = new WandererStar(this, _fx.wanderer.color, _fx.wanderer, this.systemContent?.fx ?? {});
|
||||||
|
this.wanderer.create();
|
||||||
|
}
|
||||||
|
|
||||||
// The TETHER — the player's range. In the STARTING system it starts
|
// The TETHER — the player's range. In the STARTING system it starts
|
||||||
// as one level-1 tether anchored on the home world (data/tether.json):
|
// as one level-1 tether anchored on the home world (data/tether.json):
|
||||||
// the ship may fly anywhere within its rim (level 1 = 5120 px from the
|
// the ship may fly anywhere within its rim (level 1 = 5120 px from the
|
||||||
|
|
@ -1294,9 +1316,11 @@ export class GameScene extends Phaser.Scene {
|
||||||
/**
|
/**
|
||||||
* The effects demo's archetype (js/visuals/SystemEffects.js): read the
|
* The effects demo's archetype (js/visuals/SystemEffects.js): read the
|
||||||
* `?fx=` URL parameter (browser only — no-op in Node/tests) and keep it
|
* `?fx=` URL parameter (browser only — no-op in Node/tests) and keep it
|
||||||
* only if it names a known system type (data/systems.json → types).
|
* only if it names a known system type (data/systems.json → types),
|
||||||
* `?fx` alone defaults to `nebula` — the one effect family wired up so
|
* matched case-insensitively (so `?fx=redDwarf` resolves to the camelCase
|
||||||
* far. Example: http://localhost:8080/?fx=nebula
|
* `redDwarf` key). `?fx` alone defaults to `nebula`. The live families
|
||||||
|
* today: nebula, redDwarf, binary.
|
||||||
|
* Example: http://localhost:8080/?fx=redDwarf
|
||||||
*
|
*
|
||||||
* @returns {string | null} a systems.types key, or null
|
* @returns {string | null} a systems.types key, or null
|
||||||
*/
|
*/
|
||||||
|
|
@ -1304,12 +1328,16 @@ export class GameScene extends Phaser.Scene {
|
||||||
const loc = typeof globalThis !== 'undefined' ? globalThis.location : null;
|
const loc = typeof globalThis !== 'undefined' ? globalThis.location : null;
|
||||||
if (!loc?.search) return null;
|
if (!loc?.search) return null;
|
||||||
try {
|
try {
|
||||||
const raw = new URLSearchParams(loc.search).get('fx') ?? '';
|
const raw = (new URLSearchParams(loc.search).get('fx') ?? '').trim() || 'nebula';
|
||||||
const type = (raw.trim() || 'nebula').toLowerCase();
|
|
||||||
const types = config.section('systems.types', {});
|
const types = config.section('systems.types', {});
|
||||||
return types && typeof types === 'object' && Object.prototype.hasOwnProperty.call(types, type)
|
if (!types || typeof types !== 'object') return null;
|
||||||
? type
|
// Case-insensitive match on the config keys, so `?fx=redDwarf`,
|
||||||
: null;
|
// `?fx=reddwarf`, `?fx=REDWARf` all resolve to the `redDwarf` type.
|
||||||
|
const wanted = raw.toLowerCase();
|
||||||
|
for (const key of Object.keys(types)) {
|
||||||
|
if (key.toLowerCase() === wanted) return key;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -1660,6 +1688,9 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.updateCamera(delta);
|
this.updateCamera(delta);
|
||||||
this.starfield.update(); // after the camera, so it sees this frame's motion
|
this.starfield.update(); // after the camera, so it sees this frame's motion
|
||||||
this.nebula?.update(); // the gas: parallax + slow self-swirl
|
this.nebula?.update(); // the gas: parallax + slow self-swirl
|
||||||
|
this.haze?.update(_time, delta); // redDwarf red haze: follow the camera
|
||||||
|
this.embers?.update(_time, delta); // redDwarf ember dust: drift + parallax
|
||||||
|
this.wanderer?.update(_time, delta); // binary companion star: slow wander
|
||||||
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
||||||
this.tetherField.draw(_time); // the barrier (on-screen dots only)
|
this.tetherField.draw(_time); // the barrier (on-screen dots only)
|
||||||
this.actionBar?.update(_time, delta); // the deck's living details
|
this.actionBar?.update(_time, delta); // the deck's living details
|
||||||
|
|
@ -4268,6 +4299,9 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.stopGameMusic(); // neither can the soundtrack
|
this.stopGameMusic(); // neither can the soundtrack
|
||||||
this.starfield?.destroy();
|
this.starfield?.destroy();
|
||||||
this.nebula?.destroy();
|
this.nebula?.destroy();
|
||||||
|
this.haze?.destroy();
|
||||||
|
this.embers?.destroy();
|
||||||
|
this.wanderer?.destroy();
|
||||||
this.compass?.destroy();
|
this.compass?.destroy();
|
||||||
this.actionBar?.destroy();
|
this.actionBar?.destroy();
|
||||||
this.menuSubBar?.destroy();
|
this.menuSubBar?.destroy();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
/**
|
||||||
|
* redDwarf ember field — a slow drift of tiny warm sparks behind the art.
|
||||||
|
*
|
||||||
|
* Content layer (world-space sprites), NOT a filter: it works in the
|
||||||
|
* Canvas fallback and can never touch the HUD (it is a sprite at depth 3,
|
||||||
|
* like the starfield, so the camera split is irrelevant to it). The sparks
|
||||||
|
* are small, warm, additively blended (they glow like embers) and sit at a
|
||||||
|
* parallax BETWEEN the starfield (0.15–0.85) and the ship — dust in the
|
||||||
|
* air, closer than the stars, farther than you.
|
||||||
|
*
|
||||||
|
* The field FILLs the view and parallaxes like the starfield (the proven
|
||||||
|
* pattern in Starfield.js): embers start in a window around the CURRENT
|
||||||
|
* camera, each frame they shift by (1 − p) of the camera delta (so on
|
||||||
|
* screen they stream opposite to travel, near sparks faster than far
|
||||||
|
* ones) plus their own slow drift, and are wrapped back into the current
|
||||||
|
* view. That keeps them on screen no matter how far the ship flies.
|
||||||
|
*
|
||||||
|
* Config: data/systems.json → types.redDwarf.effect.particles.
|
||||||
|
* Node-testable color/shape math is inline (this file imports Phaser and
|
||||||
|
* is exercised in the browser via dev/server.mjs).
|
||||||
|
*/
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { canvasTexture } from '../utils/Textures.js';
|
||||||
|
import { toColor } from '../utils/Color.js';
|
||||||
|
|
||||||
|
const DEPTH = 3; // behind the art (planets 5, ship 10), in front of starfield
|
||||||
|
const MARGIN = 80; // embers may start/exit this far past the viewport
|
||||||
|
|
||||||
|
/** A small, bright, soft dot — an ember. White so a tint reads as color. */
|
||||||
|
function drawSpark(ctx, size) {
|
||||||
|
const c = size / 2;
|
||||||
|
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
|
||||||
|
g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
|
||||||
|
g.addColorStop(0.35, 'rgba(255,255,255,0.85)');
|
||||||
|
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps any coordinate into [left, left + span) — safe for huge deltas too. */
|
||||||
|
function wrapIn(v, left, span) {
|
||||||
|
return left + ((((v - left) % span) + span) % span);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ember layer.
|
||||||
|
*
|
||||||
|
* @param {object} scene Phaser scene (add / camera / renderer / scale)
|
||||||
|
* @param {string} color ember color (CSS hex)
|
||||||
|
* @param {object} cfg data/systems.json → effect.particles
|
||||||
|
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
|
||||||
|
*/
|
||||||
|
export class EmberField {
|
||||||
|
constructor(scene, color, cfg, fx = {}) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.color = toColor(color, 0xff8a4a);
|
||||||
|
this.cfg = cfg ?? {};
|
||||||
|
this.fx = fx ?? {};
|
||||||
|
this.sprites = [];
|
||||||
|
this.lastScrollX = 0;
|
||||||
|
this.lastScrollY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the spark texture + one sprite per ember (idempotent). */
|
||||||
|
create() {
|
||||||
|
const scene = this.scene;
|
||||||
|
if (this.sprites.length) return;
|
||||||
|
const {
|
||||||
|
count = 40,
|
||||||
|
size = [2, 6],
|
||||||
|
alpha = [0.4, 0.9],
|
||||||
|
parallax = [0.25, 0.5],
|
||||||
|
speed = [8, 25],
|
||||||
|
} = this.cfg;
|
||||||
|
|
||||||
|
const texKey = canvasTexture(scene, 'fx-ember-spark', 32, 32, (ctx) => drawSpark(ctx, 32));
|
||||||
|
const w = scene.scale.width;
|
||||||
|
const h = scene.scale.height;
|
||||||
|
const cam = scene.cameras.main;
|
||||||
|
|
||||||
|
// Embers start in a window around the CURRENT camera view (like the
|
||||||
|
// starfield); update() keeps them wrapped into it as the camera flies.
|
||||||
|
const left = cam.scrollX - MARGIN;
|
||||||
|
const top = cam.scrollY - MARGIN;
|
||||||
|
|
||||||
|
// Seeded per-ember variation (xorshift32 from the system fx, so the
|
||||||
|
// same system looks the same every run).
|
||||||
|
let s = (Math.imul(0x9e3779b1, 2654435761) ^ Math.round((this.fx.angle ?? 0.5) * 1e9)) >>> 0;
|
||||||
|
const rand = () => {
|
||||||
|
s ^= s << 13; s >>>= 0;
|
||||||
|
s ^= s >> 17; s >>>= 0;
|
||||||
|
s ^= s << 5; s >>>= 0;
|
||||||
|
return (s >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
const range = (a, b) => a + (b - a) * rand();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const bx = left + rand() * (w + 2 * MARGIN);
|
||||||
|
const by = top + rand() * (h + 2 * MARGIN);
|
||||||
|
const ang = rand() * Math.PI * 2;
|
||||||
|
const spd = range(speed[0], speed[1]);
|
||||||
|
const sp = scene.add
|
||||||
|
.image(bx, by, texKey)
|
||||||
|
.setDepth(DEPTH)
|
||||||
|
.setTint(this.color)
|
||||||
|
.setAlpha(range(alpha[0], alpha[1]))
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
const sz = range(size[0], size[1]);
|
||||||
|
sp.setScale(sz / 16); // 32px texture, scale to ~size px
|
||||||
|
this.sprites.push({
|
||||||
|
sp,
|
||||||
|
vx: Math.cos(ang) * spd,
|
||||||
|
vy: Math.sin(ang) * spd,
|
||||||
|
p: range(parallax[0], parallax[1]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.lastScrollX = cam.scrollX;
|
||||||
|
this.lastScrollY = cam.scrollY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-frame: parallax (opposite the camera, like the starfield) + drift. */
|
||||||
|
update(nowMs, dtMs) {
|
||||||
|
if (!this.sprites.length) return;
|
||||||
|
const dt = (dtMs || 0) / 1000;
|
||||||
|
const cam = this.scene.cameras.main;
|
||||||
|
const dx = cam.scrollX - this.lastScrollX;
|
||||||
|
const dy = cam.scrollY - this.lastScrollY;
|
||||||
|
this.lastScrollX = cam.scrollX;
|
||||||
|
this.lastScrollY = cam.scrollY;
|
||||||
|
|
||||||
|
const w = this.scene.scale.width;
|
||||||
|
const h = this.scene.scale.height;
|
||||||
|
const spanX = w + 2 * MARGIN;
|
||||||
|
const spanY = h + 2 * MARGIN;
|
||||||
|
const left = cam.scrollX - MARGIN;
|
||||||
|
const top = cam.scrollY - MARGIN;
|
||||||
|
|
||||||
|
for (const e of this.sprites) {
|
||||||
|
// Move by (1 − p) of the camera delta (→ on screen −p of it, opposite
|
||||||
|
// to travel, near sparks faster), plus the ember's own slow drift.
|
||||||
|
e.sp.x += dx * (1 - e.p) + e.vx * dt;
|
||||||
|
e.sp.y += dy * (1 - e.p) + e.vy * dt;
|
||||||
|
// Keep the field full: wrap into the current camera window.
|
||||||
|
e.sp.x = wrapIn(e.sp.x, left, spanX);
|
||||||
|
e.sp.y = wrapIn(e.sp.y, top, spanY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear down. */
|
||||||
|
destroy() {
|
||||||
|
for (const e of this.sprites) e.sp.destroy();
|
||||||
|
this.sprites = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* redDwarf "red haze" — a thin, gritty red cast over the DARKNESS of space
|
||||||
|
* behind the stars.
|
||||||
|
*
|
||||||
|
* The darkness is the renderer's background (config backgroundColor), which
|
||||||
|
* a camera filter does NOT reach (the clear color bypasses the composite).
|
||||||
|
* So this is a CONTENT layer, not a filter: one large, canvas-generated
|
||||||
|
* gritty red-haze texture drawn at depth -1 (BEHIND the starfield's 0–2),
|
||||||
|
* recentered on the camera each frame so it always fills the view. The
|
||||||
|
* white stars and all the art sit in front and stay bright — only the void
|
||||||
|
* turns a dusty red.
|
||||||
|
*
|
||||||
|
* The texture (generated once, seeded per system) is: a dark warm-red base
|
||||||
|
* + a few large soft blotches (the "haze") + fine per-pixel grain (the
|
||||||
|
* "grit"). It is a plain sprite, so it is Canvas-safe and can never touch
|
||||||
|
* the HUD (it is world-space, behind everything, independent of the UI split).
|
||||||
|
*
|
||||||
|
* Config: data/systems.json → types.redDwarf.effect.haze.
|
||||||
|
*/
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { canvasTexture } from '../utils/Textures.js';
|
||||||
|
import { hexToRgb01 } from './SystemEffectsMath.js';
|
||||||
|
|
||||||
|
const DEPTH = -1; // behind the starfield (0–2) — the very back
|
||||||
|
const MARGIN = 320; // the haze extends this far past the viewport (covers drift)
|
||||||
|
|
||||||
|
/** A seeded 32-bit PRNG (mulberry32) so a system's haze is stable per run. */
|
||||||
|
function makeRng(seedNum) {
|
||||||
|
let a = (seedNum >>> 0) || 0x9e3779b9;
|
||||||
|
return () => {
|
||||||
|
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paint one haze tile (SIZE×SIZE): dark base + soft blotches + fine grain.
|
||||||
|
* All from the config; deterministic under `rng`.
|
||||||
|
*/
|
||||||
|
function paintHaze(ctx, size, cfg, rng) {
|
||||||
|
const [br, bg, bb] = hexToRgb01(cfg.base ?? '#160b09');
|
||||||
|
const [tr, tg, tb] = hexToRgb01(cfg.tint ?? '#c0392b');
|
||||||
|
const to255 = (v) => Math.max(0, Math.min(255, Math.round(v * 255)));
|
||||||
|
|
||||||
|
// 1. dark warm-red base (this IS the "darkness", so it reads as space).
|
||||||
|
ctx.fillStyle = `rgb(${to255(br)}, ${to255(bg)}, ${to255(bb)})`;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// 2. soft atmospheric blotches — the "haze" (large, low-contrast).
|
||||||
|
const blobs = Math.max(3, Math.round(cfg.blobs ?? 9));
|
||||||
|
const [sMin, sMax] = Array.isArray(cfg.scale) ? cfg.scale : [0.18, 0.42];
|
||||||
|
for (let i = 0; i < blobs; i++) {
|
||||||
|
const x = rng() * size;
|
||||||
|
const y = rng() * size;
|
||||||
|
const r = size * (sMin + (sMax - sMin) * rng());
|
||||||
|
const warm = 0.55 + 0.45 * rng();
|
||||||
|
const a = 0.10 + 0.16 * rng();
|
||||||
|
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||||
|
g.addColorStop(0, `rgba(${to255(tr * warm)}, ${to255(tg * warm)}, ${to255(tb * warm)}, ${a.toFixed(3)})`);
|
||||||
|
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. fine grain — the "grit" (per-pixel, warm-biased).
|
||||||
|
const amp = Math.max(0, cfg.grain ?? 20);
|
||||||
|
if (amp > 0) {
|
||||||
|
const img = ctx.getImageData(0, 0, size, size);
|
||||||
|
const d = img.data;
|
||||||
|
for (let i = 0; i < d.length; i += 4) {
|
||||||
|
const n = (rng() - 0.5) * 2 * amp;
|
||||||
|
d[i] = Math.max(0, Math.min(255, d[i] + n));
|
||||||
|
d[i + 1] = Math.max(0, Math.min(255, d[i + 1] + n * 0.62));
|
||||||
|
d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + n * 0.46));
|
||||||
|
}
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The red-haze layer (a single full-screen sprite behind the stars).
|
||||||
|
*
|
||||||
|
* @param {object} scene Phaser scene (add / camera / scale / textures)
|
||||||
|
* @param {object} cfg data/systems.json → effect.haze
|
||||||
|
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
|
||||||
|
*/
|
||||||
|
export class RedHaze {
|
||||||
|
constructor(scene, cfg, fx = {}) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.cfg = cfg ?? {};
|
||||||
|
this.fx = fx ?? {};
|
||||||
|
this.sp = null;
|
||||||
|
this.tSec = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the texture + sprite (idempotent). */
|
||||||
|
create() {
|
||||||
|
const scene = this.scene;
|
||||||
|
if (this.sp) return;
|
||||||
|
const SIZE = 1024;
|
||||||
|
// Fixed seed: the haze is a shared, subtle background, so it is
|
||||||
|
// consistent across systems and runs (the texture is cached by key).
|
||||||
|
const texKey = canvasTexture(scene, 'fx-redhaze', SIZE, SIZE, (ctx) => {
|
||||||
|
paintHaze(ctx, SIZE, this.cfg, makeRng(0x51ab1234));
|
||||||
|
});
|
||||||
|
const w = scene.scale.width;
|
||||||
|
const h = scene.scale.height;
|
||||||
|
const cam = scene.cameras.main;
|
||||||
|
// One large sprite, viewport + margin, centered on the camera.
|
||||||
|
this.sp = scene.add
|
||||||
|
.image(cam.scrollX + w / 2, cam.scrollY + h / 2, texKey)
|
||||||
|
.setDepth(DEPTH)
|
||||||
|
.setAlpha(this.cfg.alpha ?? 0.9)
|
||||||
|
.setScale((w + 2 * MARGIN) / SIZE, (h + 2 * MARGIN) / SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-frame: keep the haze centered on the camera (with a slow drift). */
|
||||||
|
update(nowMs, dtMs) {
|
||||||
|
if (!this.sp) return;
|
||||||
|
this.tSec += (dtMs || 0) / 1000;
|
||||||
|
const cam = this.scene.cameras.main;
|
||||||
|
const w = this.scene.scale.width;
|
||||||
|
const h = this.scene.scale.height;
|
||||||
|
const t = this.tSec;
|
||||||
|
// A very slow drift so the haze feels alive (amplitude < MARGIN, so it
|
||||||
|
// always still covers the view).
|
||||||
|
const dx = Math.sin(t * 0.05 + this.fx.phase * 6.28) * 120;
|
||||||
|
const dy = Math.cos(t * 0.04 + this.fx.drift * 6.28) * 120;
|
||||||
|
this.sp.x = cam.scrollX + w / 2 + dx;
|
||||||
|
this.sp.y = cam.scrollY + h / 2 + dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear down. */
|
||||||
|
destroy() {
|
||||||
|
this.sp?.destroy();
|
||||||
|
this.sp = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,59 +1,67 @@
|
||||||
/**
|
/**
|
||||||
* Per-system visual effects — the star's "character", rendered as a
|
* Per-system visual effects — the star's "character".
|
||||||
* full-screen composite over the WORLD camera.
|
*
|
||||||
|
* An effect is a BUNDLE of sub-effects, all data-driven off
|
||||||
|
* data/systems.json → types.<type>.effect (tuning = a JSON edit). Two kinds:
|
||||||
|
*
|
||||||
|
* CAMERA FILTERS (WebGL; the HUD is excluded by the UI-camera split):
|
||||||
|
* - ripple: concentric waves, 1 or 2 centers. One center (nebula,
|
||||||
|
* redDwarf) radiates from the view middle ("screen") or from the
|
||||||
|
* star's direction ("star"); two centers can orbit each other
|
||||||
|
* (orbit.radius/period). Every sub-effect is OPTIONAL — a type wears
|
||||||
|
* exactly what it lists (binary, for example, has only the grade +
|
||||||
|
* the wandering star — no ripple at all).
|
||||||
|
* - grade: a per-pixel color grade — tint + saturation + brightness,
|
||||||
|
* or a two-color directional split, plus a flare flash (redDwarf).
|
||||||
|
*
|
||||||
|
* CONTENT (world-space sprites, Canvas-safe, behind the art at depth 3):
|
||||||
|
* - particles (redDwarf ember dust) → js/visuals/EmberField.js
|
||||||
|
* - wanderer (binary companion star) → js/visuals/WandererStar.js
|
||||||
|
* (Those are created by GameScene, not here — this file owns the
|
||||||
|
* camera filters.)
|
||||||
*
|
*
|
||||||
* Why camera-level (not per-object): a system is a PLACE. Its effect
|
* Why camera-level (not per-object): a system is a PLACE. Its effect
|
||||||
* should bend the light of everything in it — the starfield included —
|
* should bend the light of everything in it — the starfield included —
|
||||||
* as one continuous spacetime distortion. Per-body filters would need
|
* as one continuous distortion. Per-body filters would need one composite
|
||||||
* one composite pass per body (200+ starfield sprites, no) and the
|
* pass per body (200+ starfield sprites, no) and the stars could never
|
||||||
* stars could never warp. One camera filter = one full-screen pass,
|
* warp. One or two camera filters = a couple of full-screen passes,
|
||||||
* constant cost, whole scene coheres. The HUD is excluded by drawing it
|
* constant cost, whole scene coheres. The HUD is excluded by drawing it
|
||||||
* on a separate, unfiltered camera (js/visuals/UiCameras.js).
|
* on a separate, unfiltered camera (js/visuals/UiCameras.js).
|
||||||
*
|
*
|
||||||
* The effect itself is DATA-DRIVEN: every system type carries an
|
* The "flare" (redDwarf) is a shared signal: one seeded rhythm drives BOTH
|
||||||
* `effect` block in data/systems.json → types.<id>.effect:
|
* the ripple's amplitude surge AND the grade's brightness flash, so the
|
||||||
* "effect": {
|
* wave and the light flash together — the star flaring.
|
||||||
* "kind": "ripple", // effect family (today: "ripple" | "none")
|
|
||||||
* "strength": 90, // wave cycles across the screen (1/UV)
|
|
||||||
* "amplitude": 0.010, // displacement, in UV (fraction of screen)
|
|
||||||
* "speed": 1.0, // phase advance, radians/second
|
|
||||||
* "padding": 16 // px of extra framebuffer the filter may sample
|
|
||||||
* }
|
|
||||||
* `kind: "none"` (or a missing block) renders the system untouched —
|
|
||||||
* the common case, zero cost (no filter attached, no extra pass).
|
|
||||||
*
|
*
|
||||||
* The RIPPLE: concentric waves radiating from a CENTER and traveling
|
* WebGL only for the filters. On the canvas renderer the filters are a
|
||||||
* outward. Two anchors (data-driven, `effect.center`):
|
* no-op (the game degrades to no filter, never to a crash); the CONTENT
|
||||||
* "screen" (default) — the middle of the view: the sandbox look,
|
* sub-effects still render (they are plain sprites).
|
||||||
* always visible while you fly; the star itself is invisible
|
|
||||||
* flavor, so the shimmer is the star's character, not a map of
|
|
||||||
* where it sits;
|
|
||||||
* "star" — the system origin (world 0,0), tracked each frame: the
|
|
||||||
* wavefronts arrive from the star's direction, which is usually
|
|
||||||
* off-screen (the ship spawns at a world, not the star) — a
|
|
||||||
* subtler, directional shimmer.
|
|
||||||
* Nebula systems wear it: young, bright, still messy — the light
|
|
||||||
* itself is unsettled.
|
|
||||||
*
|
|
||||||
* WebGL only. On the canvas renderer apply() is a no-op — the game
|
|
||||||
* degrades to no effect, never to a crash.
|
|
||||||
*
|
*
|
||||||
* Node-testable math lives in SystemEffectsMath.js (this file imports
|
* Node-testable math lives in SystemEffectsMath.js (this file imports
|
||||||
* Phaser and is exercised in the browser via dev/server.mjs).
|
* Phaser and is exercised in the browser via dev/server.mjs).
|
||||||
*/
|
*/
|
||||||
import Phaser from '../vendor/phaser.js';
|
import Phaser from '../vendor/phaser.js';
|
||||||
import { config } from '../config/Config.js';
|
import { config } from '../config/Config.js';
|
||||||
import { ripplePhase, worldToUV } from './SystemEffectsMath.js';
|
import {
|
||||||
|
ripplePhase,
|
||||||
|
worldToUV,
|
||||||
|
flareIntensity,
|
||||||
|
orbitCenters,
|
||||||
|
hexToRgb01,
|
||||||
|
tintMultiplier,
|
||||||
|
} from './SystemEffectsMath.js';
|
||||||
import { ensureUiCameras } from './UiCameras.js';
|
import { ensureUiCameras } from './UiCameras.js';
|
||||||
|
|
||||||
/** Registered render-node name for the ripple effect (renderer-global). */
|
/** Registered render-node names (renderer-global). */
|
||||||
export const RIPPLE_NODE = 'FilterRippleEffect';
|
export const RIPPLE_NODE = 'FilterRippleEffect';
|
||||||
|
export const GRADE_NODE = 'FilterGradeEffect';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ripple fragment shader — Phaser 4 filter-shader conventions
|
* The ripple fragment shader — generalized to up to TWO centers. Each
|
||||||
* (verified against the vendored build's own filter shaders):
|
* center contributes a traveling radial wave (phase moves outward); the
|
||||||
* `uMainSampler` is the input frame, `outTexCoord` the screen UV, and
|
* total displacement is the sum. A center with amplitude 0 is inert, so a
|
||||||
* `boundedSampler` is auto-injected via the BoundedSampler addition.
|
* one-center system (nebula, redDwarf) is the degenerate case. Phaser 4
|
||||||
|
* filter-shader conventions: `uMainSampler` is the input frame,
|
||||||
|
* `outTexCoord` the screen UV, `boundedSampler` auto-injected.
|
||||||
*/
|
*/
|
||||||
const RIPPLE_FRAGMENT = [
|
const RIPPLE_FRAGMENT = [
|
||||||
'#pragma phaserTemplate(shaderName)',
|
'#pragma phaserTemplate(shaderName)',
|
||||||
|
|
@ -61,34 +69,130 @@ const RIPPLE_FRAGMENT = [
|
||||||
'uniform sampler2D uMainSampler;',
|
'uniform sampler2D uMainSampler;',
|
||||||
'uniform float time;',
|
'uniform float time;',
|
||||||
'uniform float strength;',
|
'uniform float strength;',
|
||||||
'uniform float amplitude;',
|
'uniform float amp0;',
|
||||||
'uniform float centerX;',
|
'uniform float amp1;',
|
||||||
'uniform float centerY;',
|
'uniform float cx0;',
|
||||||
|
'uniform float cy0;',
|
||||||
|
'uniform float cx1;',
|
||||||
|
'uniform float cy1;',
|
||||||
'varying vec2 outTexCoord;',
|
'varying vec2 outTexCoord;',
|
||||||
'#pragma phaserTemplate(fragmentHeader)',
|
'#pragma phaserTemplate(fragmentHeader)',
|
||||||
'void main()',
|
'void main()',
|
||||||
'{',
|
'{',
|
||||||
' vec2 center = vec2(centerX, centerY);',
|
' vec2 disp = vec2(0.0);',
|
||||||
' vec2 delta = outTexCoord - center;',
|
// center 0
|
||||||
' float dist = length(delta);',
|
' vec2 d0 = outTexCoord - vec2(cx0, cy0);',
|
||||||
' float invDist = dist > 0.0001 ? 1.0 / dist : 0.0;',
|
' float r0 = length(d0);',
|
||||||
' vec2 dir = delta * invDist;',
|
' if (amp0 > 0.0001 && r0 > 0.0001) {',
|
||||||
' // Traveling rings: phase moves OUTWARD from the center (screen',
|
' vec2 dir0 = d0 / r0;',
|
||||||
' // middle by default — the star itself is invisible flavor).',
|
' float w0 = sin(r0 * strength - time);',
|
||||||
' float wave = sin(dist * strength - time);',
|
' float f0 = smoothstep(0.0, 0.02, r0) * (0.8 + 0.2 * exp(-r0 * 0.25));',
|
||||||
' // Attenuation: zero displacement exactly on the center, a gentle',
|
' disp += dir0 * (w0 * amp0 * f0);',
|
||||||
' // calm-down toward the corners. Screen-anchored: dist stays within',
|
' }',
|
||||||
' // ~0..0.7, so the rings read strong across the view.',
|
// center 1
|
||||||
' float fade = smoothstep(0.0, 0.02, dist) * (0.8 + 0.2 * exp(-dist * 0.25));',
|
' vec2 d1 = outTexCoord - vec2(cx1, cy1);',
|
||||||
' vec2 uv = outTexCoord + dir * (wave * amplitude * fade);',
|
' float r1 = length(d1);',
|
||||||
' gl_FragColor = boundedSampler(uMainSampler, uv);',
|
' if (amp1 > 0.0001 && r1 > 0.0001) {',
|
||||||
|
' vec2 dir1 = d1 / r1;',
|
||||||
|
' float w1 = sin(r1 * strength - time);',
|
||||||
|
' float f1 = smoothstep(0.0, 0.02, r1) * (0.8 + 0.2 * exp(-r1 * 0.25));',
|
||||||
|
' disp += dir1 * (w1 * amp1 * f1);',
|
||||||
|
' }',
|
||||||
|
' gl_FragColor = boundedSampler(uMainSampler, outTexCoord + disp);',
|
||||||
'}',
|
'}',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The filter's state carrier (Phaser.Filters.Controller) — the values
|
* The grade fragment shader — per-pixel color grade (the exact math of
|
||||||
* the shader reads each pass. Plain data: time (radians), strength
|
* gradeColor in SystemEffectsMath.js, as a thin GLSL wrapper):
|
||||||
* (cycles/UV), amplitude (UV), and the star's screen UV.
|
* brightness → saturation → tint → directional split → flare flash.
|
||||||
|
* All uniforms are scalars (no vec packing) for portability.
|
||||||
|
*/
|
||||||
|
const GRADE_FRAGMENT = [
|
||||||
|
'#pragma phaserTemplate(shaderName)',
|
||||||
|
'precision mediump float;',
|
||||||
|
'uniform sampler2D uMainSampler;',
|
||||||
|
'uniform float uBright;',
|
||||||
|
'uniform float uSat;',
|
||||||
|
'uniform float uTintAmt;',
|
||||||
|
'uniform float uTintR;',
|
||||||
|
'uniform float uTintG;',
|
||||||
|
'uniform float uTintB;',
|
||||||
|
'uniform float uSplitMix;',
|
||||||
|
'uniform float uSplitAR;',
|
||||||
|
'uniform float uSplitAG;',
|
||||||
|
'uniform float uSplitAB;',
|
||||||
|
'uniform float uSplitBR;',
|
||||||
|
'uniform float uSplitBG;',
|
||||||
|
'uniform float uSplitBB;',
|
||||||
|
'uniform float uAxisX;',
|
||||||
|
'uniform float uAxisY;',
|
||||||
|
'uniform float uFlash;',
|
||||||
|
'uniform float uFlashR;',
|
||||||
|
'uniform float uFlashG;',
|
||||||
|
'uniform float uFlashB;',
|
||||||
|
'uniform float uLiftAmt;',
|
||||||
|
'uniform float uLiftR;',
|
||||||
|
'uniform float uLiftG;',
|
||||||
|
'uniform float uLiftB;',
|
||||||
|
'uniform float uGrainAmt;',
|
||||||
|
'uniform float uGrainOffX;',
|
||||||
|
'uniform float uGrainOffY;',
|
||||||
|
'varying vec2 outTexCoord;',
|
||||||
|
'#pragma phaserTemplate(fragmentHeader)',
|
||||||
|
'void main()',
|
||||||
|
'{',
|
||||||
|
' vec4 col = boundedSampler(uMainSampler, outTexCoord);',
|
||||||
|
' vec3 rgb = col.rgb;',
|
||||||
|
' rgb *= uBright;',
|
||||||
|
' if (uSat != 1.0) {',
|
||||||
|
' float l = dot(rgb, vec3(0.299, 0.587, 0.114));',
|
||||||
|
' rgb = mix(vec3(l), rgb, uSat);',
|
||||||
|
' }',
|
||||||
|
' if (uTintAmt > 0.0) {',
|
||||||
|
' vec3 tint = vec3(uTintR, uTintG, uTintB);',
|
||||||
|
' rgb = mix(rgb, rgb * tint, uTintAmt);',
|
||||||
|
' }',
|
||||||
|
' // Shadow lift: push the DARKNESS toward the lift hue (the red void),',
|
||||||
|
' // leaving bright pixels (the stars) alone. Luminance-gated.',
|
||||||
|
' if (uLiftAmt > 0.0) {',
|
||||||
|
' float lum2 = dot(rgb, vec3(0.299, 0.587, 0.114));',
|
||||||
|
' float darkAmt = (1.0 - clamp(lum2 * 3.0, 0.0, 1.0)) * uLiftAmt;',
|
||||||
|
' rgb += vec3(uLiftR, uLiftG, uLiftB) * darkAmt;',
|
||||||
|
' }',
|
||||||
|
' // Gritty grain: fine, warm, only in the dark (a thin red haze). The',
|
||||||
|
' // coordinate is offset by the camera scroll (uGrainOff) so the grain',
|
||||||
|
' // sits at a DEPTH in the air — it shifts slightly as you fly, instead',
|
||||||
|
' // of being stuck to the lens (a subtle parallax).',
|
||||||
|
' if (uGrainAmt > 0.0) {',
|
||||||
|
' float lum3 = dot(rgb, vec3(0.299, 0.587, 0.114));',
|
||||||
|
' float gdark = 1.0 - clamp(lum3 * 3.0, 0.0, 1.0);',
|
||||||
|
' vec2 gp = gl_FragCoord.xy + vec2(uGrainOffX, uGrainOffY);',
|
||||||
|
' float gnoise = fract(sin(dot(gp, vec2(12.9898, 78.233))) * 43758.5453);',
|
||||||
|
' rgb += (gnoise - 0.5) * uGrainAmt * gdark * vec3(1.0, 0.55, 0.45);',
|
||||||
|
' }',
|
||||||
|
' if (uSplitMix > 0.0) {',
|
||||||
|
' float alen = length(vec2(uAxisX, uAxisY));',
|
||||||
|
' vec2 an = alen > 0.0001 ? vec2(uAxisX, uAxisY) / alen : vec2(1.0, 0.0);',
|
||||||
|
' float s = dot(outTexCoord - vec2(0.5, 0.5), an) * 2.0;',
|
||||||
|
' s = clamp(s * 0.5 + 0.5, 0.0, 1.0);',
|
||||||
|
' vec3 a = vec3(uSplitAR, uSplitAG, uSplitAB);',
|
||||||
|
' vec3 b = vec3(uSplitBR, uSplitBG, uSplitBB);',
|
||||||
|
' rgb = mix(rgb, rgb * (b + (a - b) * s), uSplitMix);',
|
||||||
|
' }',
|
||||||
|
' if (uFlash > 0.0) rgb += vec3(uFlashR, uFlashG, uFlashB) * uFlash;',
|
||||||
|
' gl_FragColor = vec4(rgb, col.a);',
|
||||||
|
'}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RIPPLE
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ripple's state carrier (Phaser.Filters.Controller) — the values the
|
||||||
|
* shader reads each pass: time (radians), strength (cycles/UV), and the
|
||||||
|
* two centers' UVs + amplitudes. Plain data.
|
||||||
*
|
*
|
||||||
* @extends {Phaser.Filters.Controller}
|
* @extends {Phaser.Filters.Controller}
|
||||||
*/
|
*/
|
||||||
|
|
@ -96,19 +200,30 @@ class RippleController extends Phaser.Filters.Controller {
|
||||||
/** @param {object} camera the world camera */
|
/** @param {object} camera the world camera */
|
||||||
constructor(camera) {
|
constructor(camera) {
|
||||||
super(camera, RIPPLE_NODE);
|
super(camera, RIPPLE_NODE);
|
||||||
|
// uniforms
|
||||||
this.time = 0;
|
this.time = 0;
|
||||||
this.strength = 90;
|
this.strength = 18;
|
||||||
this.amplitude = 0.01;
|
this.amp0 = 0.004;
|
||||||
|
this.amp1 = 0;
|
||||||
|
this.cx0 = 0.5;
|
||||||
|
this.cy0 = 0.5;
|
||||||
|
this.cx1 = 0.5;
|
||||||
|
this.cy1 = 0.5;
|
||||||
|
// parameters (set at apply(), read in update())
|
||||||
|
this.baseAmp = 0.004;
|
||||||
this.speed = 1;
|
this.speed = 1;
|
||||||
this.centerX = 0.5;
|
this.anchor = 'screen';
|
||||||
this.centerY = 0.5;
|
this.centers = 1;
|
||||||
|
this.orbit = null; // { radius, period } (binary)
|
||||||
|
this.flare = null; // { interval, duration, rippleBoost } (redDwarf)
|
||||||
|
this.phase = 0; // per-system 0..1 (flare rhythm / orbit start)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ripple render node (Phaser.Renderer.WebGL.RenderNodes.
|
* The ripple render node (BaseFilterShader) — a full-screen quad through
|
||||||
* BaseFilterShader) — a full-screen quad through which the camera's
|
* which the camera's composited frame flows, displaced per the
|
||||||
* composited frame flows, displaced per the controller's uniforms.
|
* controller's uniforms.
|
||||||
*
|
*
|
||||||
* @extends {Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader}
|
* @extends {Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader}
|
||||||
*/
|
*/
|
||||||
|
|
@ -123,70 +238,206 @@ class FilterRippleEffect extends Phaser.Renderer.WebGL.RenderNodes.BaseFilterSha
|
||||||
const pm = this.programManager;
|
const pm = this.programManager;
|
||||||
pm.setUniform('time', controller.time);
|
pm.setUniform('time', controller.time);
|
||||||
pm.setUniform('strength', controller.strength);
|
pm.setUniform('strength', controller.strength);
|
||||||
pm.setUniform('amplitude', controller.amplitude);
|
pm.setUniform('amp0', controller.amp0);
|
||||||
pm.setUniform('centerX', controller.centerX);
|
pm.setUniform('amp1', controller.amp1);
|
||||||
pm.setUniform('centerY', controller.centerY);
|
pm.setUniform('cx0', controller.cx0);
|
||||||
|
pm.setUniform('cy0', controller.cy0);
|
||||||
|
pm.setUniform('cx1', controller.cx1);
|
||||||
|
pm.setUniform('cy1', controller.cy1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the ripple node with the renderer (idempotent — the registry
|
* Register the ripple node (idempotent — the registry is renderer-global
|
||||||
* is renderer-global and throws on a duplicate constructor).
|
* and throws on a duplicate constructor).
|
||||||
*
|
*
|
||||||
* @param {object} renderer Phaser renderer
|
* @param {object} renderer Phaser renderer
|
||||||
* @returns {boolean} true if the node is registered after the call
|
* @returns {boolean} true if the node is registered after the call
|
||||||
*/
|
*/
|
||||||
export function ensureRippleNode(renderer) {
|
export function ensureRippleNode(renderer) {
|
||||||
const nodes = renderer?.renderNodes;
|
return registerNode(renderer, RIPPLE_NODE, FilterRippleEffect);
|
||||||
if (!nodes || typeof nodes.hasNode !== 'function') return false;
|
}
|
||||||
if (nodes.hasNode(RIPPLE_NODE)) return true;
|
|
||||||
try {
|
// ---------------------------------------------------------------------------
|
||||||
nodes.addNodeConstructor(RIPPLE_NODE, FilterRippleEffect);
|
// GRADE
|
||||||
return true;
|
// ---------------------------------------------------------------------------
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[orbit] could not register the ripple filter node: ${err}`);
|
/**
|
||||||
return false;
|
* The grade's state carrier — the per-pixel color-grade values the shader
|
||||||
|
* reads. The static parts (bright/sat/tint/split) are set once at apply();
|
||||||
|
* the flare flash (uFlash) is advanced each frame from the shared flare.
|
||||||
|
*
|
||||||
|
* @extends {Phaser.Filters.Controller}
|
||||||
|
*/
|
||||||
|
class GradeController extends Phaser.Filters.Controller {
|
||||||
|
/** @param {object} camera the world camera */
|
||||||
|
constructor(camera) {
|
||||||
|
super(camera, GRADE_NODE);
|
||||||
|
// uniforms (all scalars)
|
||||||
|
this.uBright = 1;
|
||||||
|
this.uSat = 1;
|
||||||
|
this.uTintAmt = 0;
|
||||||
|
this.uTintR = 1;
|
||||||
|
this.uTintG = 1;
|
||||||
|
this.uTintB = 1;
|
||||||
|
this.uSplitMix = 0;
|
||||||
|
this.uSplitAR = 1;
|
||||||
|
this.uSplitAG = 1;
|
||||||
|
this.uSplitAB = 1;
|
||||||
|
this.uSplitBR = 1;
|
||||||
|
this.uSplitBG = 1;
|
||||||
|
this.uSplitBB = 1;
|
||||||
|
this.uAxisX = 1;
|
||||||
|
this.uAxisY = 0;
|
||||||
|
this.uFlash = 0;
|
||||||
|
this.uFlashR = 1;
|
||||||
|
this.uFlashG = 1;
|
||||||
|
this.uFlashB = 1;
|
||||||
|
// shadow lift (the red void) + gritty grain — redDwarf; 0 = off
|
||||||
|
this.uLiftAmt = 0;
|
||||||
|
this.uLiftR = 0.75;
|
||||||
|
this.uLiftG = 0.22;
|
||||||
|
this.uLiftB = 0.17;
|
||||||
|
this.uGrainAmt = 0;
|
||||||
|
this.uGrainOffX = 0;
|
||||||
|
this.uGrainOffY = 0;
|
||||||
|
// grain parallax (0 = stuck to the lens; >0 = shifts as the camera moves)
|
||||||
|
this.grainParallax = 0;
|
||||||
|
// parameter for update(): the flare's base flash strength (0..1)
|
||||||
|
this.flareFlash = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The facade GameScene drives: one effect per system type, applied on
|
* The grade render node (BaseFilterShader).
|
||||||
* entry, released on exit. Holds NO scene-lifetime assumptions of its
|
*
|
||||||
* own (the scene's shutdown() calls release()).
|
* @extends {Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader}
|
||||||
|
*/
|
||||||
|
class FilterGradeEffect extends Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader {
|
||||||
|
/** @param {object} manager the RenderNodes manager */
|
||||||
|
constructor(manager) {
|
||||||
|
super(GRADE_NODE, manager, null, GRADE_FRAGMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push this pass's controller values into the shader. */
|
||||||
|
setupUniforms(controller, _drawingContext) {
|
||||||
|
const pm = this.programManager;
|
||||||
|
const c = controller;
|
||||||
|
pm.setUniform('uBright', c.uBright);
|
||||||
|
pm.setUniform('uSat', c.uSat);
|
||||||
|
pm.setUniform('uTintAmt', c.uTintAmt);
|
||||||
|
pm.setUniform('uTintR', c.uTintR);
|
||||||
|
pm.setUniform('uTintG', c.uTintG);
|
||||||
|
pm.setUniform('uTintB', c.uTintB);
|
||||||
|
pm.setUniform('uSplitMix', c.uSplitMix);
|
||||||
|
pm.setUniform('uSplitAR', c.uSplitAR);
|
||||||
|
pm.setUniform('uSplitAG', c.uSplitAG);
|
||||||
|
pm.setUniform('uSplitAB', c.uSplitAB);
|
||||||
|
pm.setUniform('uSplitBR', c.uSplitBR);
|
||||||
|
pm.setUniform('uSplitBG', c.uSplitBG);
|
||||||
|
pm.setUniform('uSplitBB', c.uSplitBB);
|
||||||
|
pm.setUniform('uAxisX', c.uAxisX);
|
||||||
|
pm.setUniform('uAxisY', c.uAxisY);
|
||||||
|
pm.setUniform('uFlash', c.uFlash);
|
||||||
|
pm.setUniform('uFlashR', c.uFlashR);
|
||||||
|
pm.setUniform('uFlashG', c.uFlashG);
|
||||||
|
pm.setUniform('uFlashB', c.uFlashB);
|
||||||
|
pm.setUniform('uLiftAmt', c.uLiftAmt);
|
||||||
|
pm.setUniform('uLiftR', c.uLiftR);
|
||||||
|
pm.setUniform('uLiftG', c.uLiftG);
|
||||||
|
pm.setUniform('uLiftB', c.uLiftB);
|
||||||
|
pm.setUniform('uGrainAmt', c.uGrainAmt);
|
||||||
|
pm.setUniform('uGrainOffX', c.uGrainOffX);
|
||||||
|
pm.setUniform('uGrainOffY', c.uGrainOffY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the grade node (idempotent). */
|
||||||
|
export function ensureGradeNode(renderer) {
|
||||||
|
return registerNode(renderer, GRADE_NODE, FilterGradeEffect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Facade
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a render-node constructor (shared by ripple + grade).
|
||||||
|
*
|
||||||
|
* @param {object} renderer
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Function} ctor
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function registerNode(renderer, name, ctor) {
|
||||||
|
const nodes = renderer?.renderNodes;
|
||||||
|
if (!nodes || typeof nodes.hasNode !== 'function') return false;
|
||||||
|
if (nodes.hasNode(name)) return true;
|
||||||
|
try {
|
||||||
|
nodes.addNodeConstructor(name, ctor);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[orbit] could not register the ${name} filter node: ${err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a finite number from `src[key]`, else `fallback`. */
|
||||||
|
function num(src, key, fallback) {
|
||||||
|
const x = Number(src?.[key]);
|
||||||
|
return Number.isFinite(x) ? x : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The facade GameScene drives: apply the system type's effect bundle on
|
||||||
|
* entry, advance it per frame, release on exit. Holds NO scene-lifetime
|
||||||
|
* assumptions of its own (the scene's shutdown() calls release()).
|
||||||
|
*
|
||||||
|
* Attaches to the WORLD camera (each optional — the type wears what it
|
||||||
|
* lists): the ripple (when the bundle has a `ripple`) and the grade (when
|
||||||
|
* it has a `grade`), both in this file. The content sub-effects
|
||||||
|
* (particles / wanderer) are GameScene's job.
|
||||||
*/
|
*/
|
||||||
export class SystemEffects {
|
export class SystemEffects {
|
||||||
/** @param {object} scene Phaser scene */
|
/** @param {object} scene Phaser scene */
|
||||||
constructor(scene) {
|
constructor(scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.controller = null;
|
this.controller = null; // ripple
|
||||||
|
this.grade = null; // grade
|
||||||
this.kind = null;
|
this.kind = null;
|
||||||
|
this.flare = null; // the shared flare params (or null)
|
||||||
|
this.phase = 0; // per-system 0..1
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True while a filter is attached to the world camera. */
|
/** True while any filter is attached to the world camera. */
|
||||||
get active() {
|
get active() {
|
||||||
return this.controller !== null;
|
return this.controller !== null || this.grade !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the system type's effect (data/systems.json →
|
* Apply the system type's effect bundle (data/systems.json →
|
||||||
* types.<type>.effect). Replaces any current effect. No-op for
|
* types.<type>.effect). Replaces any current effect. No-op for an empty
|
||||||
* `kind: "none"` / missing blocks and for non-WebGL renderers.
|
* bundle and for non-WebGL renderers (the content sub-effects are
|
||||||
|
* independent and still render).
|
||||||
*
|
*
|
||||||
* @param {string} systemType one of systems.types keys
|
* @param {string} systemType one of systems.types keys
|
||||||
* @returns {boolean} whether an effect ended up active
|
* @returns {boolean} whether a filter ended up active
|
||||||
*/
|
*/
|
||||||
apply(systemType) {
|
apply(systemType) {
|
||||||
this.release();
|
this.release();
|
||||||
|
|
||||||
const eff = config.get(`systems.types.${systemType}.effect`, null);
|
const eff = config.get(`systems.types.${systemType}.effect`, null);
|
||||||
const kind = eff?.kind ?? 'none';
|
if (!eff || typeof eff !== 'object') {
|
||||||
if (kind === 'none' || !eff || typeof eff !== 'object') {
|
|
||||||
this.kind = null;
|
this.kind = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (kind !== 'ripple') {
|
|
||||||
// A family we have not implemented yet — degrade to none.
|
// Every sub-effect is optional — a type wears exactly what it lists
|
||||||
console.warn(`[orbit] system effect "${kind}" is not implemented yet — rendering none.`);
|
// (an empty {} bundle means no filter at all).
|
||||||
|
const ripple = eff.ripple;
|
||||||
|
const hasRipple = !!(ripple && Number(ripple.amplitude) > 0);
|
||||||
|
const hasGrade = !!eff.grade;
|
||||||
|
if (!hasRipple && !hasGrade) {
|
||||||
this.kind = null;
|
this.kind = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -194,73 +445,182 @@ export class SystemEffects {
|
||||||
const scene = this.scene;
|
const scene = this.scene;
|
||||||
const renderer = scene.renderer;
|
const renderer = scene.renderer;
|
||||||
if (!renderer || !renderer.gl) {
|
if (!renderer || !renderer.gl) {
|
||||||
// Canvas fallback: no GLSL, no composite — the system renders
|
// Canvas fallback: no GLSL, no composite — the filters are skipped
|
||||||
// untouched rather than broken.
|
// (the content sub-effects still render).
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!ensureRippleNode(renderer)) return false;
|
|
||||||
|
|
||||||
// The UI camera split must exist before the first filtered pass:
|
// The UI camera split must exist before the first filtered pass:
|
||||||
// main draws the world (and the filter displaces it); fx-ui draws
|
// main draws the world (and the filters act on it); fx-ui draws the
|
||||||
// the HUD on top, unfiltered.
|
// HUD on top, unfiltered.
|
||||||
if (!ensureUiCameras(scene)) {
|
if (!ensureUiCameras(scene)) {
|
||||||
console.warn('[orbit] UI camera split failed — the effect would warp the HUD; rendering none.');
|
console.warn('[orbit] UI camera split failed — the effect would warp the HUD; rendering none.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cam = scene.cameras.main;
|
// Per-system variation (SystemGenerator stamps content.fx).
|
||||||
const c = new RippleController(cam);
|
this.phase = Number(scene.systemContent?.fx?.phase) || 0;
|
||||||
c.strength = Number(eff.strength) || 90;
|
this.flare = eff.flare && eff.flare.interval ? eff.flare : null;
|
||||||
c.amplitude = Number(eff.amplitude) || 0.01;
|
|
||||||
c.speed = Number(eff.speed) || 1;
|
|
||||||
const pad = Math.max(4, Math.ceil(Number(eff.padding) || 16));
|
|
||||||
c.setPaddingOverride(-pad, -pad, pad, pad);
|
|
||||||
// Anchor (data-driven): "screen" (default) pins the center to the
|
|
||||||
// middle of the view — the rings are always visible while you fly.
|
|
||||||
// "star" tracks the system origin (world 0,0) — the wavefronts
|
|
||||||
// arrive from the star's (usually off-screen) direction.
|
|
||||||
c.center = eff.center === 'star' ? 'star' : 'screen';
|
|
||||||
if (c.center === 'screen') {
|
|
||||||
c.centerX = 0.5;
|
|
||||||
c.centerY = 0.5;
|
|
||||||
}
|
|
||||||
cam.filters.internal.add(c);
|
|
||||||
|
|
||||||
this.controller = c;
|
const cam = scene.cameras.main;
|
||||||
this.kind = kind;
|
let attached = false;
|
||||||
return true;
|
|
||||||
|
// --- ripple (when the bundle has one) ---------------------------------
|
||||||
|
if (hasRipple && ensureRippleNode(renderer)) {
|
||||||
|
const c = new RippleController(cam);
|
||||||
|
c.strength = num(ripple, 'strength', 18);
|
||||||
|
c.baseAmp = num(ripple, 'amplitude', 0.004);
|
||||||
|
c.speed = num(ripple, 'speed', 1);
|
||||||
|
c.anchor = ripple.center === 'star' ? 'star' : 'screen';
|
||||||
|
c.centers = num(ripple, 'centers', 1) >= 2 ? 2 : 1;
|
||||||
|
c.orbit =
|
||||||
|
c.centers === 2 && ripple.orbit
|
||||||
|
? { radius: num(ripple.orbit, 'radius', 0.16), period: num(ripple.orbit, 'period', 36) }
|
||||||
|
: null;
|
||||||
|
c.flare = this.flare ? { rippleBoost: num(this.flare, 'rippleBoost', 0) } : null;
|
||||||
|
c.phase = this.phase;
|
||||||
|
// padding must cover the worst-case displacement: baseAmp, boosted by
|
||||||
|
// the flare, summed over the active centers, across the widest side.
|
||||||
|
const boost = this.flare ? 1 + num(this.flare, 'rippleBoost', 0) : 1;
|
||||||
|
const centers = c.centers;
|
||||||
|
const maxDim = Math.max(scene.scale.width, scene.scale.height);
|
||||||
|
const need = c.baseAmp * boost * centers * maxDim;
|
||||||
|
const pad = Math.max(4, Math.ceil(num(ripple, 'padding', 20)), Math.ceil(need));
|
||||||
|
c.setPaddingOverride(-pad, -pad, pad, pad);
|
||||||
|
// Initial center(s) (update() refines every frame).
|
||||||
|
if (c.centers === 2 && c.orbit) {
|
||||||
|
const o = orbitCenters(0, c.orbit.period, c.orbit.radius, c.phase);
|
||||||
|
c.cx0 = o.cx0; c.cy0 = o.cy0; c.cx1 = o.cx1; c.cy1 = o.cy1;
|
||||||
|
c.amp0 = c.baseAmp; c.amp1 = c.baseAmp;
|
||||||
|
} else if (c.anchor === 'star') {
|
||||||
|
const m = cam?.matrixCombined;
|
||||||
|
if (m && cam.width > 0 && cam.height > 0) {
|
||||||
|
const uv = worldToUV(m, cam.width, cam.height, 0, 0);
|
||||||
|
c.cx0 = uv.x; c.cy0 = uv.y;
|
||||||
|
} else {
|
||||||
|
c.cx0 = 0.5; c.cy0 = 0.5;
|
||||||
|
}
|
||||||
|
c.amp0 = c.baseAmp; c.amp1 = 0;
|
||||||
|
} else {
|
||||||
|
c.cx0 = 0.5; c.cy0 = 0.5; c.amp0 = c.baseAmp; c.amp1 = 0;
|
||||||
|
}
|
||||||
|
cam.filters.internal.add(c);
|
||||||
|
this.controller = c;
|
||||||
|
attached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- grade (when the bundle has one) ---------------------------------
|
||||||
|
if (eff.grade && ensureGradeNode(renderer)) {
|
||||||
|
const g = new GradeController(cam);
|
||||||
|
const gc = eff.grade;
|
||||||
|
g.uBright = num(gc, 'brightness', 1);
|
||||||
|
g.uSat = num(gc, 'saturation', 1);
|
||||||
|
if (gc.tint) {
|
||||||
|
const [r, gr, b] = tintMultiplier(gc.tint);
|
||||||
|
g.uTintR = r; g.uTintG = gr; g.uTintB = b;
|
||||||
|
g.uTintAmt = num(gc, 'amount', 0.3);
|
||||||
|
}
|
||||||
|
// Shadow lift (the red void) — lift only the darkness toward a hue;
|
||||||
|
// and the gritty grain that gives the haze its texture (shader-only).
|
||||||
|
if (gc.lift) {
|
||||||
|
const [lr, lg, lb] = hexToRgb01(gc.lift.color ?? '#c0392b');
|
||||||
|
g.uLiftR = lr; g.uLiftG = lg; g.uLiftB = lb;
|
||||||
|
g.uLiftAmt = num(gc.lift, 'amount', 0.18);
|
||||||
|
}
|
||||||
|
if (gc.grain) {
|
||||||
|
g.uGrainAmt = num(gc.grain, 'amount', 0);
|
||||||
|
g.grainParallax = num(gc.grain, 'parallax', 0);
|
||||||
|
}
|
||||||
|
if (gc.split) {
|
||||||
|
const [ar, ag, ab] = tintMultiplier(gc.split.a ?? '#ffffff');
|
||||||
|
const [br, bg, bb] = tintMultiplier(gc.split.b ?? '#ffffff');
|
||||||
|
g.uSplitAR = ar; g.uSplitAG = ag; g.uSplitAB = ab;
|
||||||
|
g.uSplitBR = br; g.uSplitBG = bg; g.uSplitBB = bb;
|
||||||
|
g.uSplitMix = num(gc.split, 'mix', 0.15);
|
||||||
|
const deg = num(gc.split, 'axis', 0);
|
||||||
|
const a = (deg * Math.PI) / 180;
|
||||||
|
// seed the split axis per system (angle 0..1 → 0..2π) so each
|
||||||
|
// binary's two light sources come from its own direction.
|
||||||
|
const ax2 = Math.cos(a + this.phase * Math.PI * 2);
|
||||||
|
const ay2 = Math.sin(a + this.phase * Math.PI * 2);
|
||||||
|
g.uAxisX = ax2;
|
||||||
|
g.uAxisY = ay2;
|
||||||
|
}
|
||||||
|
if (this.flare) {
|
||||||
|
const [fr, fg, fb] = hexToRgb01(this.flare.flashColor ?? '#ffffff');
|
||||||
|
g.uFlashR = fr; g.uFlashG = fg; g.uFlashB = fb;
|
||||||
|
g.flareFlash = num(this.flare, 'flash', 0.3);
|
||||||
|
}
|
||||||
|
cam.filters.internal.add(g);
|
||||||
|
this.grade = g;
|
||||||
|
attached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.kind =
|
||||||
|
hasRipple && hasGrade ? 'ripple+grade' : hasRipple ? 'ripple' : 'grade';
|
||||||
|
return attached;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-frame advance: the wave phase, and — for the "star" anchor —
|
* Per-frame advance (call with the game-loop time, ms):
|
||||||
* the star's screen UV (the camera scrolls with the ship, so the
|
* - the ripple phase, its center(s) (orbit, or the star's screen UV),
|
||||||
* center tracks it and the distortion stays pinned to the star).
|
* and — during a flare — the amplitude surge;
|
||||||
* Screen-anchored effects only advance the phase.
|
* - the grade's flare flash (same shared flare signal).
|
||||||
*
|
*
|
||||||
* @param {number} nowMs game-loop time (ms)
|
* @param {number} nowMs game-loop time (ms)
|
||||||
*/
|
*/
|
||||||
update(nowMs) {
|
update(nowMs) {
|
||||||
|
// The shared flare (0..1), computed once for both filters.
|
||||||
|
const fl = this.flare;
|
||||||
|
const flareVal = fl
|
||||||
|
? flareIntensity(nowMs, fl.interval, fl.duration, this.phase * (Number(fl.interval) || 18))
|
||||||
|
: 0;
|
||||||
|
|
||||||
const c = this.controller;
|
const c = this.controller;
|
||||||
if (!c) return;
|
if (c) {
|
||||||
c.time = ripplePhase(nowMs, c.speed);
|
c.time = ripplePhase(nowMs, c.speed);
|
||||||
if (c.center !== 'star') return;
|
const amp = c.baseAmp * (1 + (c.flare?.rippleBoost ?? 0) * flareVal);
|
||||||
const cam = this.scene.cameras.main;
|
if (c.centers === 2 && c.orbit) {
|
||||||
const m = cam?.matrixCombined;
|
const o = orbitCenters(nowMs, c.orbit.period, c.orbit.radius, c.phase);
|
||||||
if (m && cam.width > 0 && cam.height > 0) {
|
c.cx0 = o.cx0; c.cy0 = o.cy0; c.cx1 = o.cx1; c.cy1 = o.cy1;
|
||||||
const uv = worldToUV(m, cam.width, cam.height, 0, 0);
|
c.amp0 = amp; c.amp1 = amp;
|
||||||
c.centerX = uv.x;
|
} else {
|
||||||
c.centerY = uv.y;
|
if (c.anchor === 'star') {
|
||||||
|
const cam = this.scene.cameras.main;
|
||||||
|
const m = cam?.matrixCombined;
|
||||||
|
if (m && cam.width > 0 && cam.height > 0) {
|
||||||
|
const uv = worldToUV(m, cam.width, cam.height, 0, 0);
|
||||||
|
c.cx0 = uv.x; c.cy0 = uv.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.amp0 = amp; c.amp1 = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = this.grade;
|
||||||
|
if (g) {
|
||||||
|
g.uFlash = g.flareFlash * flareVal;
|
||||||
|
// Grain parallax: offset the grain by the camera scroll so it shifts
|
||||||
|
// slightly as you fly (sits at a depth, not on the lens).
|
||||||
|
if (g.grainParallax > 0) {
|
||||||
|
const cam = this.scene.cameras.main;
|
||||||
|
g.uGrainOffX = (cam?.scrollX ?? 0) * g.grainParallax;
|
||||||
|
g.uGrainOffY = (cam?.scrollY ?? 0) * g.grainParallax;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detach and destroy the current effect (safe to call repeatedly). */
|
/** Detach and destroy the current effect (safe to call repeatedly). */
|
||||||
release() {
|
release() {
|
||||||
const c = this.controller;
|
for (const c of [this.controller, this.grade]) {
|
||||||
if (!c) return;
|
if (!c) continue;
|
||||||
|
const list = c?.camera?.filters?.internal;
|
||||||
|
if (list?.remove) list.remove(c);
|
||||||
|
else if (typeof c?.destroy === 'function') c.destroy();
|
||||||
|
}
|
||||||
this.controller = null;
|
this.controller = null;
|
||||||
|
this.grade = null;
|
||||||
this.kind = null;
|
this.kind = null;
|
||||||
const list = c?.camera?.filters?.internal;
|
this.flare = null;
|
||||||
if (list?.remove) list.remove(c);
|
|
||||||
else if (typeof c?.destroy === 'function') c.destroy();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,3 +68,193 @@ export function worldToUV(m, width, height, wx = 0, wy = 0) {
|
||||||
export function ripplePhase(nowMs, speed = 1) {
|
export function ripplePhase(nowMs, speed = 1) {
|
||||||
return (nowMs / 1000) * (Number(speed) || 0);
|
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];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
/**
|
||||||
|
* binary companion star — a distant "other star" wandering slowly.
|
||||||
|
*
|
||||||
|
* Content layer (two world-space sprites: a bright core + a soft halo),
|
||||||
|
* NOT a filter: Canvas-safe and immune to the HUD. It sits FAR away
|
||||||
|
* (very low parallax ~0.08, so it barely moves as you fly — a distant
|
||||||
|
* light source) and drifts along a slow elliptical arc, seeded per system
|
||||||
|
* so each binary's companion wanders its own way. Both sprites use
|
||||||
|
* additive blend — it is a source of light, so it glows, never occludes.
|
||||||
|
* Depth 3 keeps it behind the art (planets 5, ship 10).
|
||||||
|
*
|
||||||
|
* Config: data/systems.json → types.binary.effect.wanderer.
|
||||||
|
*/
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { canvasTexture } from '../utils/Textures.js';
|
||||||
|
import { toColor } from '../utils/Color.js';
|
||||||
|
|
||||||
|
const DEPTH = 3; // behind the art, in front of the starfield
|
||||||
|
|
||||||
|
/** A tight, bright core — the star's body. White (tint gives the color). */
|
||||||
|
function drawCore(ctx, size) {
|
||||||
|
const c = size / 2;
|
||||||
|
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
|
||||||
|
g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
|
||||||
|
g.addColorStop(0.5, 'rgba(255,255,255,0.9)');
|
||||||
|
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A wide, soft halo — the star's corona. White (tint gives the color). */
|
||||||
|
function drawHalo(ctx, size) {
|
||||||
|
const c = size / 2;
|
||||||
|
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
|
||||||
|
g.addColorStop(0.0, 'rgba(255,255,255,0.55)');
|
||||||
|
g.addColorStop(0.4, 'rgba(255,255,255,0.28)');
|
||||||
|
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The companion star (core + halo) wandering a slow seeded arc.
|
||||||
|
*
|
||||||
|
* @param {object} scene Phaser scene
|
||||||
|
* @param {string} color star color (CSS hex)
|
||||||
|
* @param {object} cfg data/systems.json → effect.wanderer
|
||||||
|
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
|
||||||
|
*/
|
||||||
|
export class WandererStar {
|
||||||
|
constructor(scene, color, cfg, fx = {}) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.color = toColor(color, 0x9fd0ff);
|
||||||
|
this.cfg = cfg ?? {};
|
||||||
|
this.fx = fx ?? {};
|
||||||
|
this.core = null;
|
||||||
|
this.halo = null;
|
||||||
|
this.tSec = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the core + halo (idempotent). */
|
||||||
|
create() {
|
||||||
|
const scene = this.scene;
|
||||||
|
if (this.core) return;
|
||||||
|
const {
|
||||||
|
coreSize = [40, 70],
|
||||||
|
haloSize = [120, 200],
|
||||||
|
parallax = 0.08,
|
||||||
|
speed = [3, 8],
|
||||||
|
} = this.cfg;
|
||||||
|
|
||||||
|
const coreKey = canvasTexture(scene, 'fx-wanderer-core', 64, 64, (ctx) => drawCore(ctx, 64));
|
||||||
|
const haloKey = canvasTexture(scene, 'fx-wanderer-halo', 128, 128, (ctx) => drawHalo(ctx, 128));
|
||||||
|
|
||||||
|
// Seeded starting point (upper third of the view — a star in the sky)
|
||||||
|
// and a slow elliptical arc (the "wander").
|
||||||
|
const fx = this.fx;
|
||||||
|
const phase = fx.phase ?? 0.5;
|
||||||
|
const angle = fx.angle ?? 0.5;
|
||||||
|
const drift = fx.drift ?? 0.5;
|
||||||
|
const w = scene.scale.width;
|
||||||
|
const h = scene.scale.height;
|
||||||
|
const baseX = w * (0.2 + 0.6 * phase);
|
||||||
|
const baseY = h * (0.15 + 0.3 * angle);
|
||||||
|
const ampX = w * (0.05 + 0.12 * drift);
|
||||||
|
const ampY = h * (0.04 + 0.10 * drift);
|
||||||
|
const omega = ((speed[0] + (speed[1] - speed[0]) * drift) / w) * Math.PI * 2; // rad/s (slow)
|
||||||
|
|
||||||
|
this.core = scene.add
|
||||||
|
.image(baseX, baseY, coreKey)
|
||||||
|
.setDepth(DEPTH)
|
||||||
|
.setTint(this.color)
|
||||||
|
.setAlpha(0.95)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
const cs = coreSize[0] + (coreSize[1] - coreSize[0]) * drift;
|
||||||
|
this.core.setScale(cs / 64);
|
||||||
|
|
||||||
|
this.halo = scene.add
|
||||||
|
.image(baseX, baseY, haloKey)
|
||||||
|
.setDepth(DEPTH)
|
||||||
|
.setTint(this.color)
|
||||||
|
.setAlpha(0.8)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
const hs = haloSize[0] + (haloSize[1] - haloSize[0]) * drift;
|
||||||
|
this.halo.setScale(hs / 128);
|
||||||
|
|
||||||
|
this.baseX = baseX;
|
||||||
|
this.baseY = baseY;
|
||||||
|
this.ampX = ampX;
|
||||||
|
this.ampY = ampY;
|
||||||
|
this.omega = omega;
|
||||||
|
this.phase0 = angle * Math.PI * 2;
|
||||||
|
this.p = parallax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-frame: slow elliptical wander + camera parallax. */
|
||||||
|
update(nowMs, dtMs) {
|
||||||
|
if (!this.core) return;
|
||||||
|
this.tSec += (dtMs || 0) / 1000;
|
||||||
|
const t = this.tSec;
|
||||||
|
const a = this.omega * t + this.phase0;
|
||||||
|
let x = this.baseX + this.ampX * Math.cos(a);
|
||||||
|
let y = this.baseY + this.ampY * Math.sin(a);
|
||||||
|
// very low parallax (distant): shifts a little as the camera flies
|
||||||
|
const cam = this.scene.cameras.main;
|
||||||
|
const camX = cam?.worldView?.x ?? 0;
|
||||||
|
const camY = cam?.worldView?.y ?? 0;
|
||||||
|
x += (camX - this.scene.scale.width / 2) * this.p;
|
||||||
|
y += (camY - this.scene.scale.height / 2) * this.p;
|
||||||
|
this.core.setPosition(x, y);
|
||||||
|
this.halo.setPosition(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear down. */
|
||||||
|
destroy() {
|
||||||
|
this.core?.destroy();
|
||||||
|
this.halo?.destroy();
|
||||||
|
this.core = null;
|
||||||
|
this.halo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue