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:
parent
42bf6c17e0
commit
84909d6f80
|
|
@ -17,7 +17,7 @@
|
|||
"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).",
|
||||
"_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,
|
||||
"palette": ["#f0884a", "#ecd23e", "#4fe08a", "#3fd6d6", "#5b9bf2", "#9a7ff5", "#e06fe0", "#f25f7a"],
|
||||
"count": 6,
|
||||
|
|
@ -25,6 +25,15 @@
|
|||
"size": [420, 900],
|
||||
"parallax": [0.10, 0.22],
|
||||
"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,
|
||||
"texture": { "size": 256, "octaves": 4, "persistence": 0.55 }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,7 +13,18 @@
|
|||
* - 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).
|
||||
* 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)
|
||||
|
|
@ -36,9 +47,12 @@ for (const f of fs.readdirSync(dataDir)) {
|
|||
config.init(configData);
|
||||
|
||||
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
|
||||
);
|
||||
const { shiftHue, toColor, toCss } = await import(
|
||||
pathToFileURL(join(__dirname, '../js/utils/Color.js')).href
|
||||
);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
|
|
@ -70,6 +84,34 @@ function hueOf(hex) {
|
|||
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
|
||||
// createImageData / putImageData). drawCloud fills the buffer we hand it.
|
||||
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; }
|
||||
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 ✘`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ export default {
|
|||
Math: {
|
||||
Clamp: (v, a, b) => Math.min(b, Math.max(a, v)),
|
||||
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: {
|
||||
Wrap: (a) => a,
|
||||
RotateTo: (c, w, s) => (Math.abs(w - c) <= s ? w : c + Math.sign(w - c) * s),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 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.
|
||||
- A nebula is not only the ripple: it fills with soft coloured GAS, in
|
||||
TWO layers of large, low-alpha cloud sprites (content, world-space —
|
||||
not a filter: they parallax-scroll with the camera, wrap like the
|
||||
starfield, and work in the Canvas fallback too; no WebGL pass). Only
|
||||
NEBULA-type systems wear it.
|
||||
- **BACK** (depth 3 — above the starfield's 0–2, below the planets at 5
|
||||
and the ship at 10), tinted with the system's base atmosphere color:
|
||||
distant atmosphere, art floats in front of it, never tinted by it.
|
||||
- **FRONT** (depth 11 — ABOVE the ship), tinted with the derived accent
|
||||
color: big, soft veils with parallax > 1 that drift PAST you slightly
|
||||
faster than the world — the moment you read FLYING THROUGH the
|
||||
nebula, not looking at one. The ship stays crisp (the veils are
|
||||
low-alpha; if it ever reads as fog over the controls, lower
|
||||
`front.alpha` first). `front.enabled: false` reverts to the single
|
||||
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
|
||||
fbm for the internal structure, multiplied by a WIDE RADIAL falloff so
|
||||
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
|
||||
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),
|
||||
clock (speed scaled by `drift`).
|
||||
- **Files:** `js/visuals/NebulaAtmosphere.js` (both layers + `drawCloud`),
|
||||
`js/utils/Color.js` (`shiftHue` — the accent derivation),
|
||||
`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),
|
||||
`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),
|
||||
`dev/nebula.test.mjs` (36 checks, bare Node: palette contract, per-system
|
||||
color determinism, cloud texture, accent math, two-layer structure).
|
||||
- **Tuning is a JSON edit** (`data/game.json → nebula`): back layer via
|
||||
`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`).
|
||||
0.15–0.85 so it sits far behind); front layer via `front.*` — `enabled`
|
||||
(the revert switch), `count`, `alpha` (it veils the ship — keep it
|
||||
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
|
||||
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.
|
||||
|
||||
## Habitable storm — lightning in the upper sky (content, Canvas-safe)
|
||||
|
|
|
|||
|
|
@ -702,10 +702,12 @@ export class GameScene extends Phaser.Scene {
|
|||
// leaves the ref null.
|
||||
this._teardownEffectLayers();
|
||||
|
||||
// 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.
|
||||
// NEBULA GAS — soft coloured clouds in TWO layers, tinted with the
|
||||
// system's own atmosphere color (assigned at random at galaxy
|
||||
// generation — SystemGenerator): a BACK layer behind the art (depth 3,
|
||||
// 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 (
|
||||
this.systemRecord?.type === 'nebula' &&
|
||||
this.systemContent?.atmosphere?.color
|
||||
|
|
|
|||
|
|
@ -28,3 +28,46 @@ export function toCss(value, fallback = '#ffffff') {
|
|||
const n = toColor(value, toColor(fallback));
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,44 @@
|
|||
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 { 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 (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
|
||||
* starfield (depths 0–2) 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).
|
||||
* BACK (depth 3, base tint) — distant atmosphere BEHIND the art:
|
||||
* above the starfield (0–2), below the
|
||||
* planets (5) and the ship (10). Art
|
||||
* floats in front of it, never tinted.
|
||||
* FRONT (depth 11, accent tint) — near gas IN FRONT of everything,
|
||||
* 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
|
||||
* 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.
|
||||
* Both are CONTENT (sprites in world space), not a filter: they
|
||||
* parallax-scroll with the camera, wrap like the starfield, and work in
|
||||
* the Canvas fallback too (no WebGL filter pass).
|
||||
*
|
||||
* Tuned by data/game.json → nebula { enabled, palette, count, alpha,
|
||||
* size, parallax, drift, texture }.
|
||||
* COLOR: the BACK tint is the system's atmosphere color — assigned at
|
||||
* 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 {
|
||||
/**
|
||||
|
|
@ -37,6 +50,16 @@ export class NebulaAtmosphere {
|
|||
this.scene = scene;
|
||||
this.cfg = cfg;
|
||||
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.lastScrollX = 0;
|
||||
this.lastScrollY = 0;
|
||||
|
|
@ -50,11 +73,11 @@ export class NebulaAtmosphere {
|
|||
const width = scene.scale.width;
|
||||
const height = scene.scale.height;
|
||||
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
|
||||
// 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) =>
|
||||
|
|
@ -63,55 +86,88 @@ export class NebulaAtmosphere {
|
|||
}),
|
||||
);
|
||||
|
||||
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]);
|
||||
// The two layers (see the class doc). The flat config keys are the
|
||||
// BACK layer (backward compatible); cfg.front is the FRONT layer.
|
||||
const layers = [
|
||||
{
|
||||
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()
|
||||
// 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.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);
|
||||
for (const layer of layers) {
|
||||
for (let i = 0; i < layer.count; i++) {
|
||||
this.spawnCloud(layer, texKeys, left, top, width, height, texSize);
|
||||
}
|
||||
}
|
||||
|
||||
this.lastScrollX = cam.scrollX;
|
||||
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
|
||||
* 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) {
|
||||
// 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.y += dy * (1 - cloud.parallax);
|
||||
cloud.x = wrapIn(cloud.x, left, spanX);
|
||||
|
|
|
|||
Loading…
Reference in New Issue