diff --git a/data/game.json b/data/game.json index 7c41bca..ec8b284 100644 --- a/data/game.json +++ b/data/game.json @@ -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 } }, diff --git a/dev/nebula.test.mjs b/dev/nebula.test.mjs index 5950295..51b31a1 100644 --- a/dev/nebula.test.mjs +++ b/dev/nebula.test.mjs @@ -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); diff --git a/dev/phaser-stub.mjs b/dev/phaser-stub.mjs index 787529c..be7a1d5 100644 --- a/dev/phaser-stub.mjs +++ b/dev/phaser-stub.mjs @@ -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), diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index eefcbf8..373fd04 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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=` 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) diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 49a864f..1d66503 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 diff --git a/js/utils/Color.js b/js/utils/Color.js index 73f6db0..588da51 100644 --- a/js/utils/Color.js +++ b/js/utils/Color.js @@ -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; +} diff --git a/js/visuals/NebulaAtmosphere.js b/js/visuals/NebulaAtmosphere.js index 234b99c..d56f039 100644 --- a/js/visuals/NebulaAtmosphere.js +++ b/js/visuals/NebulaAtmosphere.js @@ -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);