orbit/js/visuals/NebulaAtmosphere.js

265 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (02), 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 02) 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.150.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);
}