285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
/**
|
||
* StormLayer — the habitable system's electrified storm.
|
||
*
|
||
* WHY IT EXISTS: "Temperate, well-lit, and quietly crowded with life." —
|
||
* and life started in lightning (Miller–Urey). So a habitable system
|
||
* carries a slow PURPLE STORM: compact cloud clusters that gather charge,
|
||
* strike in a rapid flicker, and settle into a soft afterglow — the
|
||
* weather that seeded the worlds below.
|
||
*
|
||
* THE 3-D TRICK (three sprites per cluster, in depth order):
|
||
* 1. BACK (depth 3.0) — the cloud body (fbm texture, purple tint), dim;
|
||
* brightens as the charge builds and on the strike.
|
||
* 2. FLASH (depth 3.1) — the SAME kind of cloud shape (a related fbm
|
||
* texture), white-lavender, ADDITIVE, alpha 0 at
|
||
* rest. Because the flash is CLOUD-SHAPED (not a
|
||
* radial glow), it reads as the cloud igniting
|
||
* from within; the additive bleed past the edges
|
||
* is the light wrapping the silhouette.
|
||
* 3. FRONT (depth 3.2) — a DIFFERENT, larger, darker cloud (indigo,
|
||
* normal blend). Its fbm holes let light leak
|
||
* through in a fractal pattern while the solid
|
||
* parts stay dark — DARK OVER BRIGHT is the
|
||
* silhouette that sells the depth: "dark clouds
|
||
* overlaying the brightness".
|
||
*
|
||
* Plain sprites composited in depth order — NO shaders, Canvas-safe, and
|
||
* it can never touch the HUD (world-space content, like the starfield).
|
||
* The storm is a SKY: anchored to the camera view (the wanderer model)
|
||
* with near-zero parallax — it stays overhead while the world flies past.
|
||
* *
|
||
* Timing is PURE and Node-tested (SystemEffectsMath.stormEnvelope):
|
||
* charge (a slow rise) → strike (a strobe of bursts) → afterglow (decay).
|
||
* Each cluster gets its own seeded interval + phase, so the strikes are
|
||
* staggered — the sky flickers from different places, like weather.
|
||
*
|
||
* Config: data/systems.json → types.habitable.effect.storm.
|
||
*/
|
||
import Phaser from '../vendor/phaser.js';
|
||
import { toColor } from '../utils/Color.js';
|
||
import { canvasTexture } from '../utils/Textures.js';
|
||
import { drawCloud, cloudAlpha } from './NebulaAtmosphere.js';
|
||
import { stormEnvelope } from './SystemEffectsMath.js';
|
||
import { assignWorld } from './UiCameras.js';
|
||
|
||
const MARGIN_X = 0.15; // fraction of view width a cluster may hang over an edge (wide clouds)
|
||
const DEPTH_BACK = 3.0;
|
||
const DEPTH_FLASH = 3.1;
|
||
const DEPTH_FRONT = 3.2;
|
||
const SKY = { x: 0.10, y: 0.08, w: 0.80, h: 0.34 }; // home band: upper view (fractions)
|
||
const BAND_Y = { top: 0.03, bottom: 0.58 }; // wrap band: strictly inside the view (fractions)
|
||
|
||
/** A seeded 32-bit PRNG (mulberry32) — stable cloud layout per cluster. */
|
||
function makeRng(seedNum) {
|
||
let a = (seedNum >>> 0) || 0x9e3779b9;
|
||
return () => {
|
||
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (a >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
/** Maps any coordinate into [left, left + span) — safe for huge deltas. */
|
||
function wrapIn(v, left, span) {
|
||
return left + ((((v - left) % span) + span) % span);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/** Paints the LIGHTNING: the cloud shape (shared fbm) plus a HOT RADIAL
|
||
* CORE at the centre — so a strike reads as the cloud igniting from
|
||
* within, bright at the heart and melting out through the cloud body.
|
||
* White RGB; the alpha channel carries the intensity. */
|
||
function drawFlash(ctx, w, h, tex, seed) {
|
||
const base = cloudAlpha(w, h, tex, seed);
|
||
const img = ctx.createImageData(w, h);
|
||
const d = img.data;
|
||
for (let y = 0; y < h; y++) {
|
||
const v = y / h - 0.5;
|
||
for (let x = 0; x < w; x++) {
|
||
const i = y * w + x;
|
||
const u = x / w - 0.5;
|
||
const r = Math.hypot(u, v) * 2; // 0 centre … 1 inscribed edge
|
||
const core = r < 0.72 ? Math.pow(1 - r / 0.72, 1.5) : 0;
|
||
const a = Math.min(1, Math.max(base[i], core));
|
||
const idx = i * 4;
|
||
d[idx] = 255;
|
||
d[idx + 1] = 255;
|
||
d[idx + 2] = 255;
|
||
d[idx + 3] = Math.round(a * 255);
|
||
}
|
||
}
|
||
ctx.putImageData(img, 0, 0);
|
||
}
|
||
|
||
/**
|
||
* The storm layer (2–3 cloud clusters, each a back / flash / front trio).
|
||
*
|
||
* @param {object} scene Phaser scene (add / camera / scale / textures)
|
||
* @param {object} cfg data/systems.json → effect.storm
|
||
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
|
||
*/
|
||
export class StormLayer {
|
||
constructor(scene, cfg, fx = {}) {
|
||
this.scene = scene;
|
||
this.cfg = cfg ?? {};
|
||
this.fx = fx ?? {};
|
||
this.clusters = [];
|
||
this.lastScrollX = 0;
|
||
this.lastScrollY = 0;
|
||
}
|
||
|
||
/** Build the cloud textures + one back/flash/front trio per cluster (idempotent). */
|
||
create() {
|
||
if (this.clusters.length) return;
|
||
const scene = this.scene;
|
||
const cam = scene.cameras.main;
|
||
const w = scene.scale.width;
|
||
const h = scene.scale.height;
|
||
|
||
const clusters = Math.max(1, Math.round(this.cfg.clusters ?? 3));
|
||
const [sMin, sMax] = normRange(this.cfg.size, [420, 640]);
|
||
const [pMin, pMax] = normRange(this.cfg.parallax, [0.03, 0.06]); // a SKY dome: barely drifts
|
||
const [baMin, baMax] = normRange(this.cfg.alpha?.back, [0.30, 0.42]);
|
||
const [faMin, faMax] = normRange(this.cfg.alpha?.front, [0.38, 0.50]);
|
||
const [iMin, iMax] = normRange(this.cfg.cycle?.interval, [10, 16]);
|
||
const strikeDur = Math.max(0.15, Number(this.cfg.cycle?.flicker) || 0.55);
|
||
const chargeWindow = Number(this.cfg.cycle?.charge) || 0.45;
|
||
const strength = Math.max(0, Number(this.cfg.flashStrength) || 0.9);
|
||
|
||
const backColor = toColor(this.cfg.back ?? '#6b5fa8', 0x6b5fa8);
|
||
const frontColor = toColor(this.cfg.front ?? '#191430', 0x191430);
|
||
const flashColor = toColor(this.cfg.flash ?? '#cfd6ff', 0xcfd6ff);
|
||
|
||
const TEX = 256;
|
||
// A few DISTINCT cloud shapes per role (different noise seeds) so the
|
||
// trio reads as a volume, not one image repeated. Cached by key; every
|
||
// habitable system reuses the set (only tint/layout/phase vary).
|
||
const tex = {
|
||
back: (i) => canvasTexture(scene, `fx-storm-back-${i}`, TEX, TEX, (ctx, tw, th) =>
|
||
drawCloud(ctx, tw, th, { octaves: 4, persistence: 0.55 }, 717 + i * 101)),
|
||
flash: (i) => canvasTexture(scene, `fx-storm-flash-${i}`, TEX, TEX, (ctx, tw, th) =>
|
||
drawFlash(ctx, tw, th, { octaves: 5, persistence: 0.6 }, 1009 + i * 101)),
|
||
front: (i) => canvasTexture(scene, `fx-storm-front-${i}`, TEX, TEX, (ctx, tw, th) =>
|
||
drawCloud(ctx, tw, th, { octaves: 4, persistence: 0.62 }, 1297 + i * 101)),
|
||
};
|
||
|
||
// Seeded per-cluster layout + rhythm (xorshift32 from the system fx,
|
||
// so the same system looks the same every run).
|
||
let s = (Math.imul(0x51ab1234, 2654435761) ^ Math.round((this.fx.phase ?? 0.5) * 1e9)) >>> 0;
|
||
const rand = () => {
|
||
s ^= s << 13; s >>>= 0;
|
||
s ^= s >> 17; s >>>= 0;
|
||
s ^= s << 5; s >>>= 0;
|
||
return (s >>> 0) / 4294967296;
|
||
};
|
||
const range = (a, b) => a + (b - a) * rand();
|
||
|
||
// Clusters live in the SKY band — the upper part of the view. Their
|
||
// home is stored in VIEW space (the wanderer model): each frame we add
|
||
// a camera-parallax drift and wrap back into the band, so the storm is
|
||
// always overhead no matter how far the ship flies. (Placing them in
|
||
// world space with a wide margin let them drift out of view — the old
|
||
// bug.)
|
||
for (let i = 0; i < clusters; i++) {
|
||
const S = range(sMin, sMax);
|
||
const homeX = (SKY.x + SKY.w * rand()) * w; // across the sky
|
||
const homeY = (SKY.y + SKY.h * rand()) * h; // upper half only
|
||
const p = range(pMin, pMax);
|
||
const interval = range(iMin, iMax);
|
||
const phase = rand() * interval; // staggered strikes (seeded)
|
||
|
||
const bx = (cam.scrollX ?? 0) + homeX;
|
||
const by = (cam.scrollY ?? 0) + homeY;
|
||
|
||
const back = scene.add.image(bx, by, tex.back(i))
|
||
.setDepth(DEPTH_BACK).setTint(backColor)
|
||
.setScale(S / TEX).setAlpha(baMin)
|
||
.setRotation(rand() * Math.PI * 2);
|
||
// The flash sits inside the cloud body, near-centre (the hot core is
|
||
// the strike — it must not be buried under the front cloud).
|
||
const flashOffX = (rand() - 0.5) * 0.08 * S;
|
||
const flashOffY = (rand() - 0.5) * 0.08 * S;
|
||
const flash = scene.add.image(bx + flashOffX, by + flashOffY, tex.flash(i))
|
||
.setDepth(DEPTH_FLASH).setTint(flashColor)
|
||
.setScale((0.85 * S) / TEX).setAlpha(0)
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setRotation(back.rotation + (rand() - 0.5) * 0.4);
|
||
// The dark front: larger, offset, OVER the flash — the silhouette.
|
||
// Loose offset so its fbm holes + edge let the flash read through
|
||
// (light wrapping the dark cloud), without burying the core.
|
||
const frontOffX = (rand() - 0.5) * 0.16 * S;
|
||
const frontOffY = (rand() - 0.5) * 0.14 * S;
|
||
const front = scene.add.image(bx + frontOffX, by + frontOffY, tex.front(i))
|
||
.setDepth(DEPTH_FRONT).setTint(frontColor)
|
||
.setScale((1.12 * S) / TEX).setAlpha(faMin)
|
||
.setRotation(back.rotation + (rand() - 0.5) * 0.3);
|
||
|
||
assignWorld(scene, back);
|
||
assignWorld(scene, flash);
|
||
assignWorld(scene, front);
|
||
|
||
this.clusters.push({
|
||
back, flash, front,
|
||
homeX, homeY, p,
|
||
flashOffX, flashOffY, frontOffX, frontOffY,
|
||
driftX: 0, driftY: 0, // accumulated parallax drift (view space)
|
||
interval, phase, strikeDur, chargeWindow, strength,
|
||
backBase: range(baMin, baMax),
|
||
frontBase: range(faMin, faMax),
|
||
});
|
||
}
|
||
this.lastScrollX = cam.scrollX;
|
||
this.lastScrollY = cam.scrollY;
|
||
}
|
||
|
||
/**
|
||
* Per-frame: anchor each trio to the sky band (home + parallax drift,
|
||
* wrapped into the band — the wanderer model), and drive the storm cycle
|
||
* (charge → strike → afterglow) on each trio's alphas.
|
||
*/
|
||
update(nowMs, dtMs) {
|
||
if (!this.clusters.length) return;
|
||
const scene = this.scene;
|
||
const cam = scene.cameras.main;
|
||
const camX = cam.scrollX;
|
||
const camY = cam.scrollY;
|
||
|
||
const w = scene.scale.width;
|
||
const h = scene.scale.height;
|
||
const dx = camX - this.lastScrollX;
|
||
const dy = camY - this.lastScrollY;
|
||
this.lastScrollX = camX;
|
||
this.lastScrollY = camY;
|
||
|
||
// The sky band, in VIEW space: strictly inside the visible view (upper
|
||
// ~58%), so the trio is always overhead — no off-screen overhang. The
|
||
// wrap is done in view space (camera-independent), then converted to
|
||
// world space as camera + view offset — the wanderer model. (The storm
|
||
// is a SKY: it stays put while the world flies past; its near-zero
|
||
// parallax is the illusion of a dome around the system.)
|
||
const vx0 = -MARGIN_X * w;
|
||
const vx1 = w + MARGIN_X * w;
|
||
const vy0 = BAND_Y.top * h;
|
||
const vy1 = BAND_Y.bottom * h;
|
||
|
||
for (const c of this.clusters) {
|
||
// Parallax drift in view space (trails the camera at p), wrapped so
|
||
// the trio stays in the band.
|
||
c.driftX -= dx * c.p;
|
||
c.driftY -= dy * c.p;
|
||
const vx = wrapIn(c.homeX + c.driftX, vx0, vx1 - vx0);
|
||
const vy = wrapIn(c.homeY + c.driftY, vy0, vy1 - vy0);
|
||
|
||
c.back.x = camX + vx;
|
||
c.back.y = camY + vy;
|
||
c.flash.x = c.back.x + c.flashOffX;
|
||
c.flash.y = c.back.y + c.flashOffY;
|
||
c.front.x = c.back.x + c.frontOffX;
|
||
c.front.y = c.back.y + c.frontOffY;
|
||
|
||
// The storm cycle (pure, seeded, Node-tested).
|
||
const env = stormEnvelope(nowMs, c.interval, c.strikeDur, c.phase, c.chargeWindow);
|
||
c.back.setAlpha(c.backBase * (0.7 + 0.5 * env.charge + 0.3 * env.flash));
|
||
c.flash.setAlpha(env.flash * c.strength);
|
||
c.front.setAlpha(c.frontBase * (0.8 + 0.3 * env.charge));
|
||
}
|
||
}
|
||
|
||
/** Tear down. */
|
||
destroy() {
|
||
for (const c of this.clusters) {
|
||
c.back.destroy();
|
||
c.flash.destroy();
|
||
c.front.destroy();
|
||
}
|
||
this.clusters = [];
|
||
}
|
||
}
|