import Phaser from '../vendor/phaser.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_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 in TWO layers: * * 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. * * 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). * * 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 { /** * @param {object} scene Phaser scene (the GameScene) * @param {string|number} color the system's atmosphere color (hex or int) * @param {object} cfg data/game.json → nebula */ constructor(scene, color, cfg = {}) { this.scene = scene; this.cfg = cfg; this.color = toColor(color, 0xffffff); // 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; this.enabled = cfg.enabled !== false; } create() { if (!this.enabled) return; const scene = this.scene; const width = scene.scale.width; const height = scene.scale.height; const cam = scene.cameras.main; 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 shapes = Math.max(1, Math.round(this.cfg.shapes ?? 3)); const CLOUD_SEEDS = [1337, 4242, 9001, 617, 24601]; const texKeys = Array.from({ length: shapes }, (_, i) => canvasTexture(scene, `${TEX_KEY}_${i}`, texSize, texSize, (ctx, w, h) => { drawCloud(ctx, w, h, this.cfg.texture ?? {}, CLOUD_SEEDS[i % CLOUD_SEEDS.length]); }), ); // 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 (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, * after the camera has moved (alongside Starfield.update()). */ update() { if (!this.enabled || this.clouds.length === 0) return; const scene = this.scene; const cam = scene.cameras.main; // Slow self-swirl (frame-rate independent — the scene clock is ms). const t = (scene.time?.now ?? 0) * 0.001; for (const cloud of this.clouds) { cloud.rotation = cloud.baseRotation + t * cloud.rotSpeed; } // Camera parallax + wrap (the starfield model). const dx = cam.scrollX - this.lastScrollX; const dy = cam.scrollY - this.lastScrollY; this.lastScrollX = cam.scrollX; this.lastScrollY = cam.scrollY; if (dx === 0 && dy === 0) return; const spanX = scene.scale.width + 2 * MARGIN; const spanY = scene.scale.height + 2 * MARGIN; const left = cam.scrollX - MARGIN; const top = cam.scrollY - MARGIN; for (const cloud of this.clouds) { // Move by (1 − p) of the camera delta → on screen it drifts by −p, // opposite to travel: 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); cloud.y = wrapIn(cloud.y, top, spanY); } } destroy() { for (const cloud of this.clouds) cloud.destroy(); this.clouds = []; } } // --------------------------------------------------------------------------- // Texture generation (runs in the browser; also importable from Node for // tests — no Phaser here, just a 2-D canvas context). // --------------------------------------------------------------------------- /** * Per-pixel cloud ALPHA (0..1) for a w×h canvas: value-noise fbm for the * internal structure, multiplied by a wide radial falloff so the puff has * no perceptible silhouette — fully transparent by the inscribed circle, * dissolving gently out. (The single source of the cloud shape; both * drawCloud and the storm's flash use it.) */ export function cloudAlpha(w, h, tex, seed = 1337) { const octaves = Math.max(1, Math.round(tex.octaves ?? 4)); const persistence = clamp01(tex.persistence ?? 0.55); const FREQ = 6; // base noise cells across the sprite (fewer, larger = smoother gas) const out = new Float32Array(w * h); for (let y = 0; y < h; y++) { const v = y / h; for (let x = 0; x < w; x++) { const u = x / w; let n = 0; let amp = 1; let freq = FREQ; let norm = 0; for (let o = 0; o < octaves; o++) { n += amp * valueNoise(u * freq, v * freq, seed + o * 101); norm += amp; amp *= persistence; freq *= 2; } n /= norm; // Wide radial falloff: 1 at the centre, smoothly to 0 by the // inscribed circle (r = 1). (1 - r^2)^2 is flat-ish across the core // and melts out gently, so the puff has no visible rim. const r = Math.hypot(u - 0.5, v - 0.5) * 2; // 0 centre … 1 inscribed edge let radial = Math.max(0, 1 - r * r); radial *= radial; // Gentle internal structure (fbm) — wispy density, not speckled dust. const structure = smoothstep(0.30, 0.80, n); out[y * w + x] = radial * structure; } } return out; } /** * Paint one soft cloud into a square canvas (see cloudAlpha). White RGB; * the alpha channel carries the density (the sprite's tint sets the color). */ export function drawCloud(ctx, w, h, tex, seed = 1337) { const a = cloudAlpha(w, h, tex, seed); const img = ctx.createImageData(w, h); const d = img.data; for (let i = 0; i < w * h; i++) { const idx = i * 4; d[idx] = 255; d[idx + 1] = 255; d[idx + 2] = 255; d[idx + 3] = Math.round(a[i] * 255); } ctx.putImageData(img, 0, 0); } // --- Deterministic value noise (no Math.random; shared by every system) --- function hash2(ix, iy, seed = 1337) { let h = (ix * 374761393 + iy * 668265263 + seed * 1442695) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); h ^= h >>> 16; return (h >>> 0) / 4294967296; } function fade(t) { return t * t * (3 - 2 * t); } function valueNoise(x, y, seed = 1337) { const ix = Math.floor(x); const iy = Math.floor(y); const fx = fade(x - ix); const fy = fade(y - iy); const h00 = hash2(ix, iy, seed); const h10 = hash2(ix + 1, iy, seed); const h01 = hash2(ix, iy + 1, seed); const h11 = hash2(ix + 1, iy + 1, seed); const top = h00 + (h10 - h00) * fx; const bot = h01 + (h11 - h01) * fx; return top + (bot - top) * fy; } function smoothstep(e0, e1, x) { const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))); return t * t * (3 - 2 * t); } function clamp01(v) { return Math.min(1, Math.max(0, typeof v === 'number' ? v : 0.5)); } function normRange(range, fallback) { if (Array.isArray(range) && range.length === 2 && range.every((n) => typeof n === 'number')) { return [Math.min(range[0], range[1]), Math.max(range[0], range[1])]; } return fallback; } /** Maps any coordinate into [left, left + span) — safe for huge deltas too. */ function wrapIn(v, left, span) { return left + ((((v - left) % span) + span) % span); }