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