/** * 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. * * 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 (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; 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 = []; } }