/** * NEBULA LIGHTNING — overt bolts streaking across the background sky. * * The nebula's far-sky discharge: not the habitable's in-cloud strikes * (those ignite purple cloud clusters overhead) but OVERT lightning — * a jagged bolt that starts on one end of the view and ends on the * other, with random branches, in the system's GAS colors (base + * accent, alternating per strike). Ionized gas + electricity: the sky * of a nebula is a plasma, and plasma discharges. * * A SKY object (the WandererStar anchor model): the bolt is generated in * VIEW space and the pair of Graphics is re-positioned each frame at * camera + a near-zero parallax drift — the sky stays put while the * world flies past. It is CONTENT (world-space Graphics), not a filter: * Canvas-safe, and — because it is on the world pass — the nebula's * RIPPLE displaces the bolt as it flies, and the FRONT gas (depth 11) * veils it as a puff drifts by: depth for free. * * Stateless by design: nothing to pool. Each frame the strobe envelope * (pure, seeded, Node-tested: SystemEffectsMath.boltStrobe) decides * dark or striking; when striking, the bolt's shape is derived from * (strike ordinal, system seed, view size) — so the shape is STABLE * during the strobe, DIFFERENT on every strike, and identical every * run for the same system. * * VOICE: one soft thunder clap per strike (the habitable's four claps at * reduced volume — this lightning is FAR, in the sky), throttled like * the storm's. `thunder: false` in the config silences it. * * Config: data/systems.json → types.nebula.effect.lightning. * (Removing that block — or `enabled: false` — reverts to the * lightning-less nebula; no code changes.) */ import Phaser from '../vendor/phaser.js'; import { toColor } from '../utils/Color.js'; import { boltShape, boltStrobe, strikeCycle } from './SystemEffectsMath.js'; import { playSfxOneOfOn } from '../utils/Sfx.js'; import { assignWorld } from './UiCameras.js'; const DEPTH_GLOW = 2.9; // the sky itself: above the starfield (0–2), below the back gas (3) const DEPTH_CORE = 2.91; // the hot core, just over its own glow 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 strike const THUNDER_VOLUME = 0.35; // soft — this lightning is far (master is 0.55) /** * Mix a 0xRRGGBB color toward white by t (0..1) — the hot core's tint: * the strike's gas hue, blown out by its own light. */ function towardWhite(color, t) { const k = Math.min(1, Math.max(0, t)); const mix = (c) => Math.round(c + (255 - c) * k); return (mix((color >> 16) & 255) << 16) | (mix((color >> 8) & 255) << 8) | mix(color & 255); } /** * The far-sky lightning of a nebula system. * * @param {object} scene Phaser scene (the GameScene) * @param {string|number} baseColor the system's base atmosphere color * @param {string|number} accentColor the derived accent (the front gas's tint) * @param {object} cfg data/systems.json → effect.lightning * @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation */ export class NebulaLightning { constructor(scene, baseColor, accentColor, cfg, fx = {}) { this.scene = scene; this.cfg = cfg ?? {}; this.fx = fx ?? {}; this.base = toColor(baseColor, 0x88aaff); this.accent = toColor(accentColor, this.base); this.thunderOn = this.cfg.thunder !== false; // The RHYTHM — seeded per system (the content.fx trio), like the // flare and the wanderer: this nebula strikes on its own pace. const [iMin, iMax] = normRange(this.cfg.interval, [8, 15]); this.interval = iMin + (iMax - iMin) * (this.fx.drift ?? 0.5); this.strikeDur = Math.max(0.15, Number(this.cfg.strike) || 0.45); this.phase = (this.fx.phase ?? 0.5) * this.interval; // seconds; strike at t ≡ phase (mod interval) // The SHAPE knobs (per strike, from the strike ordinal — see update()). this.levels = Math.max(2, Math.min(8, Math.round(this.cfg.levels ?? 6))); const [bMin, bMax] = normRange(this.cfg.branches, [1, 4]); this.branches = Math.round(bMin + (bMax - bMin) * (this.fx.angle ?? 0.5)); // The LOOK. this.glowWidth = Math.max(1, Number(this.cfg.glowWidth) || 14); this.coreWidth = Math.max(1, Number(this.cfg.coreWidth) || 3); this.glowAlpha = Math.max(0, Number(this.cfg.glowAlpha) || 0.4); this.coreAlpha = Math.max(0, Number(this.cfg.coreAlpha) || 0.9); this.p = Math.max(0, Number(this.cfg.parallax) || 0.05); // sky: barely drifts // The seed base (from the system fx, the StormLayer pattern) mixed // with the strike ordinal in update() — one stable shape per strike. this.seedBase = (Math.imul(0x51ab1234, 2654435761) ^ Math.round((this.fx.phase ?? 0.5) * 1e9)) >>> 0; this.glow = null; this.core = null; this.driftX = 0; this.driftY = 0; this.lastScrollX = 0; this.lastScrollY = 0; this.armed = false; // the first frame only arms the thunder detector this.wasOn = false; // the previous frame's strike state (0→>0 edge = the bolt) this.lastThunderMs = -Infinity; this.enabled = this.cfg.enabled !== false; } /** Build the glow + core Graphics (idempotent). */ create() { if (this.glow) return; const scene = this.scene; // The GLOW: the wide additive halo in the gas color (light, not ink). this.glow = scene.add .graphics() .setDepth(DEPTH_GLOW) .setBlendMode(Phaser.BlendModes.ADD); // The CORE: the thin hot line over the glow (normal blend, crisp). this.core = scene.add.graphics().setDepth(DEPTH_CORE); // Keep both on the WORLD pass if the system-effect UI-camera split is // active (world content, like the starfield — the ripple displaces // them, the HUD never touches them). No-op in single-camera play. assignWorld(scene, this.glow); assignWorld(scene, this.core); this.lastScrollX = scene.cameras.main.scrollX; this.lastScrollY = scene.cameras.main.scrollY; } /** * Per-frame: re-anchor the pair to the view (the sky model) and draw — * or clear — the current strike. Call once per frame with the game * clock (ms). */ update(nowMs, _dtMs) { if (!this.enabled || !this.glow) return; const scene = this.scene; const cam = scene.cameras.main; // Sky anchor: trail the camera at near-zero parallax (the storm / // wanderer model). The bolt spans the whole view by construction, so // there is nothing to wrap — the sky is always the whole view. const dx = cam.scrollX - this.lastScrollX; const dy = cam.scrollY - this.lastScrollY; this.lastScrollX = cam.scrollX; this.lastScrollY = cam.scrollY; this.driftX -= dx * this.p; this.driftY -= dy * this.p; const gx = cam.scrollX + this.driftX; const gy = cam.scrollY + this.driftY; this.glow.x = gx; this.glow.y = gy; this.core.x = gx; this.core.y = gy; // The strobe (pure, seeded): 0 = dark sky. const env = boltStrobe(nowMs, this.interval, this.strikeDur, this.phase); if (env <= 0) { if (this.wasOn) { this.glow.clear(); this.core.clear(); } this.wasOn = false; return; } // THE STRIKE (data/sfx.json → thunder_01…04, soft): the bolt going // off is the strobe's 0 → >0 edge — this.wasOn is still the // PREVIOUS frame's state here (it was reset on the dark frames // above, and latched AFTER this check). The first frame only arms // (a system entered mid-strike stays silent), and the throttle // makes near-simultaneous frames one clap. if (this.armed && !this.wasOn) this.thunderStrike(nowMs); this.armed = true; this.wasOn = true; // THE BOLT: shape from (strike ordinal, system seed) — stable through // this strobe, different every strike, identical every run. const w = scene.scale.width; const h = scene.scale.height; const cyc = strikeCycle(nowMs, this.interval, this.phase); const seed = (this.seedBase ^ Math.imul(cyc + 1, 0x9e3779b9)) >>> 0; const shape = boltShape(seed, w, h, { levels: this.levels, branches: this.branches }); // ALTERNATE the gas colors per strike (deterministic): one strike in // the base color, the next in the accent — the two-tone sky. const col = cyc & 1 ? this.accent : this.base; const coreCol = towardWhite(col, 0.72); const stroke = (gfx, width, color, alpha) => { if (alpha <= 0) return; gfx.lineStyle(width, color, Math.min(1, alpha)); for (const path of [shape.main, ...shape.branches]) { for (let i = 1; i < path.length; i++) { gfx.lineBetween(path[i - 1].x, path[i - 1].y, path[i].x, path[i].y); } } }; this.glow.clear(); this.core.clear(); stroke(this.glow, this.glowWidth, col, this.glowAlpha * env); stroke(this.core, this.coreWidth, coreCol, this.coreAlpha * env); } /** A strike went off — one soft, distant thunder clap. */ thunderStrike(nowMs) { if (!this.thunderOn) return; if (nowMs - this.lastThunderMs < THUNDER_MIN_GAP_MS) return; this.lastThunderMs = nowMs; playSfxOneOfOn(this.scene, THUNDER_SFX, { volume: THUNDER_VOLUME }); } /** Tear down (idempotent — scene jumps / shutdown both call this). */ destroy() { if (this.glow) { this.glow.destroy(); this.glow = null; } if (this.core) { this.core.destroy(); this.core = null; } } } 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; }