Add nebula atmosphere and per-system visual effects

- Nebula gas layer: soft coloured cloud sprites at depth 3, tinted with a
  per-system palette color assigned deterministically at galaxy generation
  (SystemGenerator stamps content.atmosphere.color). Only nebula-type
  systems render it; all other types are untouched.
- Ripple effect: a full-screen WebGL composite on the world camera that
  displaces the starfield and world objects while the HUD stays crisp via
  a new UI/world camera split (UiCameras.js). Data-driven per archetype in
  data/systems.json (nebula = ripple, all others = none). Canvas fallback
  degrades to no effect.
- Demo mode: ?fx=<type> URL param retargets the run to the richest system
  of that archetype, fully charted with all gates active, for judging the
  effect in isolation.
- New ice and lava planet art (slots 9–14 in planets.png) replacing the
  placeholder terran/rocky/gas reuse.
- Node test suites for the nebula palette/determinism/cloud texture and
  the ripple math/data contract/shader/facade (50+ checks, bare Node).
This commit is contained in:
Brian Fertig 2026-09-08 17:41:35 -06:00
parent ae76c35587
commit 125b1657ed
21 changed files with 1613 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

After

Width:  |  Height:  |  Size: 20 MiB

Binary file not shown.

View File

@ -16,6 +16,18 @@
"parallax": [0.15, 0.85], "parallax": [0.15, 0.85],
"colors": ["#ffffff", "#b9c6ff", "#8090b8"] "colors": ["#ffffff", "#b9c6ff", "#8090b8"]
}, },
"nebula": {
"_comment": "The nebula GAS layer — soft coloured clouds BEHIND the art (depth 3: above the starfield's 02, below the planets at 5 and the ship at 10). Shown only on nebula-type systems, tinted with the system's own atmosphere color (SystemGenerator stamps content.atmosphere.color from this palette). palette = the 8 nebula shades; one is assigned at random to each system at galaxy generation (deterministic per seed). count/alpha/size/parallax/drift tune the look — keep it subtle: a wash of colour, not a wall of it. shapes = how many distinct cloud puffs to vary between (so it doesn't read as one image repeated). texture.* drives each puff (fbm value-noise for structure, wide radial falloff so it has no hard edge).",
"enabled": true,
"palette": ["#f0884a", "#ecd23e", "#4fe08a", "#3fd6d6", "#5b9bf2", "#9a7ff5", "#e06fe0", "#f25f7a"],
"count": 6,
"alpha": [0.12, 0.22],
"size": [420, 900],
"parallax": [0.10, 0.22],
"drift": 2.5,
"shapes": 3,
"texture": { "size": 256, "octaves": 4, "persistence": 0.55 }
},
"camera": { "camera": {
"followShip": true, "followShip": true,
"followRate": 3.0 "followRate": 3.0

View File

@ -8,8 +8,8 @@
"terran": [0, 1, 2], "terran": [0, 1, 2],
"rocky": [6, 7, 8], "rocky": [6, 7, 8],
"gas": [3, 4, 5], "gas": [3, 4, 5],
"ice": [0, 1, 2], "ice": [9, 10, 11],
"lava": [0, 1, 2] "lava": [12, 13, 14]
}, },
"classScale": { "classScale": {
"rocky": 1.0, "rocky": 1.0,

View File

@ -1,5 +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 12 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 12 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).",
"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 }
@ -10,6 +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" },
"attributes": { "attributes": {
"star": { "star": {
"classes": { "G": 30, "K": 40, "M": 30 }, "classes": { "G": 30, "K": 40, "M": 30 },
@ -32,6 +34,7 @@
"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" },
"attributes": { "attributes": {
"star": { "star": {
"classes": { "M": 85, "K": 15 }, "classes": { "M": 85, "K": 15 },
@ -54,6 +57,7 @@
"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" },
"attributes": { "attributes": {
"star": { "star": {
"classes": { "F": 25, "G": 40, "K": 35 }, "classes": { "F": 25, "G": 40, "K": 35 },
@ -76,6 +80,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" },
"attributes": { "attributes": {
"star": { "star": {
"classes": { "G": 70, "K": 30 }, "classes": { "G": 70, "K": 30 },
@ -98,6 +103,14 @@
"description": "Young, bright, and still messy — debris where planets should be.", "description": "Young, bright, and still messy — debris where planets should be.",
"theme": { "color": "#5fd4d0" }, "theme": { "color": "#5fd4d0" },
"distribution": { "weight": 12 }, "distribution": { "weight": 12 },
"effect": {
"kind": "ripple",
"center": "screen",
"strength": 20,
"amplitude": 0.0042,
"speed": 1.0,
"padding": 20
},
"attributes": { "attributes": {
"star": { "star": {
"classes": { "A": 20, "F": 30, "G": 50 }, "classes": { "A": 20, "F": 30, "G": 50 },
@ -120,6 +133,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" },
"attributes": { "attributes": {
"star": { "star": {
"classes": { "M": 90, "K": 10 }, "classes": { "M": 90, "K": 10 },

235
dev/nebula.test.mjs Normal file
View File

@ -0,0 +1,235 @@
/**
* Nebula atmosphere test (dev tool, run with Node no browser needed):
*
* node dev/nebula.test.mjs
*
* Asserts:
* - the PALETTE contract (data/game.json nebula.palette): exactly 8
* distinct, valid hex shades the gas is tinted per-system from this;
* - the PER-SYSTEM COLOR (js/galaxy/SystemGenerator.js): every generated
* system stamps content.atmosphere.color, always a member of the
* palette; same seed the same color for a system (deterministic), and
* it is stable across lazy vs. eager generation;
* - the CLOUD TEXTURE (js/visuals/NebulaAtmosphere.js drawCloud): a
* valid alpha channel (some opaque gas, not empty), faded at the edges
* (center denser than the rim), white RGB (tint sets the color), and
* deterministic (two renders are byte-identical).
*/
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { drawCloud } = await import(
pathToFileURL(join(__dirname, '../js/visuals/NebulaAtmosphere.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// --- Helpers -------------------------------------------------------------
const isHex = (s) =>
typeof s === 'string' && /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s.trim());
function hueOf(hex) {
let h = String(hex).trim().replace(/^#/, '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const n = parseInt(h, 16);
const r = ((n >> 16) & 255) / 255;
const g = ((n >> 8) & 255) / 255;
const b = (n & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
if (d === 0) return 0;
let hue;
if (max === r) hue = ((g - b) / d) % 6;
else if (max === g) hue = (b - r) / d + 2;
else hue = (r - g) / d + 4;
hue *= 60;
return hue < 0 ? hue + 360 : hue;
}
// A headless 2-D context just enough for drawCloud (it only uses
// createImageData / putImageData). drawCloud fills the buffer we hand it.
function renderCloud(w, h, tex) {
const data = new Uint8ClampedArray(w * h * 4);
const ctx = {
createImageData: (ww, hh) => ({ width: ww, height: hh, data }),
putImageData: () => {}, // the buffer is already filled by drawCloud
};
drawCloud(ctx, w, h, tex);
return data;
}
// =========================================================================
// 1. PALETTE contract
// =========================================================================
console.log('\n— Palette (data/game.json → nebula.palette) —');
const nebulaCfg = config.section('game.nebula', {});
const palette = nebulaCfg.palette ?? [];
const distinct = new Set(palette.map((c) => String(c).toLowerCase().replace(/^#/, '')));
check('palette has exactly 8 shades', palette.length === 8);
check('all 8 shades are distinct', distinct.size === 8);
check('every shade is a valid hex color', palette.every(isHex));
if (palette.length === 8) {
const hues = palette.map(hueOf);
const minSep = (() => {
const sorted = [...hues].sort((a, b) => a - b);
let m = Infinity;
for (let i = 0; i < sorted.length; i++) {
const next = (i + 1) % sorted.length;
let gap = Math.abs(sorted[next] - sorted[i]);
gap = Math.min(gap, 360 - gap);
if (i < sorted.length - 1) m = Math.min(m, gap);
}
return m;
})();
console.log(
` hues: ${hues.map((h) => h.toFixed(0) + '°').join(' ')} (min neighbour separation ${minSep.toFixed(0)}°)`,
);
check('colours are well spread (min neighbour separation ≥ 25°)', minSep >= 25);
}
// =========================================================================
// 2. PER-SYSTEM COLOR (deterministic, palette-member)
// =========================================================================
console.log('\n— Per-system colour (SystemGenerator) —');
const SEED = 'orbit-nebula-test';
const gal = Galaxy.create(SEED);
const gal2 = Galaxy.create(SEED); // fresh, same seed → must match
let allStamped = true;
let allInPalette = true;
let sameSeedStable = true;
let nebulaSystems = 0;
const seenColors = new Set();
for (const rec of gal.records) {
const c1 = gal.ensureContent(rec.id);
const c2 = gal2.ensureContent(rec.id);
if (!c1?.atmosphere || typeof c1.atmosphere.color !== 'string') allStamped = false;
if (typeof c1?.atmosphere?.color === 'string' && !isHex(c1.atmosphere.color)) allInPalette = false;
const palKey = (x) => String(x ?? '').toLowerCase().replace(/^#/, '');
if (c1?.atmosphere?.color && !palette.some((p) => palKey(p) === palKey(c1.atmosphere.color)))
allInPalette = false;
if (c1?.atmosphere?.color !== c2?.atmosphere?.color) sameSeedStable = false;
if (rec.type === 'nebula') {
nebulaSystems++;
if (c1?.atmosphere?.color) seenColors.add(palKey(c1.atmosphere.color));
}
}
check('every system stamps content.atmosphere.color (a string)', allStamped);
check('every stamped colour is a valid hex in the palette', allInPalette);
check('same seed ⇒ same colour for every system (deterministic)', sameSeedStable);
check('the galaxy actually contains nebula systems', nebulaSystems > 0);
// With 8 colours and ~90 systems, a real random assignment should hit many
// distinct shades across the nebulae (not a degenerate single colour).
check('nebulae draw on more than one shade (assignment is really random)', seenColors.size >= 3);
console.log(
` ${gal.records.length} systems · ${nebulaSystems} nebulae · ${seenColors.size} distinct nebula shades used`,
);
// Lazy === eager: a third galaxy generated on arrival matches.
const spot = gal.records.find((r) => r.type === 'nebula') ?? gal.records[0];
const fresh = Galaxy.create(SEED);
const lazyEq =
fresh.ensureContent(spot.id).atmosphere.color === gal.ensureContent(spot.id).atmosphere.color;
check('lazy (on-arrival) colour === eager (up-front) colour', lazyEq);
// =========================================================================
// 3. CLOUD TEXTURE (drawCloud)
// =========================================================================
console.log('\n— Cloud texture (NebulaAtmosphere.drawCloud) —');
const SIZE = 128; // smaller than the 256 default → fast in Node
const texCfg = nebulaCfg.texture ?? {};
const buf = renderCloud(SIZE, SIZE, texCfg);
const alphaAt = (x, y) => buf[(y * SIZE + x) * 4 + 3];
const rgbWhite = (x, y) => {
const i = (y * SIZE + x) * 4;
return buf[i] === 255 && buf[i + 1] === 255 && buf[i + 2] === 255;
};
// Some gas exists (not an all-transparent sprite).
let opaqueCount = 0;
let maxAlpha = 0;
for (let y = 0; y < SIZE; y++)
for (let x = 0; x < SIZE; x++) {
const a = alphaAt(x, y);
if (a > 0) opaqueCount++;
if (a > maxAlpha) maxAlpha = a;
}
check('the cloud has visible gas (some opaque pixels)', opaqueCount > SIZE * SIZE * 0.01);
check('the brightest filament reaches real density (max alpha ≥ 40)', maxAlpha >= 40);
// White RGB everywhere (the tint sets the color; the texture carries alpha).
let allWhite = true;
for (let y = 0; y < SIZE; y += 7)
for (let x = 0; x < SIZE; x += 7) if (alphaAt(x, y) > 0 && !rgbWhite(x, y)) allWhite = false;
check('opaque pixels are white RGB (tint-able)', allWhite);
// Edge fade: the centre band is denser than the outer ring.
const ring = (x0, x1, y0, y1, fn) => {
let s = 0;
let n = 0;
// Integer coords only — the alpha buffer is indexed by whole pixels.
const ax0 = Math.round(x0), ax1 = Math.round(x1);
const ay0 = Math.round(y0), ay1 = Math.round(y1);
for (let y = ay0; y < ay1; y += 3)
for (let x = ax0; x < ax1; x += 3) {
s += fn(x, y);
n++;
}
return n ? s / n : 0;
};
const centre = ring(SIZE * 0.35, SIZE * 0.65, SIZE * 0.35, SIZE * 0.65, alphaAt);
// The rim: average alpha over ONLY the outer 12% border (apples to apples
// with the centre — not diluted by the interior pixels).
let rimSum = 0;
let rimN = 0;
for (let y = 0; y < SIZE; y += 2)
for (let x = 0; x < SIZE; x += 2)
if (x < SIZE * 0.12 || x > SIZE * 0.88 || y < SIZE * 0.12 || y > SIZE * 0.88) {
rimSum += alphaAt(x, y);
rimN++;
}
const rim = rimN ? rimSum / rimN : 0;
check('edges fade to transparent (centre denser than the rim)', centre > rim);
console.log(` centre avg alpha ${centre.toFixed(1)} · rim avg alpha ${rim.toFixed(1)}`);
// Determinism: two renders are byte-identical.
const buf2 = renderCloud(SIZE, SIZE, texCfg);
let identical = buf.length === buf2.length;
if (identical) for (let i = 0; i < buf.length; i++) if (buf[i] !== buf2[i]) { identical = false; break; }
check('texture is deterministic (two renders byte-identical)', identical);
// ---------------------------------------------------------------------------
console.log(failures === 0 ? '\nAll nebula tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -34,10 +34,52 @@ function hexToRgbInt(v) {
return m ? parseInt(m[1], 16) : 0xffffff; return m ? parseInt(m[1], 16) : 0xffffff;
} }
// --- Filter plumbing (js/visuals/SystemEffects.js) -----------------------
// The stub mirrors the two base classes the custom filter extends, with
// just enough surface for the facade logic (apply/release/update) to run:
// a controller that carries shader state, and a node that records the
// uniforms setupUniforms() pushes.
class Controller {
constructor(camera, renderNode) {
this.active = true;
this.camera = camera;
this.renderNode = renderNode;
this.paddingOverride = null;
this.ignoreDestroy = false;
}
setPaddingOverride(x, y, r, b) {
this.paddingOverride = { x, y, width: r - x, height: b - y };
return this;
}
getPaddingCeil() {
const p = this.paddingOverride ?? { x: 0, y: 0, width: 0, height: 0 };
return { x: Math.ceil(p.x), y: Math.ceil(p.y), width: Math.ceil(p.width), height: Math.ceil(p.height) };
}
setActive(v) { this.active = v; return this; }
destroy() { this.active = false; }
}
class BaseFilterShader {
constructor(name, manager, _shaderKey, fragmentSource, _additions) {
this.name = name;
this.manager = manager;
this.fragmentSource = Array.isArray(fragmentSource) ? fragmentSource.join('\n') : String(fragmentSource ?? '');
this.uniforms = {};
this.programManager = {
setUniform: (nm, val) => { this.uniforms[nm] = val; },
};
}
setupUniforms(_controller, _ctx) {}
}
export { Controller, BaseFilterShader };
export default { export default {
GameObjects: { Container, Sprite }, GameObjects: { Container, Sprite },
Physics: { Arcade: { Sprite } }, Physics: { Arcade: { Sprite } },
Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } }, Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } },
Filters: { Controller },
Renderer: { WebGL: { RenderNodes: { BaseFilterShader } } },
Math: { Math: {
Clamp: (v, a, b) => Math.min(b, Math.max(a, v)), Clamp: (v, a, b) => Math.min(b, Math.max(a, v)),
Linear: (a, b, t) => a + (b - a) * t, Linear: (a, b, t) => a + (b - a) * t,

319
dev/system-effects.test.mjs Normal file
View File

@ -0,0 +1,319 @@
/**
* System effects test (dev tool, run with Node no browser needed):
*
* node dev/system-effects.test.mjs
*
* Asserts:
* - the RIPPLE MATH (js/visuals/SystemEffectsMath.js): world->screen->UV
* under identity, translation, rotation and zoom transforms; the
* phase clock is monotonic and speed-scaled;
* - the DATA CONTRACT (data/systems.json): every system type carries an
* `effect` block; `nebula` is the one live family (ripple) with
* sane numeric parameters (and padding that covers the max
* displacement); the other types render untouched (none);
* - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the
* requested type wins, ties fall to roster order, missing type => null;
* - the SHADER CONTRACT (js/visuals/SystemEffects.js, exercised against
* the Phaser stub): every uniform setupUniforms pushes is declared in
* the fragment source, and the fragment keeps the build's filter-shader
* conventions (uMainSampler / outTexCoord / boundedSampler);
* - the FACADE (SystemEffects.apply/update/release) on a fake scene:
* "none" touches nothing (no camera, no filter); "ripple" needs WebGL
* and registers the node once, splits the cameras (UI roots off the
* world pass, world roots off the UI pass), attaches a parameterized
* controller to the world camera's internal filter list, advances the
* 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 { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { worldToScreen, worldToUV, ripplePhase } = await import(
pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href
);
const { pickFxSystem } = await import(
pathToFileURL(join(__dirname, '../js/galaxy/FxSystems.js')).href
);
const SE = await import(pathToFileURL(join(__dirname, '../js/visuals/SystemEffects.js')).href);
const { ensureUiCameras, assignUi, assignWorld, isScreenPinned } = await import(
pathToFileURL(join(__dirname, '../js/visuals/UiCameras.js')).href
);
let pass = 0;
function check(name, cond) {
if (!cond) {
console.error(`${name}`);
process.exit(1);
}
pass++;
console.log(`${name}`);
}
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
// --- The ripple math -------------------------------------------------------
const ID = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
check('identity: world (0,0) -> screen (0,0)', worldToScreen(ID, 0, 0).x === 0 && worldToScreen(ID, 0, 0).y === 0);
check('identity: UV of a screen point is itself / size', (() => {
const uv = worldToUV(ID, 100, 50, 25, 10);
return approx(uv.x, 0.25) && approx(uv.y, 0.2);
})());
check('translation: world origin lands on the camera offset', (() => {
const m = { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 40 };
const p = worldToScreen(m, 0, 0);
return p.x === 100 && p.y === 40;
})());
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 p = worldToScreen(m, 1, 0);
return approx(p.x, 0) && approx(p.y, 1);
})());
check('zoom: world origin UV scales with the camera zoom', (() => {
const m = { a: 2, b: 0, c: 0, d: 2, tx: 20, ty: 10 };
const uv = worldToUV(m, 100, 100, 0, 0);
return approx(uv.x, 0.2) && approx(uv.y, 0.1);
})());
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 data contract (data/systems.json) ---------------------------------
const types = config.section('systems.types', {});
const typeIds = Object.keys(types);
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('nebula wears the ripple; the other five render untouched', (() => {
const kinds = Object.fromEntries(typeIds.map((t) => [t, types[t].effect.kind]));
return kinds.nebula === 'ripple' && typeIds.filter((t) => t !== 'nebula').every((t) => kinds[t] === 'none');
})());
check('ripple parameters are sane numbers', (() => {
const e = types.nebula.effect;
const n = (v) => typeof v === 'number' && Number.isFinite(v);
return (
n(e.strength) && e.strength > 0 && n(e.amplitude) && e.amplitude > 0 &&
n(e.speed) && e.speed > 0 && n(e.padding) && e.padding > 0 &&
(e.center === undefined || e.center === 'screen' || e.center === 'star')
);
})());
check('padding covers the max displacement (no out-of-range UV sampling)', (() => {
const e = types.nebula.effect;
const maxDispPx = Math.max(config.get('game.width', 1280), config.get('game.height', 720)) * e.amplitude;
return e.padding >= maxDispPx;
})());
// --- The demo picker (js/galaxy/FxSystems.js) -------------------------------
const fakeGalaxy = (records, contents = {}) => ({
records,
contentCache: new Map(Object.entries(contents)),
});
const rec = (id, type) => ({ id, type });
check('picker: richest system of the type wins', (() => {
const g = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula'), rec('C', 'main')], {
A: { planets: [1], settlements: [], asteroids: [] },
B: { planets: [1, 2, 3], settlements: [1], asteroids: [1, 2] },
C: { planets: [1, 2, 3, 4, 5], settlements: [1, 2], asteroids: [1, 2, 3] },
});
return pickFxSystem(g, 'nebula')?.id === 'B';
})());
check('picker: more objects beat more gates; ties fall to roster order', (() => {
const g = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula')], {
A: { planets: [1], jumps: [1, 2, 3, 4] },
B: { planets: [1, 2], jumps: [1] },
});
const first = pickFxSystem(g, 'nebula')?.id;
const g2 = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula')], {
A: { planets: [1] },
B: { planets: [1] },
});
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: empty roster => null', pickFxSystem(fakeGalaxy([]), 'nebula') === null);
// --- The shader contract -----------------------------------------------------
const fakeRenderer = {
renderNodes: {
_ctors: {},
hasNode(n) { return Object.prototype.hasOwnProperty.call(this._ctors, n); },
addNodeConstructor(n, C) { if (this._ctors[n]) throw new Error('node constructor ' + n + ' already exists'); this._ctors[n] = C; },
},
};
const makeCamera = (id) => ({
id,
width: 1280,
height: 720,
matrixCombined: { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 40 },
filters: {
internal: {
list: [],
add(f) { this.list.push(f); return f; },
remove(f) { const i = this.list.indexOf(f); if (i !== -1) this.list.splice(i, 1); return this; },
getActive() { return this.list.filter((f) => f.active); },
},
},
ignore(targets) {
(Array.isArray(targets) ? targets : [targets]).forEach((t) => { t.cameraFilter |= this.id; });
return this;
},
setForceComposite(v) { this.forceComposite = v; return this; },
});
const fakeCamMain = makeCamera(1);
const mkObject = (id, scrollFactor) => ({ id, cameraFilter: 0, scrollFactorX: scrollFactor, scrollFactorY: scrollFactor });
const uiRoot = mkObject('ui-root', 0);
const worldRoot = mkObject('world-root', 1);
const fakeCameras = {
main: fakeCamMain,
cameras: [fakeCamMain],
add(_x, _y, _w, _h, _isMain, name) {
const c = makeCamera(2);
c.name = name;
this.cameras.push(c);
return c;
},
};
const fakeScene = {
scale: { width: 1280, height: 720 },
cameras: fakeCameras,
sys: { displayList: { getChildren: () => [uiRoot, worldRoot, mkObject('world-2', 1)] } },
renderer: { gl: {}, renderNodes: fakeRenderer.renderNodes },
};
// The real split:
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: 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: idempotent (same split object, no third camera)', ensureUiCameras(fakeScene) === split && fakeCameras.cameras.length === 2);
check('isScreenPinned: scrollFactor-0 on either axis is UI', isScreenPinned({ scrollFactorX: 0, scrollFactorY: 1 }) && !isScreenPinned({ scrollFactorX: 1, scrollFactorY: 1 }));
const lateUi = mkObject('late-ui', 0);
assignUi(fakeScene, lateUi);
check('assignUi: a late UI object joins the UI pass only', (lateUi.cameraFilter & fakeCamMain.id) !== 0 && (lateUi.cameraFilter & split.ui.id) === 0);
const lateWorld = mkObject('late-world', 1);
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);
const noSplitScene = { };
assignUi(noSplitScene, lateUi); // must not throw
check('assign*: no-op while the split does not exist (single-camera pipeline intact)', true);
// The facade:
const fx = new SE.SystemEffects(fakeScene);
check('facade: "none" (main) attaches nothing — no filter, no new camera', (() => {
const camsBefore = fakeCameras.cameras.length;
const active = fx.apply('main');
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)', (() => {
const canvasScene = { ...fakeScene, renderer: { gl: null, renderNodes: fakeRenderer.renderNodes } };
const f = new SE.SystemEffects(canvasScene);
return f.apply('nebula') === false && f.active === false;
})());
check('facade: apply(nebula) registers the node once + splits + attaches', (() => {
fakeCamMain.filters.internal.list.length = 0;
const active = fx.apply('nebula');
const list = fakeCamMain.filters.internal.list;
return (
active === true &&
fx.active === true &&
fx.kind === 'ripple' &&
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', (() => {
const c = fakeCamMain.filters.internal.list[0];
const e = types.nebula.effect;
return (
c.strength === e.strength &&
c.amplitude === e.amplitude &&
c.speed === e.speed &&
c.paddingOverride &&
c.paddingOverride.x === -Math.max(4, Math.ceil(e.padding)) &&
c.paddingOverride.width === 2 * Math.max(4, Math.ceil(e.padding))
);
})());
check('facade: apply() twice replaces (no duplicate filters)', (() => {
fx.apply('nebula');
return fakeCamMain.filters.internal.list.length === 1;
})());
check('facade: update() advances the phase and keeps the screen anchor pinned', (() => {
const c = fakeCamMain.filters.internal.list[0];
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 }; // camera moved
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));
})());
check('facade: the "star" anchor tracks world 0,0 in screen UV', (() => {
const c = fakeCamMain.filters.internal.list[0];
c.center = 'star'; // exercise the tracking branch
fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 };
fx.update(1500);
return approx(c.centerX, 320 / 1280) && approx(c.centerY, 180 / 720);
})());
check('facade: release() detaches cleanly (and twice)', (() => {
fx.release();
const first = fakeCamMain.filters.internal.list.length === 0 && fx.active === false;
fx.release();
return first;
})());
// --- The shader source contract (through a live node instance) ---------------
const NodeClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE];
check('node: the registered constructor exists and names itself', typeof NodeClass === 'function' && SE.RIPPLE_NODE === 'FilterRippleEffect');
const liveNode = new NodeClass({ renderer: {} });
const fragSrc = liveNode.fragmentSource;
check('shader: declares every uniform setupUniforms pushes', (() => {
const ctrl = { time: 1, strength: 90, amplitude: 0.01, centerX: 0.5, centerY: 0.5 };
liveNode.setupUniforms(ctrl, {});
const pushed = Object.keys(liveNode.uniforms);
const declared = ['time', 'strength', 'amplitude', 'centerX', 'centerY'].every((u) =>
new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(fragSrc));
return pushed.length === 5 && declared;
})());
check('shader: keeps the build\'s filter conventions (uMainSampler, outTexCoord, boundedSampler)', (() => {
return (
fragSrc.includes('uniform sampler2D uMainSampler;') &&
fragSrc.includes('varying vec2 outTexCoord;') &&
fragSrc.includes('boundedSampler(uMainSampler') &&
fragSrc.includes('#pragma phaserTemplate(shaderName)') &&
fragSrc.includes('#pragma phaserTemplate(fragmentHeader)')
);
})());
check('shader: displacement is radial from the center, clean at the center, calm at the corners', (() => {
return (
fragSrc.includes('length(delta)') &&
fragSrc.includes('smoothstep(0.0, 0.02, dist)') &&
fragSrc.includes('0.8 + 0.2 * exp(-dist * 0.25)') &&
fragSrc.includes('wave * amplitude * fade')
);
})());
console.log(`\n✓ system effects: ${pass} checks passed`);

View File

@ -1107,6 +1107,95 @@ 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)
- A system's visual character is a full-screen composite effect on the
WORLD camera, configured per archetype in `data/systems.json →
types.<type>.effect` (`kind: none | ripple`). Nebula systems wear the
RIPPLE — concentric rings radiating from the middle of the view
(`center: "screen"`; `"star"` tracks world 0,0 instead) — young,
bright, still messy: the light itself is unsettled. The star itself is
invisible flavor; the shimmer is its character.
- Files: `js/visuals/SystemEffects.js` (filter + facade),
`js/visuals/SystemEffectsMath.js` (pure UV math),
`js/visuals/UiCameras.js` (the UI camera split),
`js/galaxy/FxSystems.js` (demo system picker),
`dev/system-effects.test.mjs` (35 checks, bare Node).
- **How it draws (Phaser 4.2.1):** the ripple is a
`Phaser.Filters.Controller` on `cameras.main`'s `filters.internal`
list — when any internal filter is active the camera pass
auto-composites (FBO + blit), so the WORLD (starfield included) is
displaced and the HUD is not. The UI pass is a second camera
(`fx-ui`, `forceComposite`) that draws the screen-pinned objects on
top WITHOUT clearing the world (a second direct pass would `clear()`
the canvas). Both directions are partitioned with `camera.ignore()`
(set-only — there is no un-ignore): screen-pinned roots are ignored by
MAIN, world roots (220+) by the UI pass, so nothing draws twice and
input still hits the UI first (hit-test walks cameras in reverse
array order). Verified in the browser: a scrollFactor-0 test ruler on
the UI pass is pixel-stable across frames while the world behind it
shimmers.
- **WebGL-only, Canvas-safe:** `apply()` requires `renderer.gl`; on the
Canvas fallback the system renders untouched (no crash, no effect).
The node constructor is registered once per renderer
(`ensureRippleNode`, `hasNode`-guarded — the registry is global and
throws on duplicates).
- **Tuning is a JSON edit:** `strength` (wave cycles across the screen),
`amplitude` (displacement, fraction of screen width), `speed` (phase
rad/s), `padding` (framebuffer slack — keep ≥ amplitude×width). The
displacement is `dir * sin(dist*strength phase) * amplitude * fade`,
sampled through the injected `boundedSampler` (out-of-bounds reads
return transparent — a too-big amplitude punches holes; the padding is
the guard).
- **Demo:** `?fx=<type>` (e.g. `dev/test-game.html?fx=nebula`) retargets
the run to the richest system of that archetype
(`pickFxSystem`), force-discovers everything
(`Discovery.markAllDiscovered`) and activates the gates — a clean
minute of flying to judge the star. No param → ordinary play, and
`kind: none` systems never split the cameras (the single-camera
pipeline renders exactly as before).
## Nebula atmosphere — the gas behind the art
- A nebula is not only the ripple: it fills with soft coloured GAS.
A handful of large, low-alpha cloud sprites sit at **depth 3** — above
the starfield (depths 02) and below the planets (5) and the ship (10) —
so the gas reads as distant atmosphere and your art floats in FRONT of
it, never tinted by it. It is content (world-space sprites), not a
filter: it parallax-scrolls with the camera, wraps like the starfield,
and works in the Canvas fallback too (no WebGL pass). Only NEBULA-type
systems wear it.
- **Per-system colour:** every system gets a stable atmosphere color —
one of the 8-shade palette, picked at random at galaxy generation
(SystemGenerator, dedicated fork `(seed,'system',id,'atmosphere')`, so
lazy === eager). Stored as `content.atmosphere.color`; the layer tints
to it. The 8 shades are well-spread around the hue wheel (orange,
gold, green, cyan, blue, violet, magenta, crimson) — each nebula is a
different-feeling place.
- **The cloud:** a small set of distinct procedural puffs (value-noise
fbm for the internal structure, multiplied by a WIDE RADIAL falloff so
each has no perceptible silhouette — fully transparent by the inscribed
circle, no hard border or corner). Generated once via the shared
`canvasTexture` helper; every nebula reuses the set and differs only in
tint. NORMAL blending (the default) so overlaps layer softly instead of
ADD-ing into bright seams, and each puff swirls slowly on the scene
clock.
- **Files:** `js/visuals/NebulaAtmosphere.js` (layer + `drawCloud`),
`data/game.json → nebula` (palette + `count/alpha/size/parallax/drift/
texture` tunables), `js/galaxy/SystemGenerator.js` (per-system color),
`js/scenes/GameScene.js` (create/update/destroy wiring),
`dev/nebula.test.mjs` (15 checks, bare Node: palette contract, per-system
color determinism, cloud texture).
- **Tuning is a JSON edit** (`data/game.json → nebula`): `alpha`
(opacity — keep it a wash, not a wall), `count` (cloud sprites),
`size` (cloud px), `parallax` (keep it slower than the starfield's
0.150.85 so it sits far behind), `drift`, `shapes` (distinct puff
varieties — more = less repetition), and `texture` (noise
`octaves/persistence`).
- **See it:** `dev/test-game.html?fx=nebula&seed=<seed>` drops you in a
nebula (a different seed → a different gas color) — fly around and
judge the look.
## Roadmap (working list, intentionally rough) ## Roadmap (working list, intentionally rough)
- [x] v0.1 foundation — menu → New Game → click-to-fly ship - [x] v0.1 foundation — menu → New Game → click-to-fly ship

View File

@ -31,6 +31,22 @@ export class Discovery {
return !!set && set.has(objectId); return !!set && set.has(objectId);
} }
/**
* Mark every listed object in a system as discovered (no proximity
* check) the demo's "fully charted" start (dev/system-effects.test
* verifies the behavior). Returns the number of objects marked.
*/
markAllDiscovered(systemId, objects) {
let known = this.bySystem.get(systemId);
if (!known) {
known = new Set();
this.bySystem.set(systemId, known);
}
const before = known.size;
for (const o of objects ?? []) known.add(o.id);
return known.size - before;
}
/** /**
* Proximity check against a list of objects. * Proximity check against a list of objects.
* *

44
js/galaxy/FxSystems.js Normal file
View File

@ -0,0 +1,44 @@
/**
* Picking a system for the effects demo (?fx=<type>) pure over the
* seeded roster, so dev/system-effects.test.mjs can exercise the
* ranking in bare Node.
*
* The demo wants to drop the player into a system of the requested
* archetype that is GOOD for judging the effect: plenty of structure to
* warp (worlds, a belt, a gate or two to jump out of). Candidates are
* therefore ranked richest-first more objects, then more jump gates
* with roster order as the stable tiebreak (seed-deterministic either
* way: the roster is seed-ordered and the score is a pure function of
* the record + its cached contents).
*/
/**
* @param {object} galaxy a seeded galaxy (Galaxy.js) reads `.records`
* ({ id, type, ... }[]) and `.contentCache` (Map id -> contents,
* possibly empty content is lazy).
* @param {string} type the requested archetype (systems.types key)
* @returns {object | null} the chosen record (has `.id`), or null when
* the roster holds no system of that type.
*/
export function pickFxSystem(galaxy, type) {
const records = Array.isArray(galaxy?.records) ? galaxy.records : [];
const cache = galaxy?.contentCache instanceof Map ? galaxy.contentCache : null;
let best = null;
let bestKey = null;
for (const rec of records) {
if (!rec || rec.type !== type) continue;
const content = cache?.get(rec.id) ?? null;
const objects =
(Array.isArray(content?.planets) ? content.planets.length : 0) +
(Array.isArray(content?.settlements) ? content.settlements.length : 0) +
(Array.isArray(content?.asteroids) ? content.asteroids.length : 0);
const gates = Array.isArray(content?.jumps) ? content.jumps.length : 0;
const key = objects * 1000 + gates;
if (!best || key > bestKey) {
best = rec;
bestKey = key;
}
}
return best;
}

View File

@ -264,6 +264,21 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
}; };
const hazard = rng.chance(attr.hazard ?? 0.1); const hazard = rng.chance(attr.hazard ?? 0.1);
// --- Nebula atmosphere color ------------------------------------------
// Every system gets a stable atmosphere color — a random pick from the
// 8-color palette (data/game.json → nebula.palette). A dedicated fork
// (seed, 'system', id, 'atmosphere') keeps it independent of the
// star/planet/settlement/layout draws above, so lazy (on-arrival) ===
// eager (generateAll) is preserved. Only NEBULA systems RENDER it
// (GameScene + js/visuals/NebulaAtmosphere.js), but the color is stamped
// on every record — the palette is future-proof for other archetypes.
const atmosphereRng = Rng.derive(galaxy.seed, 'system', record.id, 'atmosphere');
const nebulaPalette = config.get('game.nebula.palette', []);
const atmosphereColor =
Array.isArray(nebulaPalette) && nebulaPalette.length > 0
? nebulaPalette[atmosphereRng.int(0, nebulaPalette.length - 1)]
: null;
const content = { const content = {
name: record.name, name: record.name,
type: record.type, type: record.type,
@ -274,6 +289,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
belt, belt,
hazard, hazard,
jumps, // the system's jump gates (13; [] for a one-system galaxy) jumps, // the system's jump gates (13; [] for a one-system galaxy)
atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems)
}; };
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)

View File

@ -1,6 +1,7 @@
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 { toColor } from '../utils/Color.js'; import { toColor } from '../utils/Color.js';
import { assignWorld } from '../visuals/UiCameras.js';
const SOFT_KEY = '__mining_soft'; const SOFT_KEY = '__mining_soft';
@ -117,6 +118,15 @@ export class MiningBeam {
this.impactCore = this._soft(0xffffff, 12.5); this.impactCore = this._soft(0xffffff, 12.5);
this.emitter = this._soft(0x9fd8ff, 12.5); this.emitter = this._soft(0x9fd8ff, 12.5);
this.head = this._soft(0xffffff, 12.5).setAlpha(0); this.head = this._soft(0xffffff, 12.5).setAlpha(0);
// World-anchored (the beam spans ship→rock in world space): keep all
// six passes off the UI camera while the UI/world split is live —
// the additive glow would otherwise double-draw on the second pass.
assignWorld(scene, this.gUnder);
assignWorld(scene, this.gOver);
assignWorld(scene, this.impactGlow);
assignWorld(scene, this.impactCore);
assignWorld(scene, this.emitter);
assignWorld(scene, this.head);
// ---- particle / event state ---------------------------------------- // ---- particle / event state ----------------------------------------
this.particles = []; // { active, u, t0, v, amp, phase, size, tail, tint, dot, px, py, pa } this.particles = []; // { active, u, t0, v, amp, phase, size, tail, tint, dot, px, py, pa }

View File

@ -18,6 +18,10 @@ import { Station } from '../entities/Station.js';
import { JumpGate } from '../entities/JumpGate.js'; 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 { SystemEffects } from '../visuals/SystemEffects.js';
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
import { pickFxSystem } from '../galaxy/FxSystems.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js'; import { ActionBar } from '../ui/ActionBar.js';
import { MineralHud } from '../ui/MineralHud.js'; import { MineralHud } from '../ui/MineralHud.js';
@ -304,6 +308,7 @@ export class GameScene extends Phaser.Scene {
this.events.once('shutdown', () => { this.events.once('shutdown', () => {
this.stopGameMusic(); this.stopGameMusic();
this.setMiningLoop(false); // the hum can't outlive the scene this.setMiningLoop(false); // the hum can't outlive the scene
this.systemEffects?.release(); // the effect can't outlive its scene
}); });
this.events.on('sleep', () => { this.events.on('sleep', () => {
this.stopGameMusic(); // no space hum on a world's surface this.stopGameMusic(); // no space hum on a world's surface
@ -318,6 +323,22 @@ export class GameScene extends Phaser.Scene {
// registry), the current system record, and its lazy contents — the // registry), the current system record, and its lazy contents — the
// dossier below and the world's other planets both read them. // dossier below and the world's other planets both read them.
this.ensureGalaxy(); this.ensureGalaxy();
// THE EFFECTS DEMO (?fx=<type> — js/visuals/SystemEffects.js): drop
// the run into the richest system of that archetype (ranked in
// js/galaxy/FxSystems.js), fully charted, all gates live — a clean
// minute of flying to judge the star's character. It retargets the
// CURRENT SYSTEM only (the seed and the galaxy are untouched), so
// everything downstream — the record, the contents, the worlds,
// the gates, the tethers — builds for the demo system for free.
this.fxSystemType = this._readFxSystemType();
if (this.fxSystemType) {
const rec = pickFxSystem(this.galaxy, this.fxSystemType);
if (rec) this.galaxy.currentSystemId = rec.id;
else {
console.warn(`[orbit] ?fx=${this.fxSystemType} — no system of that type in this galaxy; starting as usual.`);
this.fxSystemType = null;
}
}
// A LOAD from the save panel staged its live state (ship / tethers / // A LOAD from the save panel staged its live state (ship / tethers /
// playtime) in the registry (js/save/SaveData.js) — the ship and the // playtime) in the registry (js/save/SaveData.js) — the ship and the
// tether field don't exist yet, so apply it once they do (below). // tether field don't exist yet, so apply it once they do (below).
@ -401,6 +422,15 @@ export class GameScene extends Phaser.Scene {
for (const j of this.systemContent.jumps ?? []) { for (const j of this.systemContent.jumps ?? []) {
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true; if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
} }
if (this.fxSystemType) {
// The demo wants the whole system LIVE: every gate on (its tether
// is the flight range — the union of them is the playable space),
// so a jump out and back is one click away.
for (const j of this.systemContent.jumps ?? []) {
j.active = true;
this.activatedGates.add(`${this.systemRecord.id}>${j.to}`);
}
}
// The CENTRAL BODY — the home world, present ONLY in the STARTING // The CENTRAL BODY — the home world, present ONLY in the STARTING
// system, at the world origin (the generator deals its name — // system, at the world origin (the generator deals its name —
// content.homeName — so it reads as a real place rather than a generic // content.homeName — so it reads as a real place rather than a generic
@ -567,6 +597,22 @@ export class GameScene extends Phaser.Scene {
this.starfield = new Starfield(this); this.starfield = new Starfield(this);
this.starfield.create(); this.starfield.create();
// NEBULA GAS — a soft coloured cloud layer BEHIND the art (depth 3),
// tinted with the system's own atmosphere color (assigned at random at
// galaxy generation — SystemGenerator). Nebula systems only. See
// js/visuals/NebulaAtmosphere.js.
if (
this.systemRecord?.type === 'nebula' &&
this.systemContent?.atmosphere?.color
) {
this.nebula = new NebulaAtmosphere(
this,
this.systemContent.atmosphere.color,
config.get('game.nebula', {}),
);
this.nebula.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
@ -609,6 +655,14 @@ export class GameScene extends Phaser.Scene {
this.discovery = new Discovery(config.get('game.discovery.distance', 540)); this.discovery = new Discovery(config.get('game.discovery.distance', 540));
this.registry.set('discovery', this.discovery); this.registry.set('discovery', this.discovery);
} }
if (this.fxSystemType) {
// The demo's "fully charted": the system is already discovered —
// the compass rests, the dossier reads complete, no pings compete
// with the effect for your eye. (check() can't do this — its
// radius is capped at the configured discovery distance — so the
// demo marks the roster directly.)
this.discovery.markAllDiscovered(this.systemRecord.id, this.discoverableObjects());
}
// REPUTATION — the player's standing on each planet and space station // REPUTATION — the player's standing on each planet and space station
// (js/reputation/Reputation.js): 20…+20 (data/reputation.json), // (js/reputation/Reputation.js): 20…+20 (data/reputation.json),
@ -1179,6 +1233,16 @@ export class GameScene extends Phaser.Scene {
// re-applies this frame's inward velocity right after the push-out — // re-applies this frame's inward velocity right after the push-out —
// the ship would dip a frame-deep into every rim it meets at speed. // the ship would dip a frame-deep into every rim it meets at speed.
this.events.on('postupdate', this.onPostUpdate, this); this.events.on('postupdate', this.onPostUpdate, this);
// SYSTEM EFFECT (the star's character — js/visuals/SystemEffects.js):
// data-driven per type (data/systems.json → effect). "none" (the
// common case) attaches nothing — the scene renders exactly as
// before. A live effect (nebula's ripple) splits the cameras first
// (world pass carries the filter; the UI pass draws the HUD on top,
// unfiltered — js/visuals/UiCameras.js), then attaches the filter
// to the world camera.
this.systemEffects = new SystemEffects(this);
this.systemEffects.apply(this.systemRecord.type);
} }
/** /**
@ -1227,6 +1291,30 @@ export class GameScene extends Phaser.Scene {
} }
} }
/**
* The effects demo's archetype (js/visuals/SystemEffects.js): read the
* `?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).
* `?fx` alone defaults to `nebula` the one effect family wired up so
* far. Example: http://localhost:8080/?fx=nebula
*
* @returns {string | null} a systems.types key, or null
*/
_readFxSystemType() {
const loc = typeof globalThis !== 'undefined' ? globalThis.location : null;
if (!loc?.search) return null;
try {
const raw = new URLSearchParams(loc.search).get('fx') ?? '';
const type = (raw.trim() || 'nebula').toLowerCase();
const types = config.section('systems.types', {});
return types && typeof types === 'object' && Object.prototype.hasOwnProperty.call(types, type)
? type
: null;
} catch {
return null;
}
}
/** /**
* Top-left HUD: the current system's dossier the system name, its * Top-left HUD: the current system's dossier the system name, its
* identity ("<Type> system · star <class>"), and its standing: faction * identity ("<Type> system · star <class>"), and its standing: faction
@ -1527,6 +1615,7 @@ export class GameScene extends Phaser.Scene {
this.time.update(_time, delta); this.time.update(_time, delta);
this.tweens.update(); this.tweens.update();
this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle
this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV)
this.questTracker?.update(_time); // the tracker: live checklist + priority this.questTracker?.update(_time); // the tracker: live checklist + priority
this.ship.update(_time, delta); this.ship.update(_time, delta);
// Session time (saved with the game) — capped so a backgrounded tab // Session time (saved with the game) — capped so a backgrounded tab
@ -1570,6 +1659,7 @@ export class GameScene extends Phaser.Scene {
this.updateSignalCompass(_time); // the secondary compass: bearing signals on the ring this.updateSignalCompass(_time); // the secondary compass: bearing signals on the ring
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.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
@ -1628,6 +1718,8 @@ export class GameScene extends Phaser.Scene {
g1.setPosition(x0, yy).setDepth(45).setAlpha(0); g1.setPosition(x0, yy).setDepth(45).setAlpha(0);
g2.setPosition(x0 + g1.width + 10, yy).setDepth(45).setAlpha(0); g2.setPosition(x0 + g1.width + 10, yy).setDepth(45).setAlpha(0);
this.consoleToastG = [g1, g2]; this.consoleToastG = [g1, g2];
assignUi(this, g1); // the UI pass (no double-draw while the split is live)
assignUi(this, g2);
this.tweens.add({ targets: this.consoleToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' }); this.tweens.add({ targets: this.consoleToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
this.time.delayedCall(durationMs, () => { this.time.delayedCall(durationMs, () => {
if (!Array.isArray(this.consoleToastG)) return; if (!Array.isArray(this.consoleToastG)) return;
@ -2121,11 +2213,13 @@ export class GameScene extends Phaser.Scene {
.rectangle(W / 2, H / 2, W, H, toColor(themeColor('bg', 0x04060d))) .rectangle(W / 2, H / 2, W, H, toColor(themeColor('bg', 0x04060d)))
.setScrollFactor(0) .setScrollFactor(0)
.setDepth(60); .setDepth(60);
assignUi(this, this._jumpBackdrop); // the UI pass (no double-draw)
// v4: add.video(x, y, key) — the key is the LAST argument (it loads the // v4: add.video(x, y, key) — the key is the LAST argument (it loads the
// cached clip and attaches the <video> element). scrollFactor(0) keeps // cached clip and attaches the <video> element). scrollFactor(0) keeps
// the clip pinned to the screen as the camera scrolls (see above). // the clip pinned to the screen as the camera scrolls (see above).
const v = this.add.video(0, 0, JUMP_VIDEO_KEY).setOrigin(0.5).setScrollFactor(0).setDepth(61); const v = this.add.video(0, 0, JUMP_VIDEO_KEY).setOrigin(0.5).setScrollFactor(0).setDepth(61);
this._jumpVideo = v; this._jumpVideo = v;
assignUi(this, v); // the UI pass (no double-draw)
v.setVolume(Math.max(0, Math.min(1, Number(config.get('gates.jump.videoVolume', 1))))); v.setVolume(Math.max(0, Math.min(1, Number(config.get('gates.jump.videoVolume', 1)))));
this._fitJumpClip(v); this._fitJumpClip(v);
// Re-fit on the first presented frame (v4: the bookkeeping size is a // Re-fit on the first presented frame (v4: the bookkeeping size is a
@ -2578,6 +2672,7 @@ export class GameScene extends Phaser.Scene {
// Expanding ring at the world's rim (world space). // Expanding ring at the world's rim (world space).
const ring = this.add.circle(o.x, o.y, o.radius, 0, 0).setStrokeStyle(2, neon, 0.9).setDepth(6); const ring = this.add.circle(o.x, o.y, o.radius, 0, 0).setStrokeStyle(2, neon, 0.9).setDepth(6);
assignWorld(this, ring); // world-anchored — keep it off the UI pass
this.tweens.add({ this.tweens.add({
targets: ring, targets: ring,
scale: 1.12, scale: 1.12,
@ -2602,6 +2697,7 @@ export class GameScene extends Phaser.Scene {
showTargetMarker(x, y) { showTargetMarker(x, y) {
const color = toColor(config.get('game.markerColor', '#41c7ff')); const color = toColor(config.get('game.markerColor', '#41c7ff'));
const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5); const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5);
assignWorld(this, marker); // world-anchored — keep it off the UI pass
this.tweens.add({ this.tweens.add({
targets: marker, targets: marker,
scale: 2.4, scale: 2.4,
@ -4171,6 +4267,7 @@ export class GameScene extends Phaser.Scene {
this.setMiningLoop(false); // the hum can't outlive the scene this.setMiningLoop(false); // the hum can't outlive the scene
this.stopGameMusic(); // neither can the soundtrack this.stopGameMusic(); // neither can the soundtrack
this.starfield?.destroy(); this.starfield?.destroy();
this.nebula?.destroy();
this.compass?.destroy(); this.compass?.destroy();
this.actionBar?.destroy(); this.actionBar?.destroy();
this.menuSubBar?.destroy(); this.menuSubBar?.destroy();

View File

@ -0,0 +1,264 @@
import Phaser from '../vendor/phaser.js';
import { toColor } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js';
import { assignWorld } from './UiCameras.js';
const TEX_KEY = '__nebula_cloud';
const MARGIN = 120; // px of cloud buffer beyond the camera view, each side
const DEPTH = 3; // above the starfield (02), below the planets (5) and ship (10)
/**
* The nebula GAS soft coloured clouds that sit BEHIND the art.
*
* A handful of large, low-alpha cloud sprites at depth 3: above the
* starfield (depths 02) and below the planets (5) and the ship (10). So
* the gas reads as distant atmosphere your art floats in front of it,
* never tinted by it. It is CONTENT (sprites in world space), not a
* filter: it parallax-scrolls with the camera, wraps like the starfield,
* and works in the Canvas fallback too (no WebGL filter pass).
*
* Each nebula system gets its own color assigned at random at galaxy
* generation (SystemGenerator stamps content.atmosphere.color from the
* palette in data/game.json nebula). The cloud SHAPE is shared (one
* procedural texture, generated once); only the tint differs, so the
* "gas" reads as the same kind of phenomenon in every nebula, just a
* different hue.
*
* Tuned by data/game.json nebula { enabled, palette, count, alpha,
* size, parallax, drift, texture }.
*/
export class NebulaAtmosphere {
/**
* @param {object} scene Phaser scene (the GameScene)
* @param {string|number} color the system's atmosphere color (hex or int)
* @param {object} cfg data/game.json nebula
*/
constructor(scene, color, cfg = {}) {
this.scene = scene;
this.cfg = cfg;
this.color = toColor(color, 0xffffff);
this.clouds = [];
this.lastScrollX = 0;
this.lastScrollY = 0;
this.enabled = cfg.enabled !== false;
}
create() {
if (!this.enabled) return;
const scene = this.scene;
const width = scene.scale.width;
const height = scene.scale.height;
const cam = scene.cameras.main;
// A few DISTINCT soft cloud shapes (different noise seeds) so the gas
// doesn't read as one image repeated — only the tint is shared between
// them. Generated once and cached by key; every nebula reuses the set.
const texSize = Math.max(32, Math.round(this.cfg.texture?.size ?? 256));
const shapes = Math.max(1, Math.round(this.cfg.shapes ?? 3));
const CLOUD_SEEDS = [1337, 4242, 9001, 617, 24601];
const texKeys = Array.from({ length: shapes }, (_, i) =>
canvasTexture(scene, `${TEX_KEY}_${i}`, texSize, texSize, (ctx, w, h) => {
drawCloud(ctx, w, h, this.cfg.texture ?? {}, CLOUD_SEEDS[i % CLOUD_SEEDS.length]);
}),
);
const count = Math.max(0, Math.round(this.cfg.count ?? 6));
const [aMin, aMax] = normRange(this.cfg.alpha, [0.09, 0.18]);
const [sMin, sMax] = normRange(this.cfg.size, [420, 900]);
const [pMin, pMax] = normRange(this.cfg.parallax, [0.10, 0.22]);
// Clouds start in a window around the current camera view; update()
// keeps them wrapped into it as the camera flies (the starfield model).
const left = cam.scrollX - MARGIN;
const top = cam.scrollY - MARGIN;
for (let i = 0; i < count; i++) {
const key = texKeys[Phaser.Math.Between(0, texKeys.length - 1)];
const cloud = scene.add.image(
Phaser.Math.FloatBetween(left, left + width + 2 * MARGIN),
Phaser.Math.FloatBetween(top, top + height + 2 * MARGIN),
key,
);
// Parallax: keep it FAR (slower than the starfield's 0.150.85) so
// the gas sits behind the stars' nearest layer.
cloud.parallax = Phaser.Math.FloatBetween(pMin, pMax);
const s = Phaser.Math.FloatBetween(sMin, sMax) / texSize;
cloud
.setScale(s)
.setAlpha(Phaser.Math.FloatBetween(aMin, aMax))
.setTint(this.color)
.setRotation(Phaser.Math.FloatBetween(0, Math.PI * 2))
.setDepth(DEPTH);
// NORMAL blending (the default): overlaps layer softly instead of
// ADD-ing into bright seams, so no cloud edge reads as a hard rim.
// A slow self-rotation (rad/s) so the gas swirls even while the ship
// holds still — driven from the scene clock in update() (frame-safe).
cloud.baseRotation = cloud.rotation;
cloud.rotSpeed = (Phaser.Math.Between(0, 1) ? 1 : -1) * Phaser.Math.FloatBetween(0.003, 0.009);
// Keep it on the WORLD pass if the system-effect UI-camera split is
// active (a semi-transparent world object would otherwise draw twice).
// No-op while the split does not exist (single-camera play).
assignWorld(scene, cloud);
this.clouds.push(cloud);
}
this.lastScrollX = cam.scrollX;
this.lastScrollY = cam.scrollY;
}
/**
* Shift the gas opposite to the camera motion, wrap it back into the
* current view, and let each cloud swirl slowly. Call once per frame,
* after the camera has moved (alongside Starfield.update()).
*/
update() {
if (!this.enabled || this.clouds.length === 0) return;
const scene = this.scene;
const cam = scene.cameras.main;
// Slow self-swirl (frame-rate independent — the scene clock is ms).
const t = (scene.time?.now ?? 0) * 0.001;
for (const cloud of this.clouds) {
cloud.rotation = cloud.baseRotation + t * cloud.rotSpeed;
}
// Camera parallax + wrap (the starfield model).
const dx = cam.scrollX - this.lastScrollX;
const dy = cam.scrollY - this.lastScrollY;
this.lastScrollX = cam.scrollX;
this.lastScrollY = cam.scrollY;
if (dx === 0 && dy === 0) return;
const spanX = scene.scale.width + 2 * MARGIN;
const spanY = scene.scale.height + 2 * MARGIN;
const left = cam.scrollX - MARGIN;
const top = cam.scrollY - MARGIN;
for (const cloud of this.clouds) {
// Move by (1 p) of the camera delta → on screen it drifts by p,
// opposite to travel; far (small p) gas barely moves.
cloud.x += dx * (1 - cloud.parallax);
cloud.y += dy * (1 - cloud.parallax);
cloud.x = wrapIn(cloud.x, left, spanX);
cloud.y = wrapIn(cloud.y, top, spanY);
}
}
destroy() {
for (const cloud of this.clouds) cloud.destroy();
this.clouds = [];
}
}
// ---------------------------------------------------------------------------
// Texture generation (runs in the browser; also importable from Node for
// tests — no Phaser here, just a 2-D canvas context).
// ---------------------------------------------------------------------------
/**
* Paint one soft cloud into a square canvas: value-noise fbm for the
* internal structure, multiplied by a wide RADIAL falloff so the puff has
* no perceptible silhouette it is fully transparent by the inscribed
* circle and dissolves gradually out, so no border or corner reads as a
* hard edge. White RGB; the alpha channel carries the density (the
* sprite's tint sets the color).
*/
export function drawCloud(ctx, w, h, tex, seed = 1337) {
const octaves = Math.max(1, Math.round(tex.octaves ?? 4));
const persistence = clamp01(tex.persistence ?? 0.55);
const img = ctx.createImageData(w, h);
const d = img.data;
const FREQ = 6; // base noise cells across the sprite (fewer, larger = smoother gas)
for (let y = 0; y < h; y++) {
const v = y / h;
for (let x = 0; x < w; x++) {
const u = x / w;
let n = 0;
let amp = 1;
let freq = FREQ;
let norm = 0;
for (let o = 0; o < octaves; o++) {
n += amp * valueNoise(u * freq, v * freq, seed + o * 101);
norm += amp;
amp *= persistence;
freq *= 2;
}
n /= norm;
// Wide radial falloff: 1 at the centre, smoothly to 0 by the
// inscribed circle (r = 1). (1 - r^2)^2 is flat-ish across the core
// and melts out gently, so the puff has no visible rim.
const r = Math.hypot(u - 0.5, v - 0.5) * 2; // 0 centre … 1 inscribed edge
let radial = Math.max(0, 1 - r * r);
radial *= radial;
// Gentle internal structure (fbm) — wispy density, not speckled dust.
const structure = smoothstep(0.30, 0.80, n);
const a = radial * structure;
const idx = (y * w + x) * 4;
d[idx] = 255;
d[idx + 1] = 255;
d[idx + 2] = 255;
d[idx + 3] = Math.round(a * 255);
}
}
ctx.putImageData(img, 0, 0);
}
// --- Deterministic value noise (no Math.random; shared by every system) ---
function hash2(ix, iy, seed = 1337) {
let h = (ix * 374761393 + iy * 668265263 + seed * 1442695) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function fade(t) {
return t * t * (3 - 2 * t);
}
function valueNoise(x, y, seed = 1337) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = fade(x - ix);
const fy = fade(y - iy);
const h00 = hash2(ix, iy, seed);
const h10 = hash2(ix + 1, iy, seed);
const h01 = hash2(ix, iy + 1, seed);
const h11 = hash2(ix + 1, iy + 1, seed);
const top = h00 + (h10 - h00) * fx;
const bot = h01 + (h11 - h01) * fx;
return top + (bot - top) * fy;
}
function smoothstep(e0, e1, x) {
const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
return t * t * (3 - 2 * t);
}
function clamp01(v) {
return Math.min(1, Math.max(0, typeof v === 'number' ? v : 0.5));
}
function normRange(range, fallback) {
if (Array.isArray(range) && range.length === 2 && range.every((n) => typeof n === 'number')) {
return [Math.min(range[0], range[1]), Math.max(range[0], range[1])];
}
return fallback;
}
/** Maps any coordinate into [left, left + span) — safe for huge deltas too. */
function wrapIn(v, left, span) {
return left + ((((v - left) % span) + span) % span);
}

266
js/visuals/SystemEffects.js Normal file
View File

@ -0,0 +1,266 @@
/**
* Per-system visual effects the star's "character", rendered as a
* full-screen composite over the WORLD camera.
*
* Why camera-level (not per-object): a system is a PLACE. Its effect
* should bend the light of everything in it the starfield included
* as one continuous spacetime distortion. Per-body filters would need
* one composite pass per body (200+ starfield sprites, no) and the
* stars could never warp. One camera filter = one full-screen pass,
* constant cost, whole scene coheres. The HUD is excluded by drawing it
* on a separate, unfiltered camera (js/visuals/UiCameras.js).
*
* The effect itself is DATA-DRIVEN: every system type carries an
* `effect` block in data/systems.json types.<id>.effect:
* "effect": {
* "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
* outward. Two anchors (data-driven, `effect.center`):
* "screen" (default) the middle of the view: the sandbox look,
* 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
* Phaser and is exercised in the browser via dev/server.mjs).
*/
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { ripplePhase, worldToUV } from './SystemEffectsMath.js';
import { ensureUiCameras } from './UiCameras.js';
/** Registered render-node name for the ripple effect (renderer-global). */
export const RIPPLE_NODE = 'FilterRippleEffect';
/**
* The ripple fragment shader Phaser 4 filter-shader conventions
* (verified against the vendored build's own filter shaders):
* `uMainSampler` is the input frame, `outTexCoord` the screen UV, and
* `boundedSampler` is auto-injected via the BoundedSampler addition.
*/
const RIPPLE_FRAGMENT = [
'#pragma phaserTemplate(shaderName)',
'precision mediump float;',
'uniform sampler2D uMainSampler;',
'uniform float time;',
'uniform float strength;',
'uniform float amplitude;',
'uniform float centerX;',
'uniform float centerY;',
'varying vec2 outTexCoord;',
'#pragma phaserTemplate(fragmentHeader)',
'void main()',
'{',
' vec2 center = vec2(centerX, centerY);',
' vec2 delta = outTexCoord - center;',
' float dist = length(delta);',
' float invDist = dist > 0.0001 ? 1.0 / dist : 0.0;',
' vec2 dir = delta * invDist;',
' // Traveling rings: phase moves OUTWARD from the center (screen',
' // middle by default — the star itself is invisible flavor).',
' float wave = sin(dist * strength - time);',
' // Attenuation: zero displacement exactly on the center, a gentle',
' // calm-down toward the corners. Screen-anchored: dist stays within',
' // ~0..0.7, so the rings read strong across the view.',
' float fade = smoothstep(0.0, 0.02, dist) * (0.8 + 0.2 * exp(-dist * 0.25));',
' vec2 uv = outTexCoord + dir * (wave * amplitude * fade);',
' gl_FragColor = boundedSampler(uMainSampler, uv);',
'}',
].join('\n');
/**
* The filter's state carrier (Phaser.Filters.Controller) the values
* the shader reads each pass. Plain data: time (radians), strength
* (cycles/UV), amplitude (UV), and the star's screen UV.
*
* @extends {Phaser.Filters.Controller}
*/
class RippleController extends Phaser.Filters.Controller {
/** @param {object} camera the world camera */
constructor(camera) {
super(camera, RIPPLE_NODE);
this.time = 0;
this.strength = 90;
this.amplitude = 0.01;
this.speed = 1;
this.centerX = 0.5;
this.centerY = 0.5;
}
}
/**
* The ripple render node (Phaser.Renderer.WebGL.RenderNodes.
* BaseFilterShader) a full-screen quad through which the camera's
* composited frame flows, displaced per the controller's uniforms.
*
* @extends {Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader}
*/
class FilterRippleEffect extends Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader {
/** @param {object} manager the RenderNodes manager */
constructor(manager) {
super(RIPPLE_NODE, manager, null, RIPPLE_FRAGMENT);
}
/** Push this pass's controller values into the shader. */
setupUniforms(controller, _drawingContext) {
const pm = this.programManager;
pm.setUniform('time', controller.time);
pm.setUniform('strength', controller.strength);
pm.setUniform('amplitude', controller.amplitude);
pm.setUniform('centerX', controller.centerX);
pm.setUniform('centerY', controller.centerY);
}
}
/**
* Register the ripple node with the renderer (idempotent the registry
* is renderer-global and throws on a duplicate constructor).
*
* @param {object} renderer Phaser renderer
* @returns {boolean} true if the node is registered after the call
*/
export function ensureRippleNode(renderer) {
const nodes = renderer?.renderNodes;
if (!nodes || typeof nodes.hasNode !== 'function') return false;
if (nodes.hasNode(RIPPLE_NODE)) return true;
try {
nodes.addNodeConstructor(RIPPLE_NODE, FilterRippleEffect);
return true;
} catch (err) {
console.warn(`[orbit] could not register the ripple filter node: ${err}`);
return false;
}
}
/**
* The facade GameScene drives: one effect per system type, applied on
* entry, released on exit. Holds NO scene-lifetime assumptions of its
* own (the scene's shutdown() calls release()).
*/
export class SystemEffects {
/** @param {object} scene Phaser scene */
constructor(scene) {
this.scene = scene;
this.controller = null;
this.kind = null;
}
/** True while a filter is attached to the world camera. */
get active() {
return this.controller !== null;
}
/**
* Apply the system type's effect (data/systems.json
* types.<type>.effect). Replaces any current effect. No-op for
* `kind: "none"` / missing blocks and for non-WebGL renderers.
*
* @param {string} systemType one of systems.types keys
* @returns {boolean} whether an effect ended up active
*/
apply(systemType) {
this.release();
const eff = config.get(`systems.types.${systemType}.effect`, null);
const kind = eff?.kind ?? 'none';
if (kind === 'none' || !eff || typeof eff !== 'object') {
this.kind = null;
return false;
}
if (kind !== 'ripple') {
// A family we have not implemented yet — degrade to none.
console.warn(`[orbit] system effect "${kind}" is not implemented yet — rendering none.`);
this.kind = null;
return false;
}
const scene = this.scene;
const renderer = scene.renderer;
if (!renderer || !renderer.gl) {
// Canvas fallback: no GLSL, no composite — the system renders
// untouched rather than broken.
return false;
}
if (!ensureRippleNode(renderer)) return false;
// The UI camera split must exist before the first filtered pass:
// main draws the world (and the filter displaces it); fx-ui draws
// the HUD on top, unfiltered.
if (!ensureUiCameras(scene)) {
console.warn('[orbit] UI camera split failed — the effect would warp the HUD; rendering none.');
return false;
}
const cam = scene.cameras.main;
const c = new RippleController(cam);
c.strength = Number(eff.strength) || 90;
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;
this.kind = kind;
return true;
}
/**
* Per-frame advance: the wave phase, and for the "star" anchor
* the star's screen UV (the camera scrolls with the ship, so the
* center tracks it and the distortion stays pinned to the star).
* Screen-anchored effects only advance the phase.
*
* @param {number} nowMs game-loop time (ms)
*/
update(nowMs) {
const c = this.controller;
if (!c) return;
c.time = ripplePhase(nowMs, c.speed);
if (c.center !== 'star') return;
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.centerX = uv.x;
c.centerY = uv.y;
}
}
/** Detach and destroy the current effect (safe to call repeatedly). */
release() {
const c = this.controller;
if (!c) return;
this.controller = null;
this.kind = null;
const list = c?.camera?.filters?.internal;
if (list?.remove) list.remove(c);
else if (typeof c?.destroy === 'function') c.destroy();
}
}

View File

@ -0,0 +1,70 @@
/**
* Pure math for the per-system visual effects NO Phaser import, so the
* dev harness (dev/system-effects.test.mjs) can run it in bare Node.
*
* The system effect is a full-screen composite over the WORLD camera
* (see SystemEffects.js): a fragment shader displaces the camera's
* screen-space UVs around the system's star. The star is never rendered
* (it is invisible flavor data/systems.json), but it sits at the
* system origin (world 0,0), so the ripple center is "where world (0,0)
* projects to, in the camera's UV space".
*
* A camera's `matrixCombined` is the world->screen affine transform
* (a·x + c·y + tx, b·x + d·y + ty). Dividing the screen point by the
* camera's viewport size gives the UV: (0,0) = the camera's top-left,
* (1,1) = its bottom-right exactly the space the shader's
* `outTexCoord` lives in.
*/
/**
* @typedef {{ a: number, b: number, c: number, d: number, tx: number, ty: number }} Affine2D
*
* Affine2D the shape of a Phaser camera's matrixCombined (the
* world->screen transform). Duck-typed on purpose: the tests pass plain
* objects, the game passes the real matrix.
*/
/**
* Screen-space point of a world point under an affine transform.
*
* @param {Affine2D} m world->screen transform
* @param {number} [wx=0] world x
* @param {number} [wy=0] world y
* @returns {{ x: number, y: number }} screen point (camera viewport px)
*/
export function worldToScreen(m, wx = 0, wy = 0) {
return {
x: m.a * wx + m.c * wy + m.tx,
y: m.b * wx + m.d * wy + m.ty,
};
}
/**
* UV coordinate of a world point for a camera of the given viewport size
* (0,0 top-left -> 1,1 bottom-right).
*
* @param {Affine2D} m world->screen transform
* @param {number} width camera viewport width (px)
* @param {number} height camera viewport height (px)
* @param {number} [wx=0] world x
* @param {number} [wy=0] world y
* @returns {{ x: number, y: number }} UV
*/
export function worldToUV(m, width, height, wx = 0, wy = 0) {
const p = worldToScreen(m, wx, wy);
return { x: p.x / width, y: p.y / height };
}
/**
* The wave's phase (radians) at a moment — the shader's `time` uniform.
* Monotonic in the game clock, scaled by the configured speed, so the
* ripple keeps its pace across system entries without any per-frame
* accumulation to drift.
*
* @param {number} nowMs game-loop time (ms, monotonic)
* @param {number} [speed=1] angular speed multiplier (radians/second)
* @returns {number} phase, radians
*/
export function ripplePhase(nowMs, speed = 1) {
return (nowMs / 1000) * (Number(speed) || 0);
}

117
js/visuals/UiCameras.js Normal file
View File

@ -0,0 +1,117 @@
/**
* The UI camera split the seam that lets a system effect (a camera
* filter on the WORLD camera) warp the world while the HUD stays crisp.
*
* How Phaser 4 draws cameras: each camera is one render pass, in
* `scene.cameras` array order (the array's FIRST camera draws first, so
* later cameras draw ON TOP). Every pass can composite through its own
* `filters.internal` list (WebGL). `camera.ignore(targets)` marks the
* targets (and their container descendants) with the camera's id bit
* `object.willRender(camera)` then fails for that camera so a target
* simply does not exist for that pass. There is no un-ignore; the
* partition is set once.
*
* The split this module owns:
* - `cameras[0]` = main the WORLD camera (scrolls with the ship).
* The system effect's filter is attached here.
* - `cameras[1]` = `fx-ui` the UI camera (fixed viewport). It is
* force-composited (rendered to a framebuffer, then blitted over
* the main pass) so it draws on top WITHOUT clearing the main
* camera's output a second non-composited pass would clear the
* canvas and erase the world.
*
* Partition rule (heuristic, deliberate): a top-level scene object is
* UI iff it is screen-pinned (scrollFactor 0 on either axis). That is
* exactly this game's screen/world divide the HUD, deck, panels and
* toasts are scrollFactor 0; the starfield, worlds, gates, tethers,
* the ship, even the world-anchored pop-ups (comms, mining) are not.
* Screen-pinned objects are ignored by MAIN (they exist only for the UI
* pass); world objects are ignored by the UI pass (they exist only for
* the world pass, and are what the effect displaces starfield
* included, for free).
*
* Consequences to remember (audited, deliberate):
* - The UI no longer shakes with the world camera (shake is a
* camera effect the UI camera is untouched).
* - Input is camera-aware: the interaction manager hit-tests each
* camera under the pointer, in REVERSE array order the UI pass
* first and an object is only a candidate where `willRender`
* holds. UI-over-world clicks therefore keep working, with the UI
* winning (the same topness the single-camera depth order gave).
*
* Lazily created objects (toasts, the jump clip, world markers, the
* mining beam) are assigned explicitly at their creation sites via
* assignUi()/assignWorld() below.
*/
/**
* @param {object} scene Phaser scene
* @returns {boolean} true if the object is screen-pinned (UI)
*/
export function isScreenPinned(obj) {
return (
typeof obj?.scrollFactorX === 'number' &&
(obj.scrollFactorX === 0 || obj.scrollFactorY === 0)
);
}
/**
* Create (once) the fixed UI camera and partition the scene's
* top-level objects between the two passes. Idempotent subsequent
* calls return the existing split.
*
* @param {object} scene Phaser scene
* @returns {{ main: object, ui: object } | null} the split, or null if
* the scene has no camera plugin (defensive).
*/
export function ensureUiCameras(scene) {
if (scene._fxUiCameras) return scene._fxUiCameras;
const cameras = scene.cameras;
if (!cameras?.cameras || cameras.cameras.length === 0) return null;
const main = cameras.main;
const ui = cameras.add(0, 0, scene.scale.width, scene.scale.height, false, 'fx-ui');
// The UI pass must composite (FBO + blit over the main pass) — a
// second DIRECT pass would clear() the canvas and erase the world.
if (typeof ui.setForceComposite === 'function') ui.setForceComposite(true);
const uiRoots = [];
const worldRoots = [];
const children = scene.sys.displayList?.getChildren?.() ?? [];
for (const child of children) {
if (isScreenPinned(child)) uiRoots.push(child);
else worldRoots.push(child);
}
if (uiRoots.length) main.ignore(uiRoots); // UI: world pass never sees it
if (worldRoots.length) ui.ignore(worldRoots); // world: UI pass never sees it
scene._fxUiCameras = { main, ui, uiRoots: uiRoots.length, worldRoots: worldRoots.length };
return scene._fxUiCameras;
}
/**
* Assign a lazily created screen-pinned object to the UI pass (main
* ignores it). No-op while the split does not exist the single-camera
* pipeline renders it exactly as before.
*
* @param {object} scene Phaser scene
* @param {object} child object to attach to the UI pass
*/
export function assignUi(scene, child) {
const split = scene._fxUiCameras;
if (split && child) split.main.ignore(child);
}
/**
* Assign a lazily created world object to the world pass (the UI camera
* ignores it, so it is not drawn twice semi-transparent world objects
* would otherwise additively double). No-op while the split does not
* exist.
*
* @param {object} scene Phaser scene
* @param {object} child object to keep on the world pass
*/
export function assignWorld(scene, child) {
const split = scene._fxUiCameras;
if (split && child) split.ui.ignore(child);
}