import Phaser from '../vendor/phaser.js'; import { toColor } 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) /** * The nebula GAS — soft coloured clouds that sit BEHIND the art. * * 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). * * 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. * * Tuned by data/game.json → nebula { enabled, palette, count, alpha, * size, parallax, drift, texture }. */ 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); 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; // 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) => canvasTexture(scene, `${TEX_KEY}_${i}`, texSize, texSize, (ctx, w, h) => { drawCloud(ctx, w, h, this.cfg.texture ?? {}, CLOUD_SEEDS[i % CLOUD_SEEDS.length]); }), ); 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]); // 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); } this.lastScrollX = cam.scrollX; this.lastScrollY = cam.scrollY; } /** * 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; far (small p) gas barely moves. 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). // --------------------------------------------------------------------------- /** * Paint one soft cloud into a square canvas: value-noise fbm for the * internal structure, multiplied by a wide RADIAL falloff so the puff has * no perceptible silhouette — it is fully transparent by the inscribed * circle and dissolves gradually out, so no border or corner reads as a * hard edge. 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 octaves = Math.max(1, Math.round(tex.octaves ?? 4)); const persistence = clamp01(tex.persistence ?? 0.55); const img = ctx.createImageData(w, h); const d = img.data; const FREQ = 6; // base noise cells across the sprite (fewer, larger = smoother gas) 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); const a = radial * structure; const idx = (y * w + x) * 4; d[idx] = 255; d[idx + 1] = 255; d[idx + 2] = 255; d[idx + 3] = Math.round(a * 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); }