232 lines
9.6 KiB
JavaScript
232 lines
9.6 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|