Add front nebula layer with derived accent tint for two-tone gas

- Add `front` block to game.json: depth-11 clouds above the ship (parallax > 1) that drift past the player, creating a "flying through" feel vs. the back layer at depth 3
- Implement `shiftHue` in Color.js to derive the front accent from the system's base atmosphere color (hue rotation + lightness lift), making every nebula a stable two-tone gas
- Refactor NebulaAtmosphere into a data-driven layer array with per-layer depth/tint/parallax/drift; extract `spawnCloud` helper; scale swirl speed by configurable drift
- Add 21 new test checks covering accent math (hue delta, identity, white/black preservation, all 8 palette shades) and two-layer structure (depths, tints, parallax ranges, wrapping, destroy, revert path)
- Stub `Phaser.Math.Between`/`FloatBetween` in phaser-stub for Node tests
- Update PROJECT_NOTES.md to document the two-layer model, accent derivation, and new tuning knobs
This commit is contained in:
Brian Fertig 2026-09-10 22:48:40 -06:00
parent 42bf6c17e0
commit 84909d6f80
7 changed files with 405 additions and 91 deletions

View File

@ -17,7 +17,7 @@
"colors": ["#ffffff", "#b9c6ff", "#8090b8"] "colors": ["#ffffff", "#b9c6ff", "#8090b8"]
}, },
"nebula": { "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).", "_comment": "The nebula GAS — soft coloured clouds in TWO layers. BACK (depth 3, above the starfield's 0–2, below the planets at 5 and the ship at 10): the flat keys below, tinted with the system's own atmosphere color (SystemGenerator stamps content.atmosphere.color from this palette). FRONT (depth 11, ABOVE the ship): the 'front' block — big, soft veils that drift PAST you (parallax > 1 = closer than the art), tinted with a DERIVED accent (accent.hueShift rotates the base hue, lightScale lifts it, satScale rescales it) so every nebula is a two-tone gas. Shown only on nebula-type systems. 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 BACK layer — keep it subtle: a wash of colour, not a wall of it. front.enabled = false reverts to the single back layer. 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, "enabled": true,
"palette": ["#f0884a", "#ecd23e", "#4fe08a", "#3fd6d6", "#5b9bf2", "#9a7ff5", "#e06fe0", "#f25f7a"], "palette": ["#f0884a", "#ecd23e", "#4fe08a", "#3fd6d6", "#5b9bf2", "#9a7ff5", "#e06fe0", "#f25f7a"],
"count": 6, "count": 6,
@ -25,6 +25,15 @@
"size": [420, 900], "size": [420, 900],
"parallax": [0.10, 0.22], "parallax": [0.10, 0.22],
"drift": 2.5, "drift": 2.5,
"front": {
"enabled": true,
"count": 4,
"alpha": [0.07, 0.15],
"size": [700, 1400],
"parallax": [1.05, 1.4],
"drift": 4,
"accent": { "hueShift": 42, "satScale": 1, "lightScale": 1.12 }
},
"shapes": 3, "shapes": 3,
"texture": { "size": 256, "octaves": 4, "persistence": 0.55 } "texture": { "size": 256, "octaves": 4, "persistence": 0.55 }
}, },

View File

@ -13,7 +13,18 @@
* - the CLOUD TEXTURE (js/visuals/NebulaAtmosphere.js → drawCloud): a * - the CLOUD TEXTURE (js/visuals/NebulaAtmosphere.js → drawCloud): a
* valid alpha channel (some opaque gas, not empty), faded at the edges * valid alpha channel (some opaque gas, not empty), faded at the edges
* (center denser than the rim), white RGB (tint sets the color), and * (center denser than the rim), white RGB (tint sets the color), and
* deterministic (two renders are byte-identical). * deterministic (two renders are byte-identical);
* - the FRONT ACCENT (js/utils/Color.js → shiftHue): the near layer's
* tint is the base hue rotated by the configured hueShift, lightness
* lifted (lightScale), saturation preserved (satScale 1); identity,
* white/black preservation, and a valid distinct accent for all 8
* palette shades;
* - the TWO-LAYER STRUCTURE (NebulaAtmosphere.create/update/destroy):
* back clouds at depth 3 in the base tint with far parallax (<1),
* front clouds at depth 11 (ABOVE the ship) in the accent tint with
* near parallax (>1), sizes/alphas in the configured ranges, clouds
* wrapped into the camera window on flight, slow self-swirl, clean
* destroy, and front.enabled=false reverting to the single back layer.
*/ */
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
@ -36,9 +47,12 @@ for (const f of fs.readdirSync(dataDir)) {
config.init(configData); config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { drawCloud } = await import( const { drawCloud, NebulaAtmosphere } = await import(
pathToFileURL(join(__dirname, '../js/visuals/NebulaAtmosphere.js')).href pathToFileURL(join(__dirname, '../js/visuals/NebulaAtmosphere.js')).href
); );
const { shiftHue, toColor, toCss } = await import(
pathToFileURL(join(__dirname, '../js/utils/Color.js')).href
);
let failures = 0; let failures = 0;
const check = (label, cond) => { const check = (label, cond) => {
@ -70,6 +84,34 @@ function hueOf(hex) {
return hue < 0 ? hue + 360 : hue; return hue < 0 ? hue + 360 : hue;
} }
/** HSL of a color (number 0xRRGGBB or hex string) → { h: 0–360, s: 0–1, l: 0–1 } */
function hslOf(c) {
let n;
if (typeof c === 'number') n = c >>> 0;
else {
let h = String(c).trim().replace(/^#/, '');
if (h.length === 3) h = h.split('').map((ch) => ch + ch).join('');
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;
const l = (max + min) / 2;
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h, s, l };
}
// A headless 2-D context just enough for drawCloud (it only uses // A headless 2-D context just enough for drawCloud (it only uses
// createImageData / putImageData). drawCloud fills the buffer we hand it. // createImageData / putImageData). drawCloud fills the buffer we hand it.
function renderCloud(w, h, tex) { function renderCloud(w, h, tex) {
@ -230,6 +272,149 @@ let identical = buf.length === buf2.length;
if (identical) for (let i = 0; i < buf.length; i++) if (buf[i] !== buf2[i]) { identical = false; break; } 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); check('texture is deterministic (two renders byte-identical)', identical);
// =========================================================================
// 4. FRONT ACCENT (shiftHue — the near layer's derived tint)
// =========================================================================
console.log('\n— Front accent (Color.shiftHue) —');
const accentCfg = nebulaCfg.front?.accent ?? {};
const HUE_SHIFT = accentCfg.hueShift ?? 42;
const SAT_SCALE = accentCfg.satScale ?? 1;
const LIGHT_SCALE = accentCfg.lightScale ?? 1.12;
const baseColor = toColor('#f0884a');
const accent = shiftHue(baseColor, { hueShift: HUE_SHIFT, satScale: SAT_SCALE, lightScale: LIGHT_SCALE });
const hueDelta = (() => {
// expected: hue(accent) − hue(base) ≡ +HUE_SHIFT (mod 360)
const d = (hslOf(accent).h - hslOf(baseColor).h + 360) % 360;
const want = HUE_SHIFT % 360;
return Math.min(Math.abs(d - want), 360 - Math.abs(d - want));
})();
check(`hue rotated by the configured ${HUE_SHIFT}° (±3°)`, hueDelta <= 3);
check(`lightness lifted by lightScale ${LIGHT_SCALE}`, hslOf(accent).l > hslOf(baseColor).l);
check(`saturation preserved (satScale ${SAT_SCALE})`, Math.abs(hslOf(accent).s - hslOf(baseColor).s) <= 0.05);
// Identity: no shift ⇒ same color (float round-trip, ±1 per channel).
const identity = shiftHue(0x3f6fa0, {});
const chanDiff = (a, b, shift) => Math.abs(((a >> shift) & 255) - ((b >> shift) & 255));
check('identity round-trip keeps the color (±1/channel)',
chanDiff(0x3f6fa0, identity, 16) <= 1 && chanDiff(0x3f6fa0, identity, 8) <= 1 && chanDiff(0x3f6fa0, identity, 0) <= 1);
// Grays keep their gray: white stays white, black stays black.
check('white and black are preserved',
shiftHue(0xffffff, { hueShift: 42, lightScale: 1.12 }) === 0xffffff &&
shiftHue(0x000000, { hueShift: 42, lightScale: 1.12 }) === 0x000000);
// Every palette shade yields a VALID accent that is DISTINCT from its base
// (a 42° shift can't collide) and keeps saturation sane.
let accentsOk = true;
for (const shade of palette) {
const b2 = toColor(shade);
const a2 = shiftHue(b2, { hueShift: HUE_SHIFT, satScale: SAT_SCALE, lightScale: LIGHT_SCALE });
if (a2 < 0 || a2 > 0xffffff || a2 === b2) accentsOk = false;
const hl = hslOf(a2);
if (hl.l < 0 || hl.l > 1) accentsOk = false;
}
check('all 8 palette shades → valid, distinct accents', accentsOk);
console.log(` base ${toCss(baseColor)} → accent ${toCss(accent)} (Δhue vs the expected +${HUE_SHIFT}°: ${hueDelta.toFixed(1)}°)`);
// =========================================================================
// 5. TWO-LAYER STRUCTURE (create / update / destroy)
// =========================================================================
console.log('\n— Two-layer structure (NebulaAtmosphere) —');
// A minimal fake scene: just enough surface for create()/update()/destroy()
// (scale, cameras, add.image, textures, time). canvasTexture needs a
// document.createElement canvas — stub it (the draw is a no-op here; the
// TEXTURE math is already covered in section 3).
function makeFakeScene() {
const images = [];
const fakeCtx = {
createImageData: (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }),
putImageData: () => {},
};
globalThis.document = {
createElement: () => ({ width: 0, height: 0, getContext: () => fakeCtx }),
};
const makeImage = (x, y, key) => {
const o = { x, y, key, scale: 1, alpha: 1, rotation: 0, depth: 0, tint: 0, destroyed: false };
o.setScale = (v) => ((o.scale = v), o);
o.setAlpha = (v) => ((o.alpha = v), o);
o.setRotation = (v) => ((o.rotation = v), o);
o.setDepth = (v) => ((o.depth = v), o);
o.setTint = (v) => ((o.tint = v), o);
o.destroy = () => (o.destroyed = true);
images.push(o);
return o;
};
return {
images,
scale: { width: 1280, height: 720 },
cameras: { main: { scrollX: 1000, scrollY: 500 } },
time: { now: 0 },
textures: { exists: () => false, addCanvas: () => {} },
add: { image: makeImage },
};
}
const scene = makeFakeScene();
const neb = new NebulaAtmosphere(scene, '#f0884a', nebulaCfg);
neb.create();
const M = 120; // = MARGIN in NebulaAtmosphere.js
const back = scene.images.filter((o) => o.depth === 3);
const front = scene.images.filter((o) => o.depth === 11);
check(`back layer: cfg.count (${nebulaCfg.count}) clouds at depth 3`, back.length === nebulaCfg.count);
check(`front layer: cfg.front.count (${nebulaCfg.front?.count}) clouds at depth 11 (above the ship)`,
front.length === (nebulaCfg.front?.count ?? 0));
check('back tint = the base atmosphere color', back.every((o) => o.tint === baseColor));
const expectedAccent = shiftHue(baseColor, { hueShift: HUE_SHIFT, satScale: SAT_SCALE, lightScale: LIGHT_SCALE });
check('front tint = the derived accent (shiftHue of the base)', front.every((o) => o.tint === expectedAccent));
check('back parallax < 1 (far gas, slower than the world)', back.every((o) => o.parallax > 0 && o.parallax < 1));
check('front parallax > 1 (near gas, drifts past the world)',
front.every((o) => o.parallax > 1) && front.every((o) => o.parallax <= (nebulaCfg.front?.parallax?.[1] ?? 1.4) + 1e-9));
check('front alpha within cfg.front.alpha',
front.every((o) => o.alpha >= (nebulaCfg.front?.alpha?.[0] ?? 0.07) - 1e-9 && o.alpha <= (nebulaCfg.front?.alpha?.[1] ?? 0.15) + 1e-9));
check('front clouds are the BIG puffs (cfg.front.size range)',
front.every((o) => o.scale * 256 >= (nebulaCfg.front?.size?.[0] ?? 700) - 1 && o.scale * 256 <= (nebulaCfg.front?.size?.[1] ?? 1400) + 1));
check('all clouds start inside the camera window (+margin)',
scene.images.every((o) =>
o.x >= 1000 - M - 1e-6 && o.x < 1000 + 1280 + M + 1e-6 &&
o.y >= 500 - M - 1e-6 && o.y < 500 + 720 + M + 1e-6));
// drift wiring: rotSpeed = drift × [0.0012, 0.0036] rad/s (drift 2.5 ⇒ the
// original 0.003–0.009; the front layer runs its own drift).
const inRot = (o, drift) => Math.abs(o.rotSpeed) >= drift * 0.0012 - 1e-12 && Math.abs(o.rotSpeed) <= drift * 0.0036 + 1e-12;
check('back swirl speed scaled by cfg.drift', back.every((o) => inRot(o, nebulaCfg.drift ?? 2.5)));
check('front swirl speed scaled by cfg.front.drift', front.every((o) => inRot(o, nebulaCfg.front?.drift ?? nebulaCfg.drift ?? 2.5)));
// Self-swirl over the scene clock (frame-safe, ms-based).
scene.time.now = 500;
neb.update();
check('clouds self-swirl over the scene clock', scene.images.some((o) => Math.abs(o.rotation - o.baseRotation) > 1e-6));
// Camera flight: the gas stays wrapped into the live view window.
scene.cameras.main.scrollX += 3000;
neb.update();
const newLeft = 1000 + 3000 - M;
const spanX = 1280 + 2 * M;
check('camera flight keeps the gas wrapped in view',
scene.images.every((o) => o.x >= newLeft - 1e-6 && o.x < newLeft + spanX + 1e-6));
// Clean teardown (scene jump / system change).
neb.destroy();
check('destroy() removes every cloud', scene.images.every((o) => o.destroyed) && neb.clouds.length === 0);
// The REVERT path: front.enabled = false → the single back layer, as before.
const scene2 = makeFakeScene();
const cfgNoFront = { ...nebulaCfg, front: { ...(nebulaCfg.front ?? {}), enabled: false } };
const neb2 = new NebulaAtmosphere(scene2, '#5b9bf2', cfgNoFront);
neb2.create();
check('front.enabled=false reverts to the single back layer',
scene2.images.length === nebulaCfg.count && scene2.images.every((o) => o.depth === 3));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
console.log(failures === 0 ? '\nAll nebula tests passed ✔' : `\n${failures} test(s) FAILED ✘`); console.log(failures === 0 ? '\nAll nebula tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1); process.exit(failures === 0 ? 0 : 1);

View File

@ -83,6 +83,8 @@ export default {
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,
Between: (a, b) => a + Math.floor(Math.random() * (b - a + 1)),
FloatBetween: (a, b) => a + Math.random() * (b - a),
Angle: { Angle: {
Wrap: (a) => a, Wrap: (a) => a,
RotateTo: (c, w, s) => (Math.abs(w - c) <= s ? w : c + Math.sign(w - c) * s), RotateTo: (c, w, s) => (Math.abs(w - c) <= s ? w : c + Math.sign(w - c) * s),

View File

@ -1308,23 +1308,33 @@ The player holds a REPUTATION (standing) on each planet and space station:
system never splits the cameras (the single-camera pipeline renders system never splits the cameras (the single-camera pipeline renders
exactly as before). exactly as before).
## Nebula atmosphere — the gas behind the art ## Nebula atmosphere — the gas you fly THROUGH
- A nebula is not only the ripple: it fills with soft coloured GAS. - A nebula is not only the ripple: it fills with soft coloured GAS, in
A handful of large, low-alpha cloud sprites sit at **depth 3** — above TWO layers of large, low-alpha cloud sprites (content, world-space —
the starfield (depths 0–2) and below the planets (5) and the ship (10) — not a filter: they parallax-scroll with the camera, wrap like the
so the gas reads as distant atmosphere and your art floats in FRONT of starfield, and work in the Canvas fallback too; no WebGL pass). Only
it, never tinted by it. It is content (world-space sprites), not a NEBULA-type systems wear it.
filter: it parallax-scrolls with the camera, wraps like the starfield, - **BACK** (depth 3 — above the starfield's 0–2, below the planets at 5
and works in the Canvas fallback too (no WebGL pass). Only NEBULA-type and the ship at 10), tinted with the system's base atmosphere color:
systems wear it. distant atmosphere, art floats in front of it, never tinted by it.
- **Per-system colour:** every system gets a stable atmosphere color — - **FRONT** (depth 11 — ABOVE the ship), tinted with the derived accent
one of the 8-shade palette, picked at random at galaxy generation color: big, soft veils with parallax > 1 that drift PAST you slightly
(SystemGenerator, dedicated fork `(seed,'system',id,'atmosphere')`, so faster than the world — the moment you read FLYING THROUGH the
lazy === eager). Stored as `content.atmosphere.color`; the layer tints nebula, not looking at one. The ship stays crisp (the veils are
to it. The 8 shades are well-spread around the hue wheel (orange, low-alpha; if it ever reads as fog over the controls, lower
gold, green, cyan, blue, violet, magenta, crimson) — each nebula is a `front.alpha` first). `front.enabled: false` reverts to the single
different-feeling place. back layer (the pre-2-layer look).
- **Per-system colour (two-tone):** every system gets a stable base
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 BACK layer tints to it. The FRONT tint
is DERIVED from it — `shiftHue` (js/utils/Color.js) rotates the hue by
`front.accent.hueShift` (42°) and lifts lightness (`lightScale`, 1.12)
— two gas species of one nebula, always stable per system (the 8 base
shades are well-spread around the hue wheel, so the 8 two-tone pairs
are all different-feeling places).
- **The cloud:** a small set of distinct procedural puffs (value-noise - **The cloud:** a small set of distinct procedural puffs (value-noise
fbm for the internal structure, multiplied by a WIDE RADIAL falloff so fbm for the internal structure, multiplied by a WIDE RADIAL falloff so
each has no perceptible silhouette — fully transparent by the inscribed each has no perceptible silhouette — fully transparent by the inscribed
@ -1332,21 +1342,27 @@ The player holds a REPUTATION (standing) on each planet and space station:
`canvasTexture` helper; every nebula reuses the set and differs only in `canvasTexture` helper; every nebula reuses the set and differs only in
tint. NORMAL blending (the default) so overlaps layer softly instead of tint. NORMAL blending (the default) so overlaps layer softly instead of
ADD-ing into bright seams, and each puff swirls slowly on the scene ADD-ing into bright seams, and each puff swirls slowly on the scene
clock. clock (speed scaled by `drift`).
- **Files:** `js/visuals/NebulaAtmosphere.js` (layer + `drawCloud`), - **Files:** `js/visuals/NebulaAtmosphere.js` (both layers + `drawCloud`),
`data/game.json → nebula` (palette + `count/alpha/size/parallax/drift/ `js/utils/Color.js` (`shiftHue` — the accent derivation),
texture` tunables), `js/galaxy/SystemGenerator.js` (per-system color), `data/game.json → nebula` (palette + back-layer `count/alpha/size/
parallax/drift` + `front` block + `texture` tunables),
`js/galaxy/SystemGenerator.js` (per-system base color),
`js/scenes/GameScene.js` (create/update/destroy wiring), `js/scenes/GameScene.js` (create/update/destroy wiring),
`dev/nebula.test.mjs` (15 checks, bare Node: palette contract, per-system `dev/nebula.test.mjs` (36 checks, bare Node: palette contract, per-system
color determinism, cloud texture). color determinism, cloud texture, accent math, two-layer structure).
- **Tuning is a JSON edit** (`data/game.json → nebula`): `alpha` - **Tuning is a JSON edit** (`data/game.json → nebula`): back layer via
(opacity — keep it a wash, not a wall), `count` (cloud sprites), `alpha` (opacity — keep it a wash, not a wall), `count` (cloud sprites),
`size` (cloud px), `parallax` (keep it slower than the starfield's `size` (cloud px), `parallax` (keep it slower than the starfield's
0.15–0.85 so it sits far behind), `drift`, `shapes` (distinct puff 0.15–0.85 so it sits far behind); front layer via `front.*` — `enabled`
varieties — more = less repetition), and `texture` (noise (the revert switch), `count`, `alpha` (it veils the ship — keep it
`octaves/persistence`). lower than the back's), `size` (bigger = closer), `parallax` (> 1 =
closer than the art; 1.05–1.4 is the sweet spot), `drift` (swirl
speed), and `accent` (`hueShift` degrees, `satScale`, `lightScale`).
Shared: `shapes` (distinct puff varieties — more = less repetition),
`texture` (noise `octaves/persistence`), `drift` (back-layer swirl).
- **See it:** `dev/test-game.html?fx=nebula&seed=<seed>` drops you in a - **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 nebula (a different seed → a different two-tone gas) — fly around and
judge the look. judge the look.
## Habitable storm — lightning in the upper sky (content, Canvas-safe) ## Habitable storm — lightning in the upper sky (content, Canvas-safe)

View File

@ -702,10 +702,12 @@ export class GameScene extends Phaser.Scene {
// leaves the ref null. // leaves the ref null.
this._teardownEffectLayers(); this._teardownEffectLayers();
// NEBULA GAS — a soft coloured cloud layer BEHIND the art (depth 3), // NEBULA GAS — soft coloured clouds in TWO layers, tinted with the
// tinted with the system's own atmosphere color (assigned at random at // system's own atmosphere color (assigned at random at galaxy
// galaxy generation — SystemGenerator). Nebula systems only. See // generation — SystemGenerator): a BACK layer behind the art (depth 3,
// js/visuals/NebulaAtmosphere.js. // base color) and a FRONT layer above the ship (depth 11, a derived
// accent hue) that drifts past you — flying THROUGH the nebula.
// Nebula systems only. See js/visuals/NebulaAtmosphere.js.
if ( if (
this.systemRecord?.type === 'nebula' && this.systemRecord?.type === 'nebula' &&
this.systemContent?.atmosphere?.color this.systemContent?.atmosphere?.color

View File

@ -28,3 +28,46 @@ export function toCss(value, fallback = '#ffffff') {
const n = toColor(value, toColor(fallback)); const n = toColor(value, toColor(fallback));
return '#' + (n >>> 0).toString(16).padStart(6, '0'); return '#' + (n >>> 0).toString(16).padStart(6, '0');
} }
/**
* Rotate a color's HUE (degrees; + is clockwise on the hue wheel) and
* rescale its saturation and lightness (1 = unchanged). Returns a 0xRRGGBB
* number (ready for setTint()).
*
* Used by the nebula FRONT gas layer: the accent tint is the system's base
* color rotated a little and lifted — two gas species of one nebula
* (see data/game.json → nebula.front.accent). Pure math (no Phaser), so it
* is testable in bare Node; grays stay grays (s=0 ⇒ hue is irrelevant),
* and black/white are preserved.
*/
export function shiftHue(color, { hueShift = 0, satScale = 1, lightScale = 1 } = {}) {
const n = toColor(color, 0xffffff) >>> 0;
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;
const l = (max + min) / 2;
let h = 0;
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
h = (h + hueShift) % 360;
const s2 = Math.min(1, Math.max(0, s * satScale));
const l2 = Math.min(1, Math.max(0, l * lightScale));
const c = (1 - Math.abs(2 * l2 - 1)) * s2;
const hp = h / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
const [r2, g2, b2] =
hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x] :
hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x];
const m = l2 - c / 2;
const to255 = (v) => Math.min(255, Math.max(0, Math.round((v + m) * 255)));
return ((to255(r2) << 16) | (to255(g2) << 8) | to255(b2)) >>> 0;
}

View File

@ -1,31 +1,44 @@
import Phaser from '../vendor/phaser.js'; import Phaser from '../vendor/phaser.js';
import { toColor } from '../utils/Color.js'; import { toColor, shiftHue } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js'; import { canvasTexture } from '../utils/Textures.js';
import { assignWorld } from './UiCameras.js'; import { assignWorld } from './UiCameras.js';
const TEX_KEY = '__nebula_cloud'; const TEX_KEY = '__nebula_cloud';
const MARGIN = 120; // px of cloud buffer beyond the camera view, each side const MARGIN = 120; // px of cloud buffer beyond the camera view, each side
const DEPTH = 3; // above the starfield (0–2), below the planets (5) and ship (10) const DEPTH_BACK = 3; // above the starfield (0–2), below the planets (5) and ship (10)
const DEPTH_FRONT = 11; // ABOVE the ship (10): near gas that passes in front of everything
/** /**
* The nebula GAS — soft coloured clouds that sit BEHIND the art. * The nebula GAS — soft coloured clouds in TWO layers:
* *
* A handful of large, low-alpha cloud sprites at depth 3: above the * BACK (depth 3, base tint) — distant atmosphere BEHIND the art:
* starfield (depths 0–2) and below the planets (5) and the ship (10). So * above the starfield (0–2), below the
* the gas reads as distant atmosphere — your art floats in front of it, * planets (5) and the ship (10). Art
* never tinted by it. It is CONTENT (sprites in world space), not a * floats in front of it, never tinted.
* filter: it parallax-scrolls with the camera, wraps like the starfield, * FRONT (depth 11, accent tint) — near gas IN FRONT of everything,
* and works in the Canvas fallback too (no WebGL filter pass). * above the ship (10): big, soft veils
* that drift PAST you (parallax > 1) —
* the moment you read FLYING THROUGH
* the nebula, not looking at one.
* *
* Each nebula system gets its own color — assigned at random at galaxy * Both are CONTENT (sprites in world space), not a filter: they
* generation (SystemGenerator stamps content.atmosphere.color from the * parallax-scroll with the camera, wrap like the starfield, and work in
* palette in data/game.json → nebula). The cloud SHAPE is shared (one * the Canvas fallback too (no WebGL filter pass).
* 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, * COLOR: the BACK tint is the system's atmosphere color — assigned at
* size, parallax, drift, texture }. * random at galaxy generation (SystemGenerator stamps
* content.atmosphere.color from the palette in data/game.json → nebula).
* The FRONT tint is DERIVED from it (shiftHue: a hue rotation + lightness
* lift, see cfg.front.accent), so every nebula is a two-tone gas —
* base + accent — always stable per system.
*
* The cloud SHAPE is shared (a few procedural textures, generated once);
* only the tint/depth/parallax differ between layers.
*
* Tuned by data/game.json → nebula: flat keys { count, alpha, size,
* parallax, drift } = the BACK layer; { front: { enabled, count, alpha,
* size, parallax (>1), drift, accent: { hueShift, satScale, lightScale } } }
* = the FRONT layer; { enabled, palette, shapes, texture } shared.
*/ */
export class NebulaAtmosphere { export class NebulaAtmosphere {
/** /**
@ -37,6 +50,16 @@ export class NebulaAtmosphere {
this.scene = scene; this.scene = scene;
this.cfg = cfg; this.cfg = cfg;
this.color = toColor(color, 0xffffff); this.color = toColor(color, 0xffffff);
// The FRONT layer (see the class doc): near gas above the ship.
this.front = cfg.front ?? {};
const accent = this.front.accent ?? {};
// Accent tint = the base color rotated a little and lifted — two gas
// species of one nebula (deterministic per system, pure math).
this.frontTint = shiftHue(this.color, {
hueShift: accent.hueShift ?? 42,
satScale: accent.satScale ?? 1,
lightScale: accent.lightScale ?? 1.12,
});
this.clouds = []; this.clouds = [];
this.lastScrollX = 0; this.lastScrollX = 0;
this.lastScrollY = 0; this.lastScrollY = 0;
@ -50,11 +73,11 @@ export class NebulaAtmosphere {
const width = scene.scale.width; const width = scene.scale.width;
const height = scene.scale.height; const height = scene.scale.height;
const cam = scene.cameras.main; const cam = scene.cameras.main;
const texSize = Math.max(32, Math.round(this.cfg.texture?.size ?? 256));
// A few DISTINCT soft cloud shapes (different noise seeds) so the gas // 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 // 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. // 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 shapes = Math.max(1, Math.round(this.cfg.shapes ?? 3));
const CLOUD_SEEDS = [1337, 4242, 9001, 617, 24601]; const CLOUD_SEEDS = [1337, 4242, 9001, 617, 24601];
const texKeys = Array.from({ length: shapes }, (_, i) => const texKeys = Array.from({ length: shapes }, (_, i) =>
@ -63,55 +86,88 @@ export class NebulaAtmosphere {
}), }),
); );
const count = Math.max(0, Math.round(this.cfg.count ?? 6)); // The two layers (see the class doc). The flat config keys are the
const [aMin, aMax] = normRange(this.cfg.alpha, [0.09, 0.18]); // BACK layer (backward compatible); cfg.front is the FRONT layer.
const [sMin, sMax] = normRange(this.cfg.size, [420, 900]); const layers = [
const [pMin, pMax] = normRange(this.cfg.parallax, [0.10, 0.22]); {
depth: DEPTH_BACK,
tint: this.color,
count: Math.max(0, Math.round(this.cfg.count ?? 6)),
alpha: normRange(this.cfg.alpha, [0.09, 0.18]),
size: normRange(this.cfg.size, [420, 900]),
parallax: normRange(this.cfg.parallax, [0.10, 0.22]),
drift: this.cfg.drift ?? 2.5,
},
];
if (this.front.enabled !== false) {
layers.push({
depth: DEPTH_FRONT,
tint: this.frontTint,
count: Math.max(0, Math.round(this.front.count ?? 4)),
alpha: normRange(this.front.alpha, [0.07, 0.15]),
size: normRange(this.front.size, [700, 1400]),
// > 1: the gas is CLOSER than the art — on screen it drifts past
// you faster than the world (update() moves it by (1 − p)).
parallax: normRange(this.front.parallax, [1.05, 1.4]),
drift: this.front.drift ?? this.cfg.drift ?? 2.5,
});
}
// Clouds start in a window around the current camera view; update() // Clouds start in a window around the current camera view; update()
// keeps them wrapped into it as the camera flies (the starfield model). // keeps them wrapped into it as the camera flies (the starfield model).
const left = cam.scrollX - MARGIN; const left = cam.scrollX - MARGIN;
const top = cam.scrollY - MARGIN; const top = cam.scrollY - MARGIN;
for (let i = 0; i < count; i++) { for (const layer of layers) {
const key = texKeys[Phaser.Math.Between(0, texKeys.length - 1)]; for (let i = 0; i < layer.count; i++) {
const cloud = scene.add.image( this.spawnCloud(layer, texKeys, left, top, width, height, texSize);
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.15–0.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.lastScrollX = cam.scrollX;
this.lastScrollY = cam.scrollY; this.lastScrollY = cam.scrollY;
} }
/** One cloud sprite: a random puff in the view window, layer-styled. */
spawnCloud(layer, texKeys, left, top, width, height, texSize) {
const scene = this.scene;
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: the BACK layer stays FAR (slower than the starfield's
// 0.15–0.85) so it reads as distant gas; the FRONT layer runs > 1 so
// it reads as NEAR gas — closer than the planets and the ship.
cloud.parallax = Phaser.Math.FloatBetween(layer.parallax[0], layer.parallax[1]);
const s = Phaser.Math.FloatBetween(layer.size[0], layer.size[1]) / texSize;
cloud
.setScale(s)
.setAlpha(Phaser.Math.FloatBetween(layer.alpha[0], layer.alpha[1]))
.setTint(layer.tint)
.setRotation(Phaser.Math.FloatBetween(0, Math.PI * 2))
.setDepth(layer.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).
// drift (game.json) scales it; 2.5 ≈ the original 0.003–0.009 rad/s.
const drift = typeof layer.drift === 'number' && layer.drift > 0 ? layer.drift : 2.5;
cloud.baseRotation = cloud.rotation;
cloud.rotSpeed = (Phaser.Math.Between(0, 1) ? 1 : -1) * drift * Phaser.Math.FloatBetween(0.0012, 0.0036);
// 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);
}
/** /**
* Shift the gas opposite to the camera motion, wrap it back into the * 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, * current view, and let each cloud swirl slowly. Call once per frame,
@ -143,7 +199,8 @@ export class NebulaAtmosphere {
for (const cloud of this.clouds) { for (const cloud of this.clouds) {
// Move by (1 − p) of the camera delta → on screen it drifts by −p, // Move by (1 − p) of the camera delta → on screen it drifts by −p,
// opposite to travel; far (small p) gas barely moves. // opposite to travel: the back layer (p < 1) barely moves (far gas);
// the front layer (p > 1) drifts PAST the world (near gas).
cloud.x += dx * (1 - cloud.parallax); cloud.x += dx * (1 - cloud.parallax);
cloud.y += dy * (1 - cloud.parallax); cloud.y += dy * (1 - cloud.parallax);
cloud.x = wrapIn(cloud.x, left, spanX); cloud.x = wrapIn(cloud.x, left, spanX);