265 lines
9.6 KiB
JavaScript
265 lines
9.6 KiB
JavaScript
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);
|
||
}
|