orbit/js/visuals/NebulaLightning.js

232 lines
9.6 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.

/**
* 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;
}