Compare commits
2 Commits
84909d6f80
...
32215fa737
| Author | SHA1 | Date |
|---|---|---|
|
|
32215fa737 | |
|
|
8e035d0891 |
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 1–2 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the home→far density gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is GLOBALLY; which types favor WHICH REGION of the galaxy (the home corner, the middle, the far/deep corner) is the per-zone mix in data/galaxy.json → distribution.zoneMix, keyed off each system's home→far diagonal zone (record.zone).",
|
||||
"_effect": "Per-type VISUAL EFFECT (js/visuals/SystemEffects.js): the star's character. Each sub-effect is an OPTIONAL block — a type wears exactly what it lists (an empty {} = no effect, renders untouched). CAMERA FILTERS (WebGL; the HUD is excluded by the UI-camera split) — 'ripple' { center: screen|star, strength (wave cycles), amplitude (displacement, fraction of screen), speed (rad/s), padding (framebuffer slack, keep >= amplitude*width*(1+flare.rippleBoost)*centers), centers: 1|2, orbit {radius, period} (two centers circling each other) }; 'grade' { tint (hex hue), amount (0..1), saturation (1=none), brightness, lift {amount, color — or a+b for two directional hues (the binary's two lights glow opposite sides of the view, across the seeded axis)}, grain {amount, parallax}, split {a, b hex, mix 0..1, axis deg} (a two-color directional wash) }. 'flare' { interval, duration (seconds), rippleBoost x, flash 0..1, flashColor } is a SHARED seeded rhythm that surges the ripple's amplitude AND flashes the grade warm — the star flaring (a ripple with no flare just shimmers at its base amplitude). 'breath' { period (s), depth 0..0.5 } — the binary's two lights slowly trade brightness (take their turn in the sky). CONTENT (world-space sprites, Canvas-safe, depth 3, behind the art) — 'particles' (ember dust), 'wanderer' (a distant companion star: coreSize, haloSize, parallax, speed — camera-anchored so it stays in the sky), 'haze' (a full-screen atmospheric color+grit layer behind the stars, redDwarf's red void), 'void' (a full-screen NEUTRAL dark+grit base behind the stars — the binary's two lights lift/breathe it into a two-color wash; without it the clear color bypasses the filter and the void can't be tinted). LIVE TODAY: nebula = a steady ripple; redDwarf = a SUPER-subtle ripple that SURGES on each flare + a warm grade with shadow lift, gritty grain and a red haze + ember dust; binary = a two-color split grade + a two-light void glow (lift painted on a neutral void base) + a slow breath + a wandering companion star (NO ripple — two stars, one steady light). Per-system variation (flare phase, split axis, breath phase, wanderer path) is seeded from the system id in SystemGenerator (content.fx), so each system is stable but distinct. Tuning is a JSON edit.",
|
||||
"_effect": "Per-type VISUAL EFFECT (js/visuals/SystemEffects.js): the star's character. Each sub-effect is an OPTIONAL block — a type wears exactly what it lists (an empty {} = no effect, renders untouched). CAMERA FILTERS (WebGL; the HUD is excluded by the UI-camera split) — 'ripple' { center: screen|star, strength (wave cycles), amplitude (displacement, fraction of screen), speed (rad/s), padding (framebuffer slack, keep >= amplitude*width*(1+flare.rippleBoost)*centers), centers: 1|2, orbit {radius, period} (two centers circling each other) }; 'grade' { tint (hex hue), amount (0..1), saturation (1=none), brightness, lift {amount, color — or a+b for two directional hues (the binary's two lights glow opposite sides of the view, across the seeded axis)}, grain {amount, parallax}, split {a, b hex, mix 0..1, axis deg} (a two-color directional wash) }. 'flare' { interval, duration (seconds), rippleBoost x, flash 0..1, flashColor } is a SHARED seeded rhythm that surges the ripple's amplitude AND flashes the grade warm — the star flaring (a ripple with no flare just shimmers at its base amplitude). 'breath' { period (s), depth 0..0.5 } — the binary's two lights slowly trade brightness (take their turn in the sky). CONTENT (world-space sprites, Canvas-safe, depth 3, behind the art) — 'particles' (ember dust), 'wanderer' (a distant companion star: coreSize, haloSize, parallax, speed — camera-anchored so it stays in the sky), 'haze' (a full-screen atmospheric color+grit layer behind the stars, redDwarf's red void), 'void' (a full-screen NEUTRAL dark+grit base behind the stars — the binary's two lights lift/breathe it into a two-color wash; without it the clear color bypasses the filter and the void can't be tinted), 'lightning' (OVERT bolts streaking the background sky — the nebula's far-sky discharge: a jagged edge-to-edge trunk + random branches in the gas's base/accent colors (alternating per strike), strobed, optional soft distant thunder; world Graphics at depth 2.9 — the sky itself, behind the back gas; interval [s], strike (s), levels, branches, glowWidth, coreWidth, glowAlpha, coreAlpha, parallax, thunder). LIVE TODAY: nebula = a steady ripple + occasional far-sky lightning bolts (in the gas colors); redDwarf = a SUPER-subtle ripple that SURGES on each flare + a warm grade with shadow lift, gritty grain and a red haze + ember dust; binary = a two-color split grade + a two-light void glow (lift painted on a neutral void base) + a slow breath + a wandering companion star (NO ripple — two stars, one steady light). Per-system variation (flare phase, split axis, breath phase, wanderer path) is seeded from the system id in SystemGenerator (content.fx), so each system is stable but distinct. Tuning is a JSON edit.",
|
||||
"objectCount": {
|
||||
"barren": 0.10,
|
||||
"objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 }
|
||||
|
|
@ -130,7 +130,20 @@
|
|||
"theme": { "color": "#5fd4d0" },
|
||||
"distribution": { "weight": 12 },
|
||||
"effect": {
|
||||
"ripple": { "center": "screen", "strength": 20, "amplitude": 0.0042, "speed": 1.0, "padding": 20 }
|
||||
"ripple": { "center": "screen", "strength": 20, "amplitude": 0.0042, "speed": 1.0, "padding": 20 },
|
||||
"lightning": {
|
||||
"enabled": true,
|
||||
"interval": [8, 15],
|
||||
"strike": 0.45,
|
||||
"levels": 6,
|
||||
"branches": [1, 4],
|
||||
"glowWidth": 14,
|
||||
"coreWidth": 3,
|
||||
"glowAlpha": 0.4,
|
||||
"coreAlpha": 0.9,
|
||||
"parallax": 0.05,
|
||||
"thunder": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"star": {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,13 @@
|
|||
* front clouds at depth 11 (ABOVE the ship) in the accent tint with
|
||||
* near parallax (>1), sizes/alphas in the configured ranges, clouds
|
||||
* wrapped into the camera window on flight, slow self-swirl, clean
|
||||
* destroy, and front.enabled=false reverting to the single back layer.
|
||||
* destroy, and front.enabled=false reverting to the single back layer;
|
||||
* - the far-sky LIGHTNING (js/visuals/NebulaLightning.js): glow + core
|
||||
* Graphics on the SKY depth (2.9/2.91 — behind the back gas), the
|
||||
* glow additive; a strike frame draws the bolt (both stroked), a
|
||||
* quiet frame clears it; camera-anchored; destroy idempotent.
|
||||
* (The bolt math itself — strobe envelope, cycle, shape — is covered
|
||||
* in dev/system-effects.test.mjs.)
|
||||
*/
|
||||
|
||||
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
|
||||
|
|
@ -50,6 +56,9 @@ const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Gala
|
|||
const { drawCloud, NebulaAtmosphere } = await import(
|
||||
pathToFileURL(join(__dirname, '../js/visuals/NebulaAtmosphere.js')).href
|
||||
);
|
||||
const { NebulaLightning } = await import(
|
||||
pathToFileURL(join(__dirname, '../js/visuals/NebulaLightning.js')).href
|
||||
);
|
||||
const { shiftHue, toColor, toCss } = await import(
|
||||
pathToFileURL(join(__dirname, '../js/utils/Color.js')).href
|
||||
);
|
||||
|
|
@ -348,13 +357,28 @@ function makeFakeScene() {
|
|||
images.push(o);
|
||||
return o;
|
||||
};
|
||||
// A fake Graphics (the lightning module's only drawing surface): records
|
||||
// every stroke so the tests can assert draw-vs-clear without a canvas.
|
||||
const gfx = [];
|
||||
const makeGfx = () => {
|
||||
const o = { x: 0, y: 0, depth: 0, calls: [], destroyed: false };
|
||||
o.clear = () => o.calls.push('clear');
|
||||
o.lineStyle = (w, c, a) => (o.style = [w, c, a]);
|
||||
o.lineBetween = () => o.calls.push('line');
|
||||
o.setDepth = (d) => ((o.depth = d), o);
|
||||
o.setBlendMode = (m) => ((o.blend = m), o);
|
||||
o.destroy = () => (o.destroyed = true);
|
||||
gfx.push(o);
|
||||
return o;
|
||||
};
|
||||
return {
|
||||
images,
|
||||
gfx,
|
||||
scale: { width: 1280, height: 720 },
|
||||
cameras: { main: { scrollX: 1000, scrollY: 500 } },
|
||||
time: { now: 0 },
|
||||
textures: { exists: () => false, addCanvas: () => {} },
|
||||
add: { image: makeImage },
|
||||
add: { image: makeImage, graphics: makeGfx },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -415,6 +439,101 @@ neb2.create();
|
|||
check('front.enabled=false reverts to the single back layer',
|
||||
scene2.images.length === nebulaCfg.count && scene2.images.every((o) => o.depth === 3));
|
||||
|
||||
// --- The far-sky lightning (NebulaLightning) ------------------------------
|
||||
const lcfg = config.section('systems.types.nebula.effect.lightning', null);
|
||||
check('systems.json: the nebula wears the far-sky lightning (enabled, bounded cadence)', (() => {
|
||||
return (
|
||||
!!lcfg &&
|
||||
lcfg.enabled !== false &&
|
||||
Array.isArray(lcfg.interval) && lcfg.interval[0] >= 2 && lcfg.interval[1] > lcfg.interval[0] &&
|
||||
typeof lcfg.strike === 'number' && lcfg.strike > 0
|
||||
);
|
||||
})());
|
||||
|
||||
const lScene = makeFakeScene();
|
||||
const lFx = { phase: 0.3, angle: 0.5, drift: 0.7 };
|
||||
const lightning = new NebulaLightning(lScene, '#f0884a', '#e9f36d', lcfg, lFx);
|
||||
lightning.create();
|
||||
|
||||
check('lightning: glow + core Graphics on the SKY depth (behind the back gas)', (() => {
|
||||
const [glow, core] = lScene.gfx;
|
||||
return (
|
||||
lScene.gfx.length === 2 &&
|
||||
glow.depth < 3 && core.depth < 3 && core.depth > glow.depth && // sky: below the back gas (3)
|
||||
glow.blend === 'ADD' && core.blend === undefined && // halo is light; the core stays crisp
|
||||
typeof lightning.base === 'number' && typeof lightning.accent === 'number' && lightning.base !== lightning.accent
|
||||
);
|
||||
})());
|
||||
|
||||
check('lightning: a strike frame draws the bolt (both stroked); a quiet frame clears', (() => {
|
||||
// The strike sits at t ≡ phase (mod interval) — find this cycle's boundary.
|
||||
const tStrike = (lightning.phase % lightning.interval) * 1000;
|
||||
lightning.update(tStrike, 16);
|
||||
const drew = (g) => g.calls.filter((c) => c === 'line').length > 0;
|
||||
const drewOnStrike = drew(lScene.gfx[0]) && drew(lScene.gfx[1]);
|
||||
// Half a cycle later: dark sky again.
|
||||
lightning.update(tStrike + lightning.interval * 500, 16);
|
||||
const cleared = (g) => g.calls[g.calls.length - 1] === 'clear';
|
||||
return drewOnStrike && cleared(lScene.gfx[0]) && cleared(lScene.gfx[1]);
|
||||
})());
|
||||
|
||||
// Thunder (the VOICE of the bolt) — a fresh instance + a sound-recording
|
||||
// fake scene (the Sfx seam: scene.sound.play + the audio-cache guard), so
|
||||
// the assertions are independent of the update history above.
|
||||
const tScene = makeFakeScene();
|
||||
const thunderPlays = [];
|
||||
tScene.sound = { play: (key, o) => thunderPlays.push({ key, volume: o?.volume }) };
|
||||
tScene.cache = { audio: { has: (key) => String(key).startsWith('sfx_thunder_') } };
|
||||
const THUNDER_KEYS = ['sfx_thunder_01', 'sfx_thunder_02', 'sfx_thunder_03', 'sfx_thunder_04'];
|
||||
const tL = new NebulaLightning(tScene, '#f0884a', '#e9f36d', lcfg, lFx);
|
||||
tL.create();
|
||||
const tEdge = (tL.phase % tL.interval) * 1000; // this cycle's strike boundary
|
||||
tL.update(tEdge, 16); // first frame: arms the detector (entered mid-strike → silent)
|
||||
check('thunder: the arm frame (a system entered mid-strike) stays silent', thunderPlays.length === 0);
|
||||
// A dark frame between strikes (the real cadence has a long one — the
|
||||
// window is 1.35s, the interval 8–15s) resets the 0→>0 edge.
|
||||
const DARK = (t) => tL.update(t, 16);
|
||||
DARK(tEdge + 1500); // just past the 1.35s strobe window → dark sky
|
||||
tL.update(tEdge + tL.interval * 1000, 16); // the NEXT strike: a real 0→>0 edge
|
||||
check('thunder: one clap on the strike edge — a far, soft thunder voice (0.35)', (() => {
|
||||
return (
|
||||
thunderPlays.length === 1 &&
|
||||
THUNDER_KEYS.includes(thunderPlays[0].key) &&
|
||||
Math.abs(thunderPlays[0].volume - 0.35) < 1e-9
|
||||
);
|
||||
})());
|
||||
tL.update(tEdge + tL.interval * 1000 + 100, 16); // mid-strobe: the bolt is still up
|
||||
DARK(tEdge + tL.interval * 1000 + 1500); // …and then the sky darkens
|
||||
tL.update(tEdge + tL.interval * 2000, 16); // the strike AFTER next: a new edge
|
||||
tL.update(tEdge + tL.interval * 2000 + 100, 16); // …and its mid-strobe
|
||||
check('thunder: one clap per strike (mid-strobe frames stay silent)', thunderPlays.length === 2);
|
||||
|
||||
// The `thunder: false` config silences the voice (the bolt still strikes).
|
||||
const qScene = makeFakeScene();
|
||||
const quietPlays = [];
|
||||
qScene.sound = { play: () => quietPlays.push(1) };
|
||||
qScene.cache = { audio: { has: () => true } };
|
||||
const qL = new NebulaLightning(qScene, '#f0884a', '#e9f36d', { ...lcfg, thunder: false }, lFx);
|
||||
qL.create();
|
||||
const qEdge = (qL.phase % qL.interval) * 1000;
|
||||
qL.update(qEdge, 16);
|
||||
qL.update(qEdge + qL.interval * 1000, 16);
|
||||
check('thunder: `thunder: false` silences the voice (the bolt still strikes)', (() => {
|
||||
const drew = (g) => g.calls.filter((c) => c === 'line').length > 0;
|
||||
return quietPlays.length === 0 && drew(qScene.gfx[0]) && drew(qScene.gfx[1]);
|
||||
})());
|
||||
|
||||
check('lightning: camera-anchored (sky model); destroy is idempotent', (() => {
|
||||
lScene.cameras.main.scrollX = 2600;
|
||||
lScene.cameras.main.scrollY = 1500;
|
||||
const t = (lightning.phase % lightning.interval) * 1000;
|
||||
lightning.update(t, 16);
|
||||
const anchored = Math.abs(lScene.gfx[0].x - 2600) < 500 && Math.abs(lScene.gfx[0].y - 1500) < 500;
|
||||
lightning.destroy();
|
||||
lightning.destroy(); // idempotent
|
||||
return anchored && lScene.gfx[0].destroyed === true && lScene.gfx[1].destroyed === true && lightning.glow === null;
|
||||
})());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log(failures === 0 ? '\nAll nebula tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@
|
|||
* ?seed=<seed> use a DETERMINISTIC dev galaxy (same seed ⇒ same
|
||||
* system, planets, asteroid clusters) — reproducible shots
|
||||
* ?report=1 paint console errors (or "SMOKE OK") on the canvas
|
||||
* ?lightning=1 watch the nebula's far-sky LIGHTNING end-to-end:
|
||||
* poll until the scene owns a live NebulaLightning, then
|
||||
* count its bolt strokes (window.__nlProbe for CDP;
|
||||
* pair with ?fx=nebula — the bolt only wears on nebulae)
|
||||
* ?forceStrike=1 (with ?lightning=1) nudge the strike phase so the first
|
||||
* bolt lands within seconds instead of the seeded cadence
|
||||
*/
|
||||
import Phaser from '../js/vendor/phaser.js';
|
||||
import { config } from '../js/config/Config.js';
|
||||
|
|
@ -207,6 +213,60 @@ if (params && params.get('thrusttest') === '1') {
|
|||
}, 40000);
|
||||
}
|
||||
|
||||
if (params && params.get('lightning') === '1') {
|
||||
// Dev: the nebula's far-sky LIGHTNING, end-to-end in the LIVE scene —
|
||||
// poll until the scene owns a live NebulaLightning (a ?fx=nebula boot),
|
||||
// then COUNT the bolt's strokes: proof the module created cleanly, the
|
||||
// update loop runs, a strike actually fires, and the Graphics API draws.
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
(async () => {
|
||||
const t0 = Date.now();
|
||||
let nl = null;
|
||||
for (;;) {
|
||||
const s = game.scene.getScene('GameScene');
|
||||
nl = s?.nebulaLightning ?? null;
|
||||
if (nl) break;
|
||||
if (Date.now() - t0 > 15000) {
|
||||
window.__nlProbe = { ok: false, detail: 'no NebulaLightning on the scene (boot a ?fx=nebula system)' };
|
||||
return;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
// Make the first strike land inside the probe window (dev hook).
|
||||
if (params.get('forceStrike') === '1') nl.phase = 0.1;
|
||||
let strokes = 0;
|
||||
const hook = (g) => {
|
||||
const orig = g.lineBetween;
|
||||
g.lineBetween = (...a) => { strokes++; return orig(...a); };
|
||||
};
|
||||
hook(nl.glow);
|
||||
hook(nl.core);
|
||||
const t1 = Date.now();
|
||||
for (;;) {
|
||||
if (strokes > 0) {
|
||||
window.__nlProbe = {
|
||||
ok: true,
|
||||
interval: nl.interval,
|
||||
strikeDur: nl.strikeDur,
|
||||
phase: nl.phase,
|
||||
base: nl.base,
|
||||
accent: nl.accent,
|
||||
glowDepth: nl.glow.depth,
|
||||
coreDepth: nl.core.depth,
|
||||
strokes,
|
||||
ms: Date.now() - t1,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (Date.now() - t1 > 40000) {
|
||||
window.__nlProbe = { ok: false, detail: `no strike within 40 s (interval=${nl.interval}s, phase=${nl.phase}s)` };
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (nearParam) {
|
||||
const px = Number(nearParam) || 400;
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@
|
|||
* a storm; the storm envelope (charge → strike → afterglow) is quiet
|
||||
* mid-cycle, peaks at the strike, strobes and decays; padding covers
|
||||
* the worst-case displacement (amplitude × flare boost ×
|
||||
* centers × width);
|
||||
* centers × width); the nebula bundle has a ripple + the OVERT
|
||||
* far-sky lightning (strobe is quiet/strobed/bounded; the bolt shape
|
||||
* is edge-to-edge, jagged, branched, deterministic per seed, finite);
|
||||
* - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the
|
||||
* requested type wins, ties fall to roster order, missing type => null;
|
||||
* - the SHADER CONTRACT (js/visuals/SystemEffects.js, against the Phaser
|
||||
|
|
@ -177,6 +179,80 @@ check('storm: bounded, deterministic, phase-shifted, no NaN across a sweep', (()
|
|||
const c = M.stormEnvelope(1000, 12, 0.55, 6.0, 0.45);
|
||||
return sweep() && a.charge === b.charge && a.flash === b.flash && (a.charge !== c.charge || a.flash !== c.flash);
|
||||
})());
|
||||
// --- The nebula lightning math (strobe / cycle / bolt shape) -------------
|
||||
check('lightning strobe: quiet outside the strike window', (() => {
|
||||
const f = (ms) => M.boltStrobe(ms, 12, 0.45, 0);
|
||||
return f(2000) === 0 && f(5000) === 0 && f(10000) === 0; // after / mid-cycle / just before the next strike
|
||||
})());
|
||||
check('lightning strobe: peaks at the strike, strobes (rebound), decays, bounded', (() => {
|
||||
const at = (dtSec) => M.boltStrobe(12 * 1000 + dtSec * 1000, 12, 0.45, 0);
|
||||
return (
|
||||
at(0) > 0.95 && // the primary burst
|
||||
at(0.45 * 0.45) > 0.4 && // the rebound burst
|
||||
at(2 * 0.45) < 0.2 && // the afterglow has mostly faded
|
||||
at(4 * 0.45) === 0 && // clipped (exact 0, no asymptote)
|
||||
at(0) <= 1 && // bounded 0..1
|
||||
M.boltStrobe(12 * 1000 - 1000, 12, 0.45, 0) === 0 // nothing BEFORE the strike
|
||||
);
|
||||
})());
|
||||
check('lightning strobe: periodic + phase-shifted + no NaN across a sweep', (() => {
|
||||
let ok = true;
|
||||
for (let ms = 0; ms < 3 * 12 * 1000; ms += 13) {
|
||||
const v = M.boltStrobe(ms, 12, 0.45, 4.1);
|
||||
if (!Number.isFinite(v) || v < 0 || v > 1) ok = false;
|
||||
}
|
||||
const a = M.boltStrobe(1000, 12, 0.45, 1.0);
|
||||
const b = M.boltStrobe(1000, 12, 0.45, 1.0);
|
||||
const c = M.boltStrobe(1000, 12, 0.45, 6.0);
|
||||
return ok && a === b && a !== c;
|
||||
})());
|
||||
check('lightning cycle: integer ordinal, increments at each strike boundary', (() => {
|
||||
const cyc = (ms) => M.strikeCycle(ms, 12, 0);
|
||||
return (
|
||||
Number.isInteger(cyc(100)) &&
|
||||
cyc(12 * 1000) === cyc(12 * 1000 + 50) && // stable inside a cycle
|
||||
cyc(12 * 1000 + 50) === cyc(100) + 1 && // increments at the boundary
|
||||
cyc(25 * 1000 + 50) === cyc(100) + 2 &&
|
||||
Number.isInteger(M.strikeCycle(1000, 12, 3.7)) // phase-aware, still integer
|
||||
);
|
||||
})());
|
||||
check('lightning bolt: starts on one view edge, ends on the other', (() => {
|
||||
const { main } = M.boltShape(1337, 1280, 720, { levels: 6, branches: 3 });
|
||||
const a = main[0];
|
||||
const b = main[main.length - 1];
|
||||
const onLeft = (p) => p.x <= 0;
|
||||
const onRight = (p) => p.x >= 1280;
|
||||
return (onLeft(a) && onRight(b)) || (onRight(a) && onLeft(b));
|
||||
})());
|
||||
check('lightning bolt: jagged (off-axis wander), finite', (() => {
|
||||
const { main, branches } = M.boltShape(4242, 1280, 720, { levels: 6, branches: 3 });
|
||||
const finite = (pts) => pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
|
||||
if (!finite(main) || !branches.every(finite)) return false;
|
||||
// Jagged = the trunk wanders off the straight line between its own
|
||||
// endpoints (midpoint displacement), not just "longer than the chord"
|
||||
// (on a nearly horizontal bolt the chord dominates the extra length).
|
||||
const a = main[0];
|
||||
const b = main[main.length - 1];
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
let maxDev = 0;
|
||||
for (const p of main) {
|
||||
const t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / (len * len);
|
||||
maxDev = Math.max(maxDev, Math.hypot(p.x - (a.x + (b.x - a.x) * t), p.y - (a.y + (b.y - a.y) * t)));
|
||||
}
|
||||
return maxDev > 10; // a ruler line is 0; this is clearly jagged
|
||||
})());
|
||||
check('lightning bolt: branches fork from the trunk and stay near it', (() => {
|
||||
const { main, branches } = M.boltShape(9001, 1280, 720, { levels: 6, branches: 3, jitter: 0.16 });
|
||||
const inBox = (pts) => pts.every((p) => p.x > -1600 && p.x < 2880 && p.y > -800 && p.y < 1520);
|
||||
return branches.length === 3 && branches.every((br) => inBox(br) && inBox(main));
|
||||
})());
|
||||
check('lightning bolt: deterministic per seed, distinct across seeds', (() => {
|
||||
const A1 = M.boltShape(777, 1280, 720, { levels: 5, branches: 2 });
|
||||
const A2 = M.boltShape(777, 1280, 720, { levels: 5, branches: 2 });
|
||||
const B = M.boltShape(778, 1280, 720, { levels: 5, branches: 2 });
|
||||
const same = (x, y) => x.main.length === y.main.length && x.main.every((p, i) => p.x === y.main[i].x && p.y === y.main[i].y);
|
||||
return same(A1, A2) && !same(A1, B);
|
||||
})());
|
||||
check('hexToRgb01: parses #rrggbb and bad input falls to white', (() => {
|
||||
const w = M.hexToRgb01('#ffffff');
|
||||
const b = M.hexToRgb01('#0000ff');
|
||||
|
|
@ -256,10 +332,22 @@ check('the four live types wear sub-effect blocks; the rest render untouched', (
|
|||
const still = ['main', 'void'].every((t) => Object.keys(types[t].effect || {}).length === 0);
|
||||
return live && still;
|
||||
})());
|
||||
check('nebula: a steady ripple, nothing else', (() => {
|
||||
check('nebula: a steady ripple + OVERT far-sky lightning (gas-colored)', (() => {
|
||||
const e = types.nebula.effect;
|
||||
return !!e.ripple && num_(e.ripple.amplitude) && e.ripple.amplitude > 0 &&
|
||||
e.flare == null && e.grade == null && e.particles == null && e.wanderer == null;
|
||||
const L = e.lightning;
|
||||
return (
|
||||
!!e.ripple && num_(e.ripple.amplitude) && e.ripple.amplitude > 0 &&
|
||||
e.flare == null && e.grade == null && e.particles == null && e.wanderer == null &&
|
||||
!!L && L.enabled !== false &&
|
||||
Array.isArray(L.interval) && L.interval[0] >= 2 && L.interval[1] > L.interval[0] &&
|
||||
num_(L.strike) && L.strike >= 0.15 && L.strike <= 2 &&
|
||||
num_(L.levels) && L.levels >= 2 && L.levels <= 8 &&
|
||||
Array.isArray(L.branches) && L.branches[0] >= 0 && L.branches[1] >= L.branches[0] &&
|
||||
num_(L.glowWidth) && L.glowWidth > 0 && num_(L.coreWidth) && L.coreWidth > 0 &&
|
||||
num_(L.glowAlpha) && L.glowAlpha > 0 && num_(L.coreAlpha) && L.coreAlpha > 0 &&
|
||||
num_(L.parallax) && L.parallax >= 0 && L.parallax < 1 &&
|
||||
L.thunder !== false
|
||||
);
|
||||
})());
|
||||
check('redDwarf: a SUPER-subtle ripple that SURGES on a flare', (() => {
|
||||
const e = types.redDwarf.effect;
|
||||
|
|
|
|||
|
|
@ -1185,7 +1185,9 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
|||
untouched on purpose (plain is the control group — the universe must not
|
||||
all look special):
|
||||
- **nebula** — the RIPPLE (1 center, screen anchor): concentric rings,
|
||||
the light unsettled.
|
||||
the light unsettled — plus the OVERT far-sky LIGHTNING (a jagged
|
||||
edge-to-edge bolt with branches, in the gas's base/accent colors,
|
||||
strobed, soft distant thunder).
|
||||
- **redDwarf** — a SUPER-subtle RIPPLE (1 center, star anchor; the base
|
||||
shimmer is barely there) that SURGES on each FLARE — the flare is a
|
||||
seeded rhythm that spikes the ripple's amplitude AND flashes the grade
|
||||
|
|
@ -1237,16 +1239,23 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
|||
`storm` (habitable's purple storm → `js/visuals/StormLayer.js` — a SKY
|
||||
DOME: the clusters are anchored to the camera view with near-zero
|
||||
parallax (0.05–0.10) so they stay overhead while the world flies past,
|
||||
wrapping into a visible sky band).
|
||||
wrapping into a visible sky band), and
|
||||
`lightning` (the nebula's OVERT far-sky bolts →
|
||||
`js/visuals/NebulaLightning.js` — a jagged edge-to-edge trunk + random
|
||||
branches in the gas's base/accent colors (alternating per strike),
|
||||
strobed like real lightning, one soft distant thunder clap per strike;
|
||||
world Graphics at depth 2.9 — the SKY itself, behind the back gas, so
|
||||
the ripple displaces the bolt and the front gas veils it).
|
||||
- Files: `js/visuals/SystemEffects.js` (ripple + grade filters + facade),
|
||||
`js/visuals/SystemEffectsMath.js` (pure UV / flare / orbit / grade / lift /
|
||||
storm-envelope math), `js/visuals/UiCameras.js` (the UI camera split),
|
||||
`js/visuals/RedHaze.js` (the gritty red haze), `js/visuals/VoidBase.js`
|
||||
(the neutral void base), `js/visuals/EmberField.js` (ember dust +
|
||||
twinkle), `js/visuals/WandererStar.js` (companion star),
|
||||
storm-envelope / lightning-bolt math), `js/visuals/UiCameras.js` (the UI
|
||||
camera split), `js/visuals/RedHaze.js` (the gritty red haze),
|
||||
`js/visuals/VoidBase.js` (the neutral void base), `js/visuals/EmberField.js`
|
||||
(ember dust + twinkle), `js/visuals/WandererStar.js` (companion star),
|
||||
`js/visuals/StormLayer.js` (the habitable storm),
|
||||
`js/visuals/NebulaLightning.js` (the nebula's far-sky bolts),
|
||||
`js/galaxy/FxSystems.js` (demo system picker),
|
||||
`dev/system-effects.test.mjs` (66 checks, bare Node).
|
||||
`dev/system-effects.test.mjs` (77 checks, bare Node).
|
||||
- **How the filters draw (Phaser 4.2.1):** each filter is a
|
||||
`Phaser.Filters.Controller` on `cameras.main`'s `filters.internal` list —
|
||||
when any internal filter is active the camera pass auto-composites (FBO +
|
||||
|
|
@ -1325,6 +1334,22 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
|||
low-alpha; if it ever reads as fog over the controls, lower
|
||||
`front.alpha` first). `front.enabled: false` reverts to the single
|
||||
back layer (the pre-2-layer look).
|
||||
- **LIGHTNING** (depth 2.9/2.91 — the SKY itself, above the starfield,
|
||||
BEHIND the back gas so the gas tints/veils it, and the ripple
|
||||
displaces it as it flies): OVERT bolts that start on one end of the
|
||||
view and end on the other — a jagged trunk (seeded midpoint
|
||||
displacement) with 1–4 shorter branches — in the system's GAS colors,
|
||||
alternating base/accent per strike (the two-tone sky), a wide
|
||||
additive glow (the gas hue) over a thin near-white hot core. The
|
||||
cadence is seeded per system (content.fx): one strike every
|
||||
`interval` seconds (8–15s), a rapid STROBE (~0.45s) — real lightning
|
||||
flickers, it doesn't fade — and a soft distant thunder clap (the
|
||||
habitable's claps at 0.35 volume — this lightning is FAR). Stateless:
|
||||
nothing to pool; each frame the envelope decides dark vs. striking and
|
||||
the shape is derived from (strike ordinal, system seed) — stable
|
||||
during the strobe, different every strike, identical every run.
|
||||
`effect.lightning.enabled: false` (or deleting the block) reverts to
|
||||
the lightning-less nebula.
|
||||
- **Per-system colour (two-tone):** every system gets a stable base
|
||||
atmosphere color — one of the 8-shade palette, picked at random at
|
||||
galaxy generation (SystemGenerator, dedicated fork `(seed,'system',id,
|
||||
|
|
@ -1343,14 +1368,21 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
|||
tint. NORMAL blending (the default) so overlaps layer softly instead of
|
||||
ADD-ing into bright seams, and each puff swirls slowly on the scene
|
||||
clock (speed scaled by `drift`).
|
||||
- **Files:** `js/visuals/NebulaAtmosphere.js` (both layers + `drawCloud`),
|
||||
- **Files:** `js/visuals/NebulaAtmosphere.js` (both gas layers +
|
||||
`drawCloud`), `js/visuals/NebulaLightning.js` (the far-sky bolts),
|
||||
`js/visuals/SystemEffectsMath.js` (`boltStrobe` / `strikeCycle` /
|
||||
`boltShape` — the seeded lightning math, pure + Node-tested),
|
||||
`js/utils/Color.js` (`shiftHue` — the accent derivation),
|
||||
`data/game.json → nebula` (palette + back-layer `count/alpha/size/
|
||||
parallax/drift` + `front` block + `texture` tunables),
|
||||
`data/systems.json → types.nebula.effect.lightning` (the bolt's
|
||||
cadence/shape/look/thunder),
|
||||
`js/galaxy/SystemGenerator.js` (per-system base color),
|
||||
`js/scenes/GameScene.js` (create/update/destroy wiring),
|
||||
`dev/nebula.test.mjs` (36 checks, bare Node: palette contract, per-system
|
||||
color determinism, cloud texture, accent math, two-layer structure).
|
||||
`dev/nebula.test.mjs` (41 checks, bare Node: palette contract, per-system
|
||||
color determinism, cloud texture, accent math, two-layer structure,
|
||||
lightning structure), `dev/system-effects.test.mjs` (the lightning bolt
|
||||
math).
|
||||
- **Tuning is a JSON edit** (`data/game.json → nebula`): back layer via
|
||||
`alpha` (opacity — keep it a wash, not a wall), `count` (cloud sprites),
|
||||
`size` (cloud px), `parallax` (keep it slower than the starfield's
|
||||
|
|
@ -1363,7 +1395,10 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
|||
`texture` (noise `octaves/persistence`), `drift` (back-layer swirl).
|
||||
- **See it:** `dev/test-game.html?fx=nebula&seed=<seed>` drops you in a
|
||||
nebula (a different seed → a different two-tone gas) — fly around and
|
||||
judge the look.
|
||||
judge the look; the sky bolts in on its own seeded cadence (8–15s).
|
||||
Headless: `dev/smoke-game.mjs` + `?lightning=1&forceStrike=1` verifies
|
||||
the live scene owns a NebulaLightning and actually strokes a bolt
|
||||
(`window.__nlProbe`).
|
||||
|
||||
## Habitable storm — lightning in the upper sky (content, Canvas-safe)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { JumpGate } from '../entities/JumpGate.js';
|
|||
import { jumpArrival } from '../galaxy/JumpTravel.js';
|
||||
import { Starfield } from '../visuals/Starfield.js';
|
||||
import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js';
|
||||
import { NebulaLightning } from '../visuals/NebulaLightning.js';
|
||||
import { EmberField } from '../visuals/EmberField.js';
|
||||
import { WandererStar } from '../visuals/WandererStar.js';
|
||||
import { RedHaze } from '../visuals/RedHaze.js';
|
||||
|
|
@ -747,6 +748,15 @@ export class GameScene extends Phaser.Scene {
|
|||
this.wanderer = new WandererStar(this, _fx.wanderer.color, _fx.wanderer, this.systemContent?.fx ?? {});
|
||||
this.wanderer.create();
|
||||
}
|
||||
if (_fx?.lightning && _fx.lightning.enabled !== false) {
|
||||
// OVERT far-sky lightning (nebula): the bolt wears the system's GAS
|
||||
// colors — the base atmosphere + the derived accent (the front gas's
|
||||
// tint), alternating per strike. See js/visuals/NebulaLightning.js.
|
||||
const baseCol = this.systemContent?.atmosphere?.color ?? '#5fd4d0';
|
||||
const accentCol = typeof this.nebula?.frontTint === 'number' ? this.nebula.frontTint : baseCol;
|
||||
this.nebulaLightning = new NebulaLightning(this, baseCol, accentCol, _fx.lightning, this.systemContent?.fx ?? {});
|
||||
this.nebulaLightning.create();
|
||||
}
|
||||
|
||||
// The TETHER — the player's range. In the STARTING system it starts
|
||||
// as one level-1 tether anchored on the home world (data/tether.json):
|
||||
|
|
@ -2033,6 +2043,7 @@ export class GameScene extends Phaser.Scene {
|
|||
this.haze?.update(_time, delta); // redDwarf red haze: follow the camera
|
||||
this.voidBase?.update(_time, delta); // binary void base: follow the camera
|
||||
this.storm?.update(_time, delta); // habitable storm: parallax + charge/strike/afterglow
|
||||
this.nebulaLightning?.update(_time, delta); // nebula: the far-sky bolts (strobe + draw/clear)
|
||||
this.embers?.update(_time, delta); // redDwarf ember dust: drift + parallax
|
||||
this.wanderer?.update(_time, delta); // binary companion star: slow wander
|
||||
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
||||
|
|
@ -4996,6 +5007,8 @@ export class GameScene extends Phaser.Scene {
|
|||
this.embers = null;
|
||||
this.wanderer?.destroy();
|
||||
this.wanderer = null;
|
||||
this.nebulaLightning?.destroy();
|
||||
this.nebulaLightning = null;
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
|
|
@ -5008,6 +5021,7 @@ export class GameScene extends Phaser.Scene {
|
|||
this.storm?.destroy();
|
||||
this.embers?.destroy();
|
||||
this.wanderer?.destroy();
|
||||
this.nebulaLightning?.destroy();
|
||||
this.compass?.destroy();
|
||||
this.actionBar?.destroy();
|
||||
this.menuSubBar?.destroy();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -173,6 +173,167 @@ export function stormEnvelope(nowMs, intervalSec, strikeDurSec, phaseSec = 0, ch
|
|||
return { charge, flash };
|
||||
}
|
||||
|
||||
/**
|
||||
* A seeded 32-bit PRNG (mulberry32) — a deterministic float sequence
|
||||
* (0..1) from a uint32 seed. The bolt shapes use it, so the same
|
||||
* (seed, size) always yields the same lightning (Node-testable, and
|
||||
* stable across lazy vs. eager scene builds).
|
||||
*
|
||||
* @param {number} seedNum any number (mixed to uint32)
|
||||
* @returns {() => number} next-draw function
|
||||
*/
|
||||
export function seededRandom(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 ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NEBULA LIGHTNING — the far-sky bolts (pure; drawn by NebulaLightning.js).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Which STRIKE is in progress at a moment: an integer that increments at
|
||||
* each cycle boundary. Mixing it into the shape seed gives every strike a
|
||||
* DIFFERENT but fully deterministic bolt — strike N of a system is always
|
||||
* the same bolt, every run.
|
||||
*
|
||||
* @param {number} nowMs game clock (ms)
|
||||
* @param {number} intervalSec seconds per strike cycle
|
||||
* @param {number} [phaseSec=0] per-system phase offset (seconds)
|
||||
* @returns {number} integer strike ordinal (can be negative before t=0)
|
||||
*/
|
||||
export function strikeCycle(nowMs, intervalSec, phaseSec = 0) {
|
||||
const period = Math.max(2, Number(intervalSec) || 12);
|
||||
const t = nowMs / 1000 - (Number(phaseSec) || 0);
|
||||
return Math.floor(t / period);
|
||||
}
|
||||
|
||||
/**
|
||||
* The nebula lightning's STROBE envelope at a moment: 0 (dark sky) .. 1
|
||||
* (peak), mostly 0 with a rapid flicker — the same strobe the habitable
|
||||
* storm uses (a bright primary burst + two decaying rebounds + a soft
|
||||
* afterglow), minus the charge (there are no clouds to gather it):
|
||||
* real lightning STROBES, it does not fade in and out.
|
||||
*
|
||||
* The strike sits at the cycle boundary (t ≡ phase, mod period).
|
||||
* Deterministic (no Math.random), so the same system strikes the same way
|
||||
* every run.
|
||||
*
|
||||
* @param {number} nowMs game clock (ms)
|
||||
* @param {number} intervalSec seconds per strike cycle
|
||||
* @param {number} strikeDurSec the strobe's span (seconds)
|
||||
* @param {number} [phaseSec=0] per-system phase offset (seconds)
|
||||
* @returns {number} 0..1
|
||||
*/
|
||||
export function boltStrobe(nowMs, intervalSec, strikeDurSec, phaseSec = 0) {
|
||||
const period = Math.max(2, Number(intervalSec) || 12);
|
||||
const W = Math.max(0.15, Number(strikeDurSec) || 0.5);
|
||||
const t = nowMs / 1000 - (Number(phaseSec) || 0);
|
||||
const tLocal = ((t % period) + period) % period; // 0..period; strike at tLocal === 0 (≡ period)
|
||||
// Time relative to the strike (negative = before it); the afterglow is
|
||||
// clipped at 3× the strobe span (negligible there — a hard 0 keeps the
|
||||
// envelope exact instead of an asymptote).
|
||||
const dt = tLocal <= period / 2 ? tLocal : tLocal - period; // (-period/2, period/2]
|
||||
if (dt < 0 || dt > 3 * W) return 0;
|
||||
const gauss = (x, sigma) => Math.exp(-(x * x) / (2 * sigma * sigma));
|
||||
const b1 = 1.0 * gauss(dt, 0.12 * W);
|
||||
const b2 = 0.55 * gauss(dt - 0.45 * W, 0.1 * W);
|
||||
const b3 = 0.35 * gauss(dt - 0.85 * W, 0.09 * W);
|
||||
const afterglow = 0.22 * Math.exp(-dt / (0.9 * W));
|
||||
return Math.min(1, b1 + b2 + b3 + afterglow);
|
||||
}
|
||||
|
||||
/**
|
||||
* MIDPOINT DISPLACEMENT — split each segment, push its midpoint off-axis
|
||||
* by a random amount, halve the displacement, recurse. The classic
|
||||
* fractal bolt: a few segments in, a jagged filament out.
|
||||
*
|
||||
* @param {{x:number,y:number}} a start point
|
||||
* @param {{x:number,y:number}} b end point
|
||||
* @param {number} amp level-0 displacement (px, perpendicular jitter budget)
|
||||
* @param {number} levels recursion depth (2..8)
|
||||
* @param {() => number} rand seeded next-draw
|
||||
* @returns {{x:number,y:number}[]}
|
||||
*/
|
||||
function displace(a, b, amp, levels, rand) {
|
||||
let pts = [a, b];
|
||||
let cur = Math.max(0, amp);
|
||||
for (let l = 0; l < levels; l++) {
|
||||
const next = [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p = pts[i];
|
||||
const q = pts[i + 1];
|
||||
next.push(p, {
|
||||
x: (p.x + q.x) / 2 + (rand() - 0.5) * cur,
|
||||
y: (p.y + q.y) / 2 + (rand() - 0.5) * cur,
|
||||
});
|
||||
}
|
||||
next.push(pts[pts.length - 1]);
|
||||
pts = next;
|
||||
cur *= 0.5;
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* One lightning bolt, in VIEW space (px, 0,0 = top-left of the view):
|
||||
* a jagged main trunk from one view EDGE to the opposite ("starts on one
|
||||
* end and ends on the other"), plus a few shorter BRANCHES forking off
|
||||
* the trunk at random points and heading off-axis.
|
||||
*
|
||||
* Deterministic from (seed, w, h, opts) — same inputs, same bolt. Pure
|
||||
* (no Phaser), so the dev harness can exercise it in bare Node.
|
||||
*
|
||||
* @param {number} seed uint32 (mix in the strike ordinal for variety)
|
||||
* @param {number} w view width (px)
|
||||
* @param {number} h view height (px)
|
||||
* @param {{levels?:number, branches?:number, jitter?:number}} [opts]
|
||||
* levels — recursion depth (2..8, default 6); branches — branch count
|
||||
* (default 3); jitter — trunk displacement as a fraction of view height
|
||||
* (default 0.16)
|
||||
* @returns {{ main: {x:number,y:number}[], branches: {x:number,y:number}[][] }}
|
||||
*/
|
||||
export function boltShape(seed, w, h, opts = {}) {
|
||||
const rand = seededRandom(seed);
|
||||
const levels = Math.max(2, Math.min(8, Math.round(opts.levels ?? 6)));
|
||||
const jitter = Math.max(0, Number(opts.jitter) || 0.16);
|
||||
const amp = jitter * h; // the trunk's perpendicular wander budget
|
||||
|
||||
// Edge to edge: one endpoint on each horizontal edge (just outside the
|
||||
// view, so the bolt runs the FULL width), random height at each end —
|
||||
// a diagonal streak, direction random.
|
||||
const dir = rand() < 0.5 ? 1 : -1;
|
||||
const over = 0.05 * w;
|
||||
const yA = h * (0.08 + 0.84 * rand());
|
||||
const yB = h * (0.08 + 0.84 * rand());
|
||||
const a = dir > 0 ? { x: -over, y: yA } : { x: w + over, y: yA };
|
||||
const b = dir > 0 ? { x: w + over, y: yB } : { x: -over, y: yB };
|
||||
const main = displace(a, b, amp, levels, rand);
|
||||
|
||||
const nBranch = Math.max(0, Math.round(opts.branches ?? 3));
|
||||
const branches = [];
|
||||
for (let i = 0; i < nBranch; i++) {
|
||||
// Fork from a random INTERIOR point of the trunk (never the tips),
|
||||
// heading off the trunk's axis by a seeded angle, at a fraction of
|
||||
// the trunk's length.
|
||||
const pi = 1 + Math.floor(rand() * (main.length - 2));
|
||||
const p0 = main[pi];
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const off = (rand() < 0.5 ? 1 : -1) * (0.6 + 0.9 * rand());
|
||||
const ang = Math.atan2(b.y - a.y, b.x - a.x) + off;
|
||||
const blen = len * (0.12 + 0.3 * rand());
|
||||
const p1 = { x: p0.x + Math.cos(ang) * blen, y: p0.y + Math.sin(ang) * blen };
|
||||
branches.push(displace(p0, p1, amp * 0.55, Math.max(2, levels - 2), rand));
|
||||
}
|
||||
return { main, branches };
|
||||
}
|
||||
|
||||
// --- Color grade (pure; the shader does the same thing per-pixel) ---------
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue