From 8e035d08912a18846772177ad0afe032918eab90 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 10 Sep 2026 23:11:21 -0600 Subject: [PATCH] Add far-sky lightning bolts to nebula systems - New NebulaLightning visual: jagged edge-to-edge bolt with branches, strobed in the gas base/accent colors, plus a soft distant thunder clap - Pure seeded math (boltStrobe, strikeCycle, boltShape) in SystemEffectsMath, Node-tested for determinism and shape correctness - Wired into GameScene create/update/destroy lifecycle - Config block in data/systems.json types.nebula.effect.lightning; docs updated with tuning notes and smoke-game probe (?lightning=1&forceStrike=1) --- data/systems.json | 17 ++- dev/nebula.test.mjs | 77 ++++++++++- dev/smoke-game.mjs | 60 +++++++++ dev/system-effects.test.mjs | 96 ++++++++++++- docs/PROJECT_NOTES.md | 57 ++++++-- js/scenes/GameScene.js | 14 ++ js/visuals/NebulaLightning.js | 231 ++++++++++++++++++++++++++++++++ js/visuals/SystemEffectsMath.js | 161 ++++++++++++++++++++++ 8 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 js/visuals/NebulaLightning.js diff --git a/data/systems.json b/data/systems.json index 4e149b8..c40e9db 100644 --- a/data/systems.json +++ b/data/systems.json @@ -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": { diff --git a/dev/nebula.test.mjs b/dev/nebula.test.mjs index 51b31a1..4ede6c9 100644 --- a/dev/nebula.test.mjs +++ b/dev/nebula.test.mjs @@ -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,55 @@ 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]); +})()); + +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); diff --git a/dev/smoke-game.mjs b/dev/smoke-game.mjs index b6c1463..7e69fd6 100644 --- a/dev/smoke-game.mjs +++ b/dev/smoke-game.mjs @@ -23,6 +23,12 @@ * ?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(() => { diff --git a/dev/system-effects.test.mjs b/dev/system-effects.test.mjs index 173eabb..e796958 100644 --- a/dev/system-effects.test.mjs +++ b/dev/system-effects.test.mjs @@ -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; diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 373fd04..d6fbfe8 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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=` 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) diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 1d66503..930eefb 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(); diff --git a/js/visuals/NebulaLightning.js b/js/visuals/NebulaLightning.js new file mode 100644 index 0000000..2a177c8 --- /dev/null +++ b/js/visuals/NebulaLightning.js @@ -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; + } + this.wasOn = true; + + // THE STRIKE (data/sfx.json → thunder_01…04, soft): the bolt going + // off is the strobe's 0 → >0 edge (this.wasOn is reset on the dark + // frames above). 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; + + // 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; +} diff --git a/js/visuals/SystemEffectsMath.js b/js/visuals/SystemEffectsMath.js index caacff9..bdf09bb 100644 --- a/js/visuals/SystemEffectsMath.js +++ b/js/visuals/SystemEffectsMath.js @@ -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) --------- /**