diff --git a/assets/images/originals/ice-planet-01.png b/assets/images/originals/ice-planet-01.png new file mode 100644 index 0000000..dc6df4e Binary files /dev/null and b/assets/images/originals/ice-planet-01.png differ diff --git a/assets/images/originals/ice-planet-02.png b/assets/images/originals/ice-planet-02.png new file mode 100644 index 0000000..0ceeab9 Binary files /dev/null and b/assets/images/originals/ice-planet-02.png differ diff --git a/assets/images/originals/ice-planet-03.png b/assets/images/originals/ice-planet-03.png new file mode 100644 index 0000000..492d68c Binary files /dev/null and b/assets/images/originals/ice-planet-03.png differ diff --git a/assets/images/planets.png b/assets/images/planets.png index c968194..7334067 100644 Binary files a/assets/images/planets.png and b/assets/images/planets.png differ diff --git a/assets/images/planets.psd b/assets/images/planets.psd index e3b339d..e4166e3 100644 Binary files a/assets/images/planets.psd and b/assets/images/planets.psd differ diff --git a/data/game.json b/data/game.json index 41e8e5c..7c41bca 100644 --- a/data/game.json +++ b/data/game.json @@ -16,6 +16,18 @@ "parallax": [0.15, 0.85], "colors": ["#ffffff", "#b9c6ff", "#8090b8"] }, + "nebula": { + "_comment": "The nebula GAS layer — soft coloured clouds BEHIND the art (depth 3: above the starfield's 0–2, 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": { "followShip": true, "followRate": 3.0 diff --git a/data/planets.json b/data/planets.json index e771170..34961a8 100644 --- a/data/planets.json +++ b/data/planets.json @@ -8,8 +8,8 @@ "terran": [0, 1, 2], "rocky": [6, 7, 8], "gas": [3, 4, 5], - "ice": [0, 1, 2], - "lava": [0, 1, 2] + "ice": [9, 10, 11], + "lava": [12, 13, 14] }, "classScale": { "rocky": 1.0, diff --git a/data/systems.json b/data/systems.json index b24c2a4..6f2c2c4 100644 --- a/data/systems.json +++ b/data/systems.json @@ -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 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).", "objectCount": { "barren": 0.10, "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.", "theme": { "color": "#9fb4e8" }, "distribution": { "weight": 34 }, + "effect": { "kind": "none" }, "attributes": { "star": { "classes": { "G": 30, "K": 40, "M": 30 }, @@ -32,6 +34,7 @@ "description": "A small, long-lived M star with close-in, moon-rich worlds.", "theme": { "color": "#e8927c" }, "distribution": { "weight": 26 }, + "effect": { "kind": "none" }, "attributes": { "star": { "classes": { "M": 85, "K": 15 }, @@ -54,6 +57,7 @@ "description": "Two stars, one system. Tangled orbits, wide spacings, rich debris.", "theme": { "color": "#c9a7ff" }, "distribution": { "weight": 10 }, + "effect": { "kind": "none" }, "attributes": { "star": { "classes": { "F": 25, "G": 40, "K": 35 }, @@ -76,6 +80,7 @@ "description": "Temperate, well-lit, and quietly crowded with life. Rare.", "theme": { "color": "#7ce8a4" }, "distribution": { "weight": 10 }, + "effect": { "kind": "none" }, "attributes": { "star": { "classes": { "G": 70, "K": 30 }, @@ -98,6 +103,14 @@ "description": "Young, bright, and still messy — debris where planets should be.", "theme": { "color": "#5fd4d0" }, "distribution": { "weight": 12 }, + "effect": { + "kind": "ripple", + "center": "screen", + "strength": 20, + "amplitude": 0.0042, + "speed": 1.0, + "padding": 20 + }, "attributes": { "star": { "classes": { "A": 20, "F": 30, "G": 50 }, @@ -120,6 +133,7 @@ "description": "Old, cold, and mostly empty. The rim's quiet dead ends.", "theme": { "color": "#7d88a8" }, "distribution": { "weight": 8 }, + "effect": { "kind": "none" }, "attributes": { "star": { "classes": { "M": 90, "K": 10 }, diff --git a/dev/nebula.test.mjs b/dev/nebula.test.mjs new file mode 100644 index 0000000..5950295 --- /dev/null +++ b/dev/nebula.test.mjs @@ -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); diff --git a/dev/phaser-stub.mjs b/dev/phaser-stub.mjs index 932ceee..787529c 100644 --- a/dev/phaser-stub.mjs +++ b/dev/phaser-stub.mjs @@ -34,10 +34,52 @@ function hexToRgbInt(v) { 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 { GameObjects: { Container, Sprite }, Physics: { Arcade: { Sprite } }, Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } }, + Filters: { Controller }, + Renderer: { WebGL: { RenderNodes: { BaseFilterShader } } }, Math: { Clamp: (v, a, b) => Math.min(b, Math.max(a, v)), Linear: (a, b, t) => a + (b - a) * t, diff --git a/dev/system-effects.test.mjs b/dev/system-effects.test.mjs new file mode 100644 index 0000000..d4f7ccf --- /dev/null +++ b/dev/system-effects.test.mjs @@ -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`); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 1d0d973..97df4b4 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 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..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=` (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 0–2) 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.15–0.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=` drops you in a + nebula (a different seed → a different gas color) — fly around and + judge the look. + ## Roadmap (working list, intentionally rough) - [x] v0.1 foundation — menu → New Game → click-to-fly ship diff --git a/js/galaxy/Discovery.js b/js/galaxy/Discovery.js index 0635b0c..76f1be5 100644 --- a/js/galaxy/Discovery.js +++ b/js/galaxy/Discovery.js @@ -31,6 +31,22 @@ export class Discovery { 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. * diff --git a/js/galaxy/FxSystems.js b/js/galaxy/FxSystems.js new file mode 100644 index 0000000..55735a7 --- /dev/null +++ b/js/galaxy/FxSystems.js @@ -0,0 +1,44 @@ +/** + * Picking a system for the effects demo (?fx=) — 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; +} diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index ed7a1ec..75db9e0 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -264,6 +264,21 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { }; 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 = { name: record.name, type: record.type, @@ -274,6 +289,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { belt, hazard, jumps, // the system's jump gates (1–3; [] for a one-system galaxy) + atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems) }; if (isHome) { content.homeName = homeName; // the player's home world (starting system only) diff --git a/js/mining/MiningBeam.js b/js/mining/MiningBeam.js index 9b868b7..223a275 100644 --- a/js/mining/MiningBeam.js +++ b/js/mining/MiningBeam.js @@ -1,6 +1,7 @@ import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { toColor } from '../utils/Color.js'; +import { assignWorld } from '../visuals/UiCameras.js'; const SOFT_KEY = '__mining_soft'; @@ -117,6 +118,15 @@ export class MiningBeam { this.impactCore = this._soft(0xffffff, 12.5); this.emitter = this._soft(0x9fd8ff, 12.5); 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 ---------------------------------------- this.particles = []; // { active, u, t0, v, amp, phase, size, tail, tint, dot, px, py, pa } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index d5ed5d2..c66215c 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -18,6 +18,10 @@ import { Station } from '../entities/Station.js'; import { JumpGate } from '../entities/JumpGate.js'; import { jumpArrival } from '../galaxy/JumpTravel.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 { ActionBar } from '../ui/ActionBar.js'; import { MineralHud } from '../ui/MineralHud.js'; @@ -304,6 +308,7 @@ export class GameScene extends Phaser.Scene { this.events.once('shutdown', () => { this.stopGameMusic(); 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.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 // dossier below and the world's other planets both read them. this.ensureGalaxy(); + // THE EFFECTS DEMO (?fx= — 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 / // 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). @@ -401,6 +422,15 @@ export class GameScene extends Phaser.Scene { for (const j of this.systemContent.jumps ?? []) { 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 // system, at the world origin (the generator deals its name — // 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.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 // 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 @@ -609,6 +655,14 @@ export class GameScene extends Phaser.Scene { this.discovery = new Discovery(config.get('game.discovery.distance', 540)); 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 // (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 — // the ship would dip a frame-deep into every rim it meets at speed. 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 * identity (" system · star "), and its standing: faction @@ -1527,6 +1615,7 @@ export class GameScene extends Phaser.Scene { this.time.update(_time, delta); this.tweens.update(); 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.ship.update(_time, delta); // 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.updateCamera(delta); 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.draw(_time); // the barrier (on-screen dots only) 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); g2.setPosition(x0 + g1.width + 10, yy).setDepth(45).setAlpha(0); 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.time.delayedCall(durationMs, () => { 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))) .setScrollFactor(0) .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 // cached clip and attaches the