orbit/js/visuals/StormLayer.js

314 lines
14 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.

/**
* StormLayer — the habitable system's electrified storm.
*
* WHY IT EXISTS: "Temperate, well-lit, and quietly crowded with life." —
* and life started in lightning (MillerUrey). 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.
*
* VOICE (data/sfx.json → thunder_01…04): the moment a cluster's flash
* window opens (the strike itself — env.flash goes 0 → >0) plays ONE of
* the four thunder claps at random. Throttled to one clap per ~0.7 s, so
* near-simultaneous clusters make one clap, not four.
*
* 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 { playSfxOneOfOn } from '../utils/Sfx.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)
const THUNDER_SFX = ['thunder_01', 'thunder_02', 'thunder_03', 'thunder_04']; // data/sfx.json keys
const THUNDER_MIN_GAP_MS = 700; // one clap per near-simultaneous strikes
/** 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 (23 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;
this.lastThunderMs = -Infinity; // the last clap (the throttle's anchor)
}
/** 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),
strikeArmed: false, // first update() frame only arms the strike detector
wasFlashing: false, // the previous frame's flash state (0→>0 edge = the strike)
});
}
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));
// THE STRIKE (data/sfx.json → thunder_01…04): the flash window opens
// exactly at the cycle boundary — the cloud igniting (env.flash goes
// 0 → >0) is the lightning going off. One random thunder clap per
// strike, throttled so a sky of near-simultaneous clusters makes one
// clap, not four. The first frame only ARMS (a cluster already
// mid-strike at scene entry stays silent).
const flashing = env.flash > 0;
if (c.strikeArmed && flashing && !c.wasFlashing) this.thunderStrike(nowMs);
c.strikeArmed = true;
c.wasFlashing = flashing;
}
}
/** A strike went off — one random thunder clap (thunder-01…04). */
thunderStrike(nowMs) {
if (nowMs - this.lastThunderMs < THUNDER_MIN_GAP_MS) return;
this.lastThunderMs = nowMs;
playSfxOneOfOn(this.scene, THUNDER_SFX);
}
/** Tear down. */
destroy() {
for (const c of this.clusters) {
c.back.destroy();
c.flash.destroy();
c.front.destroy();
}
this.clusters = [];
}
}