orbit/js/visuals/NebulaAtmosphere.js

331 lines
13 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, 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);
}