Add habitable storm visual effect and ice landing videos

- New StormLayer renders purple cloud clusters with a seeded charge → strike → afterglow envelope in the upper sky, using sprite compositing (back/flash/front trios) rather than shaders
- Add `stormEnvelope` + `sstep01` to SystemEffectsMath.js; refactor NebulaAtmosphere's cloud shape into a shared `cloudAlpha` used by both `drawCloud` and the storm's flash texture
- Extend EmberField with optional per-spark twinkle (rate/depth) so habitable fireflies pulse while redDwarf embers stay steady
- Wire StormLayer create/update/destroy into GameScene alongside the other content effects
- Populate `types.habitable.effect` in systems.json with grade, void, particles, and storm blocks; update landing.json to use ice land/takeoff videos for slots 02 and 03
- Add 7 new test checks covering the storm envelope math, habitable data contract, bundle sanity, and facade apply(); bump live types from three to four
This commit is contained in:
Brian Fertig 2026-09-08 21:46:15 -06:00
parent 729ab09d3e
commit 6dc0e99dee
11 changed files with 589 additions and 38 deletions

Binary file not shown.

Binary file not shown.

View File

@ -64,8 +64,8 @@
{ "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
{ "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" },
{ "land": "ice-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "rocky-shop-01.mp4" },
{ "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
{ "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" }
{ "land": "ice-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "ice-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" }
],
"stationVideos": [

View File

@ -91,7 +91,22 @@
"description": "Temperate, well-lit, and quietly crowded with life. Rare.",
"theme": { "color": "#7ce8a4" },
"distribution": { "weight": 10 },
"effect": {},
"effect": {
"grade": { "tint": "#bfe8c8", "amount": 0.10, "saturation": 1.05, "brightness": 1.02, "lift": { "amount": 0.14, "color": "#7ce8a4" } },
"void": { "base": "#0a1210", "alpha": 1.0, "grain": 5, "blobs": 5, "scale": [0.2, 0.5] },
"particles": { "count": 24, "size": [2, 4], "alpha": [0.35, 0.8], "parallax": [0.2, 0.45], "speed": [4, 12], "color": "#cdeb8b", "blend": "add", "twinkle": { "rate": [0.4, 1.1], "depth": [0.4, 0.85] } },
"storm": {
"clusters": 3,
"back": "#6b5fa8",
"front": "#191430",
"flash": "#cfd6ff",
"size": [420, 640],
"parallax": [0.05, 0.10],
"alpha": { "back": [0.30, 0.42], "front": [0.38, 0.50] },
"cycle": { "interval": [10, 16], "charge": 0.45, "flicker": 0.55 },
"flashStrength": 0.9
}
},
"attributes": {
"star": {
"classes": { "G": 70, "K": 30 },

View File

@ -12,10 +12,13 @@
* (brightness saturation tint split flash) is exact and
* identity when the grade is empty;
* - the DATA CONTRACT (data/systems.json): every type carries an
* `effect` block; nebula/redDwarf/binary are live (kind "ripple");
* `effect` block; nebula/redDwarf/binary/habitable are live;
* the redDwarf bundle has a flare + grade + particles; the binary
* bundle has two centers + orbit + a split grade + a wanderer; padding
* covers the worst-case displacement (amplitude × flare boost ×
* bundle has a split grade + a two-light lift + a breath + a wanderer;
* the habitable bundle has a green grade + void + twinkling fireflies +
* 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);
* - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the
* requested type wins, ties fall to roster order, missing type => null;
@ -133,6 +136,47 @@ check('orbit: advancing time moves the pair (a full lap per period)', (() => {
(approx(t36.cx0, t0.cx0, 1e-6) && approx(t36.cy0, t0.cy0, 1e-6))
);
})());
// --- The habitable storm envelope (charge → strike → afterglow) -----------
check('storm: quiet mid-cycle (no flash, charge settled)', (() => {
// 25% into a 12s cycle — before the charge window (last 45%) begins
const e = M.stormEnvelope(3000, 12, 0.55, 0, 0.45);
return e.flash === 0 && e.charge === 0;
})());
check('storm: the charge rises smoothly to 1 at the strike', (() => {
const at = (frac) => M.stormEnvelope(frac * 12 * 1000, 12, 0.55, 0, 0.45).charge;
const cEarly = at(0.5); // before the charge window (1-0.45=0.55)
const cMid = at(0.75); // mid-rise
const cLate = at(0.95); // near the strike
const cPeak = at(0.999); // just before the strike (the light is about to be released)
const cAfter = M.stormEnvelope(100, 12, 0.55, 0, 0.45).charge; // just after — spent
return cEarly === 0 && cMid > cEarly && cLate > cMid && cPeak > 0.99 && cAfter === 0;
})());
check('storm: the strike flashes ~1, then strobes (a rebound burst) and decays', (() => {
const W = 0.55;
const at = (dtSec) => M.stormEnvelope(12 * 1000 + dtSec * 1000, 12, W, 0, 0.45).flash;
return (
at(0) > 0.95 && // the primary burst
at(0.45 * W) > 0.4 && // the rebound burst is clearly present
at(2 * W) < 0.1 && // the afterglow has mostly faded
at(4 * W) < 0.03 && // …and is gone
M.stormEnvelope(12 * 1000 - 1000, 12, W, 0, 0.45).flash === 0 // nothing BEFORE the strike
);
})());
check('storm: bounded, deterministic, phase-shifted, no NaN across a sweep', (() => {
const sweep = () => {
for (let ms = 0; ms < 2 * 12 * 1000; ms += 7) {
const e = M.stormEnvelope(ms, 12, 0.55, 3.7, 0.45);
if (!Number.isFinite(e.charge) || !Number.isFinite(e.flash)) return false;
if (e.charge < 0 || e.charge > 1 || e.flash < 0 || e.flash > 1) return false;
}
return true;
};
const a = M.stormEnvelope(1000, 12, 0.55, 1.0, 0.45);
const b = M.stormEnvelope(1000, 12, 0.55, 1.0, 0.45);
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);
})());
check('hexToRgb01: parses #rrggbb and bad input falls to white', (() => {
const w = M.hexToRgb01('#ffffff');
const b = M.hexToRgb01('#0000ff');
@ -203,13 +247,13 @@ check('grade: the shadow lift integrates into gradeColor (red void)', (() => {
const types = config.section('systems.types', {});
const typeIds = Object.keys(types);
const LIVE = ['nebula', 'redDwarf', 'binary'];
const LIVE = ['nebula', 'redDwarf', 'binary', 'habitable'];
const num_ = (v) => typeof v === 'number' && Number.isFinite(v);
check('all six archetypes are present', typeIds.length === 6 && ['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void'].every((t) => typeIds.includes(t)));
check('every type carries an effect block (data-driven by rule)', typeIds.every((t) => types[t].effect && typeof types[t].effect === 'object'));
check('the three live types wear sub-effect blocks; the rest render untouched', (() => {
check('the four live types wear sub-effect blocks; the rest render untouched', (() => {
const live = LIVE.every((t) => Object.keys(types[t].effect).length > 0);
const still = ['main', 'habitable', 'void'].every((t) => Object.keys(types[t].effect || {}).length === 0);
const still = ['main', 'void'].every((t) => Object.keys(types[t].effect || {}).length === 0);
return live && still;
})());
check('nebula: a steady ripple, nothing else', (() => {
@ -241,6 +285,18 @@ check('binary: a two-color split grade + a two-light void lift + a breath + a wa
Array.isArray(w.coreSize) && Array.isArray(w.haloSize)
);
})());
check('habitable: a lush grade + a green void + twinkling fireflies + a purple storm, and NO ripple', (() => {
const e = types.habitable.effect;
return (
e.ripple == null &&
!!e.grade && typeof e.grade.tint === 'string' && e.grade.lift?.color === '#7ce8a4' && // a green-tinged well-lit wash
e.grade.split == null && e.breath == null && // the storm breathes instead
typeof e.void?.base === 'string' && // the forest-night dark
!!e.particles && typeof e.particles.color === 'string' && !!e.particles.twinkle && // the fireflies (they pulse)
!!e.storm && typeof e.storm.back === 'string' && typeof e.storm.front === 'string' && // the purple storm
typeof e.storm.flash === 'string' && e.storm.back !== e.storm.front
);
})());
check('every ripple bundle has sane numbers + padding covering the worst case', (() => {
const maxDim = Math.max(1280, 720);
return ['nebula', 'redDwarf'].every((t) => {
@ -292,6 +348,28 @@ check('binary bundle: a two-color split grade + a two-light void glow + a slow b
Array.isArray(w.coreSize) && w.coreSize[1] >= 80 && Array.isArray(w.haloSize) && w.haloSize[1] >= 250 // present, not a smudge
);
})());
check('habitable bundle: a gentle well-lit grade, a forest-night void, pulsing fireflies, and a storm that strikes', (() => {
const e = types.habitable.effect;
const g = e.grade ?? {};
const p = e.particles ?? {};
const st = e.storm ?? {};
const n = (v) => typeof v === 'number' && Number.isFinite(v);
return (
n(g.amount) && g.amount > 0 && g.amount < 0.2 && // a wash, not a wall
n(g.brightness) && g.brightness > 1 && n(g.saturation) && g.saturation > 1 && // the only type that gets more light
n(g.lift?.amount) && g.lift.amount > 0 && typeof g.lift.color === 'string' &&
typeof e.void?.base === 'string' && n(e.void?.grain) && e.void.grain >= 0 &&
n(p.count) && p.count >= 10 && typeof p.color === 'string' && p.blend === 'add' &&
Array.isArray(p.twinkle?.rate) && p.twinkle.rate[1] < 2 && // a slow blink, not a strobe
Array.isArray(p.twinkle?.depth) && p.twinkle.depth[1] < 1 &&
n(st.clusters) && st.clusters >= 2 &&
Array.isArray(st.size) && st.size[0] >= 300 && // real clouds, not mist
Array.isArray(st.cycle?.interval) && st.cycle.interval[0] >= 6 && // a patient storm
n(st.cycle?.flicker) && st.cycle.flicker > 0 &&
n(st.cycle?.charge) && st.cycle.charge > 0 && st.cycle.charge < 1 &&
n(st.flashStrength) && st.flashStrength > 0 && st.flashStrength <= 1
);
})());
// --- The demo picker (js/galaxy/FxSystems.js) -------------------------------
@ -476,6 +554,22 @@ check('facade: update() breathes the binary lights over time', (() => {
fx.update((grade.breathPeriod * 1000) / 4);
return grade.uBreath !== b0 && Math.abs(grade.uBreath) <= grade.breathDepth;
})());
check('facade: apply(habitable) → a well-lit GREEN grade (single-hue lift, no split, no breath, no ripple)', (() => {
fakeCamMain.filters.internal.list.length = 0;
const active = fx.apply('habitable');
const list = fakeCamMain.filters.internal.list;
const ripple = list.find((c) => c.renderNode === SE.RIPPLE_NODE);
const grade = list.find((c) => c.renderNode === SE.GRADE_NODE);
return (
active === true && fx.kind === 'grade' && list.length === 1 &&
!ripple &&
grade && grade.uTintAmt > 0 && grade.uBright > 1 && grade.uSat > 1 && // well-lit + lush
grade.uLiftAmt > 0 && // the green shadow lift
grade.uLiftG > grade.uLiftR && grade.uLiftG > grade.uLiftB && // it is green
grade.uLift2R === grade.uLiftR && grade.uLift2G === grade.uLiftG && grade.uLift2B === grade.uLiftB && // single-hue (no second light)
(grade.uSplitMix === 0 || grade.uSplitMix === undefined) && grade.breathPeriod === 0 && grade.uBreath === 0
);
})());
check('facade: apply() twice replaces (no duplicate filters)', (() => {
fx.apply('redDwarf');
return fakeCamMain.filters.internal.list.length === 2;

View File

@ -1112,7 +1112,9 @@ The player holds a REPUTATION (standing) on each planet and space station:
- A system's visual character is a **bundle of sub-effects** configured per
archetype in `data/systems.json → types.<type>.effect`. Each sub-effect is
an OPTIONAL block; a type wears exactly what it lists (an empty `{}` = no
effect). Three archetypes are live today; the rest render untouched:
effect). Four archetypes are live today; `main` and `void` render
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.
- **redDwarf** — a SUPER-subtle RIPPLE (1 center, star anchor; the base
@ -1128,6 +1130,15 @@ The player holds a REPUTATION (standing) on each planet and space station:
wandering companion star (content). NO ripple — two stars, one steady
light. The two-color void needs a neutral content base to paint on (the
clear color bypasses the filter) — see `void` below.
- **habitable** — an ELECTRIFIED STORM in the upper sky (23 purple
cloud clusters, each a back/flash/front trio; per-cluster lightning
strikes on a seeded cycle — charge rises, the core ignites, a rebound
flicker, a decaying afterglow; the strike's 3D is pure sprite compositing
— see the storm section below) + a FOREST-NIGHT void base (deep
green-black, so the darkness feels vegetative) + twinkling FIREFLIES
(slow green motes with a `twinkle` pulse — the ember field's optional
twinkle) + a near-neutral GREEN grade (the storm owns the purple; the
green lives in the fireflies and the void, not a tint over everything).
The star itself is invisible flavor; the shimmer / glow / wander is its
character.
- Two kinds of sub-effect:
@ -1146,19 +1157,27 @@ The player holds a REPUTATION (standing) on each planet and space station:
both filters read, so the wave and the light flash as one.
- **Content** (world-space sprites, Canvas-safe, behind the art):
`haze` (redDwarf red haze over the void → `js/visuals/RedHaze.js`, depth
1, BEHIND the stars), `void` (binary's NEUTRAL dark+grit void base →
`js/visuals/VoidBase.js`, depth 1 — the surface the two-light lift and
breath paint on, since the clear color bypasses the filter),
`particles` (redDwarf ember dust → `js/visuals/EmberField.js`), and
`wanderer` (binary companion star → `js/visuals/WandererStar.js`).
1, BEHIND the stars), `void` (binary's NEUTRAL dark+grit void base /
habitable's FOREST-NIGHT green-black base → `js/visuals/VoidBase.js`,
depth 1 — the surface the lift/haze paint on, since the clear color
bypasses the filter),
`particles` (redDwarf ember dust / habitable twinkling fireflies →
`js/visuals/EmberField.js`; the optional `twinkle: {rate,depth}` gives
each mote a slow sine pulse, and no twinkle config = steady embers),
`wanderer` (binary companion star → `js/visuals/WandererStar.js`), and
`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.050.10) so they stay overhead while the world flies past,
wrapping into a visible sky band).
- Files: `js/visuals/SystemEffects.js` (ripple + grade filters + facade),
`js/visuals/SystemEffectsMath.js` (pure UV / flare / orbit / grade / lift
math), `js/visuals/UiCameras.js` (the UI camera split),
`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 binary's neutral void base), `js/visuals/EmberField.js` (ember dust),
`js/visuals/WandererStar.js` (companion star),
(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/galaxy/FxSystems.js` (demo system picker),
`dev/system-effects.test.mjs` (59 checks, bare Node).
`dev/system-effects.test.mjs` (66 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 +
@ -1261,6 +1280,55 @@ The player holds a REPUTATION (standing) on each planet and space station:
nebula (a different seed → a different gas color) — fly around and
judge the look.
## Habitable storm — lightning in the upper sky (content, Canvas-safe)
- The habitable's signature: a few big PURPLE CLOUD CLUSTERS overhead that
periodically LIGHT UP — charge builds (the clouds brighten over the last
~45% of the cycle), the core ignites in a white-lavender FLASH with a
quick rebound flicker, then the afterglow decays. Different clusters
strike on different seeds, so the sky is never a synchronized strobe —
it's a weather system. The narrative is MillerUrey: life started in
lightning.
- **The 3D is sprite compositing, not shaders.** Each cluster is three
sprites at depths 3.0/3.1/3.2 (above the starfield, below planets/ship):
- a BACK cloud (violet, soft — the light source seen through),
- a FLASH (additive, cloud-shaped with a HOT RADIAL CORE — `drawFlash`
= the shared `cloudAlpha` fbm shape a bright core, so a strike reads
as the cloud igniting from within),
- a FRONT cloud (dark indigo, fbm holes) that silhouettes over the flash —
the light finds the gaps and wraps the cloud's edge (the additive bleed
past the dark rim). The offsets are loose enough that the core survives
the overlap; the density does the rest.
- **The storm is a SKY DOME, not a world-space object.** The clusters are
anchored to the camera view (view-space home + tiny drift + wrap into a
visible sky band, the same model as the wanderer star and starfield) with
near-zero parallax (0.050.10): they stay overhead while the world flies
past. (A first cut used wide world-space margins like the starfield and
the clouds drifted off-screen with 3 big sprites — the sky-anchor is the
fix.)
- **The envelope is pure + tested:** `stormEnvelope(now, interval, strike,
phase, chargeWindow)` in `SystemEffectsMath.js``{charge, flash}`
(both 0..1). Charge = a smoothstep ramp in the last `chargeWindow`
fraction of the cycle (resets at the strike); flash = a Gaussian primary
burst + rebound + third crack + an exponential afterglow, clipped to
three strike-durations so the sky is fully quiet between storms.
- **Alpha model:** back = base × (0.7 + 0.5·charge + 0.3·flash) (the build
is visible); flash = env.flash × flashStrength (ADD); front = base ×
(0.8 + 0.3·charge) (it darkens as the charge builds).
- **Files:** `js/visuals/StormLayer.js` (clusters, sky anchor, per-frame
envelope → alphas), `js/visuals/SystemEffectsMath.js` (`stormEnvelope`,
`sstep01`), `js/visuals/NebulaAtmosphere.js` (`cloudAlpha` + `drawCloud`
— the shared cloud shape; `drawFlash` in StormLayer adds the hot core),
`data/systems.json → types.habitable.effect` (the whole package),
`js/scenes/GameScene.js` (create/update/destroy wiring).
- **Tuning is a JSON edit** (`types.habitable.effect.storm`): `clusters`,
`back/front/flash` colors, `size`, `parallax` (keep it sky-slow), `alpha`
(keep the clouds a presence, not a ceiling), `cycle.interval` (calm
weather = 1016 s), `cycle.charge` (how long the build lasts),
`cycle.flicker` (strike + rebound length), `flashStrength`.
- **See it:** `dev/test-game.html?fx=habitable&seed=<seed>` — fly for a
minute; strikes land every ~1016 s per cluster, staggered.
## Roadmap (working list, intentionally rough)
- [x] v0.1 foundation — menu → New Game → click-to-fly ship

View File

@ -23,6 +23,7 @@ import { EmberField } from '../visuals/EmberField.js';
import { WandererStar } from '../visuals/WandererStar.js';
import { RedHaze } from '../visuals/RedHaze.js';
import { VoidBase } from '../visuals/VoidBase.js';
import { StormLayer } from '../visuals/StormLayer.js';
import { SystemEffects } from '../visuals/SystemEffects.js';
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
import { pickFxSystem } from '../galaxy/FxSystems.js';
@ -632,6 +633,10 @@ export class GameScene extends Phaser.Scene {
this.voidBase = new VoidBase(this, _fx.void, this.systemContent?.fx ?? {});
this.voidBase.create();
}
if (_fx?.storm) {
this.storm = new StormLayer(this, _fx.storm, this.systemContent?.fx ?? {});
this.storm.create();
}
if (_fx?.particles) {
this.embers = new EmberField(this, _fx.particles.color, _fx.particles, this.systemContent?.fx ?? {});
this.embers.create();
@ -1696,6 +1701,7 @@ export class GameScene extends Phaser.Scene {
this.nebula?.update(); // the gas: parallax + slow self-swirl
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.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
@ -4308,6 +4314,7 @@ export class GameScene extends Phaser.Scene {
this.nebula?.destroy();
this.haze?.destroy();
this.voidBase?.destroy();
this.storm?.destroy();
this.embers?.destroy();
this.wanderer?.destroy();
this.compass?.destroy();

View File

@ -71,8 +71,14 @@ export class EmberField {
alpha = [0.4, 0.9],
parallax = [0.25, 0.5],
speed = [8, 25],
twinkle = null,
} = this.cfg;
// Optional twinkle (the habitable fireflies): each spark's alpha pulses
// on its own phase — depth 0 = steady (redDwarf embers, unchanged).
const twRate = twinkle?.rate ?? null;
const twDepth = twinkle?.depth ?? null;
const texKey = canvasTexture(scene, 'fx-ember-spark', 32, 32, (ctx) => drawSpark(ctx, 32));
const w = scene.scale.width;
const h = scene.scale.height;
@ -99,11 +105,12 @@ export class EmberField {
const by = top + rand() * (h + 2 * MARGIN);
const ang = rand() * Math.PI * 2;
const spd = range(speed[0], speed[1]);
const baseAlpha = range(alpha[0], alpha[1]);
const sp = scene.add
.image(bx, by, texKey)
.setDepth(DEPTH)
.setTint(this.color)
.setAlpha(range(alpha[0], alpha[1]))
.setAlpha(baseAlpha)
.setBlendMode(Phaser.BlendModes.ADD);
const sz = range(size[0], size[1]);
sp.setScale(sz / 16); // 32px texture, scale to ~size px
@ -112,6 +119,10 @@ export class EmberField {
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
p: range(parallax[0], parallax[1]),
baseAlpha,
twRate: twRate ? range(twRate[0], twRate[1]) : 0,
twDepth: twDepth ? range(twDepth[0], twDepth[1]) : 0,
twPhase: rand() * Math.PI * 2,
});
}
this.lastScrollX = cam.scrollX;
@ -143,6 +154,13 @@ export class EmberField {
// Keep the field full: wrap into the current camera window.
e.sp.x = wrapIn(e.sp.x, left, spanX);
e.sp.y = wrapIn(e.sp.y, top, spanY);
// Twinkle (optional): the alpha pulses on its own phase — a firefly
// blinking in the grass, not a steady spark (depth 0 = steady).
if (e.twDepth > 0) {
const t = (nowMs || 0) / 1000;
const wave = 0.5 * (1 + Math.sin(t * Math.PI * 2 * e.twRate + e.twPhase));
e.sp.alpha = e.baseAlpha * (1 - e.twDepth + e.twDepth * wave);
}
}
}

View File

@ -163,21 +163,17 @@ export class NebulaAtmosphere {
// ---------------------------------------------------------------------------
/**
* Paint one soft cloud into a square canvas: value-noise fbm for the
* internal structure, multiplied by a wide RADIAL falloff so the puff has
* no perceptible silhouette it is fully transparent by the inscribed
* circle and dissolves gradually out, so no border or corner reads as a
* hard edge. White RGB; the alpha channel carries the density (the
* sprite's tint sets the color).
* Per-pixel cloud ALPHA (0..1) for a w×h canvas: value-noise fbm for the
* internal structure, multiplied by a wide radial falloff so the puff has
* no perceptible silhouette fully transparent by the inscribed circle,
* dissolving gently out. (The single source of the cloud shape; both
* drawCloud and the storm's flash use it.)
*/
export function drawCloud(ctx, w, h, tex, seed = 1337) {
export function cloudAlpha(w, h, tex, seed = 1337) {
const octaves = Math.max(1, Math.round(tex.octaves ?? 4));
const persistence = clamp01(tex.persistence ?? 0.55);
const img = ctx.createImageData(w, h);
const d = img.data;
const FREQ = 6; // base noise cells across the sprite (fewer, larger = smoother gas)
const out = new Float32Array(w * h);
for (let y = 0; y < h; y++) {
const v = y / h;
for (let x = 0; x < w; x++) {
@ -204,14 +200,27 @@ export function drawCloud(ctx, w, h, tex, seed = 1337) {
// Gentle internal structure (fbm) — wispy density, not speckled dust.
const structure = smoothstep(0.30, 0.80, n);
const a = radial * structure;
const idx = (y * w + x) * 4;
d[idx] = 255;
d[idx + 1] = 255;
d[idx + 2] = 255;
d[idx + 3] = Math.round(a * 255);
out[y * w + x] = radial * structure;
}
}
return out;
}
/**
* Paint one soft cloud into a square canvas (see cloudAlpha). White RGB;
* the alpha channel carries the density (the sprite's tint sets the color).
*/
export function drawCloud(ctx, w, h, tex, seed = 1337) {
const a = cloudAlpha(w, h, tex, seed);
const img = ctx.createImageData(w, h);
const d = img.data;
for (let i = 0; i < w * h; i++) {
const idx = i * 4;
d[idx] = 255;
d[idx + 1] = 255;
d[idx + 2] = 255;
d[idx + 3] = Math.round(a[i] * 255);
}
ctx.putImageData(img, 0, 0);
}

284
js/visuals/StormLayer.js Normal file
View File

@ -0,0 +1,284 @@
/**
* StormLayer the habitable system's electrified storm.
*
* WHY IT EXISTS: "Temperate, well-lit, and quietly crowded with life."
* and life started in lightning (MillerUrey). So a habitable system
* carries a slow PURPLE STORM: compact cloud clusters that gather charge,
* strike in a rapid flicker, and settle into a soft afterglow the
* weather that seeded the worlds below.
*
* THE 3-D TRICK (three sprites per cluster, in depth order):
* 1. BACK (depth 3.0) the cloud body (fbm texture, purple tint), dim;
* brightens as the charge builds and on the strike.
* 2. FLASH (depth 3.1) the SAME kind of cloud shape (a related fbm
* texture), white-lavender, ADDITIVE, alpha 0 at
* rest. Because the flash is CLOUD-SHAPED (not a
* radial glow), it reads as the cloud igniting
* from within; the additive bleed past the edges
* is the light wrapping the silhouette.
* 3. FRONT (depth 3.2) a DIFFERENT, larger, darker cloud (indigo,
* normal blend). Its fbm holes let light leak
* through in a fractal pattern while the solid
* parts stay dark DARK OVER BRIGHT is the
* silhouette that sells the depth: "dark clouds
* overlaying the brightness".
*
* Plain sprites composited in depth order NO shaders, Canvas-safe, and
* it can never touch the HUD (world-space content, like the starfield).
* The storm is a SKY: anchored to the camera view (the wanderer model)
* with near-zero parallax it stays overhead while the world flies past.
* *
* Timing is PURE and Node-tested (SystemEffectsMath.stormEnvelope):
* charge (a slow rise) strike (a strobe of bursts) afterglow (decay).
* Each cluster gets its own seeded interval + phase, so the strikes are
* staggered the sky flickers from different places, like weather.
*
* Config: data/systems.json types.habitable.effect.storm.
*/
import Phaser from '../vendor/phaser.js';
import { toColor } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js';
import { drawCloud, cloudAlpha } from './NebulaAtmosphere.js';
import { stormEnvelope } from './SystemEffectsMath.js';
import { assignWorld } from './UiCameras.js';
const MARGIN_X = 0.15; // fraction of view width a cluster may hang over an edge (wide clouds)
const DEPTH_BACK = 3.0;
const DEPTH_FLASH = 3.1;
const DEPTH_FRONT = 3.2;
const SKY = { x: 0.10, y: 0.08, w: 0.80, h: 0.34 }; // home band: upper view (fractions)
const BAND_Y = { top: 0.03, bottom: 0.58 }; // wrap band: strictly inside the view (fractions)
/** A seeded 32-bit PRNG (mulberry32) — stable cloud layout per cluster. */
function makeRng(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 ^ (a >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Maps any coordinate into [left, left + span) — safe for huge deltas. */
function wrapIn(v, left, span) {
return left + ((((v - left) % span) + span) % span);
}
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;
}
/** Paints the LIGHTNING: the cloud shape (shared fbm) plus a HOT RADIAL
* CORE at the centre so a strike reads as the cloud igniting from
* within, bright at the heart and melting out through the cloud body.
* White RGB; the alpha channel carries the intensity. */
function drawFlash(ctx, w, h, tex, seed) {
const base = cloudAlpha(w, h, tex, seed);
const img = ctx.createImageData(w, h);
const d = img.data;
for (let y = 0; y < h; y++) {
const v = y / h - 0.5;
for (let x = 0; x < w; x++) {
const i = y * w + x;
const u = x / w - 0.5;
const r = Math.hypot(u, v) * 2; // 0 centre … 1 inscribed edge
const core = r < 0.72 ? Math.pow(1 - r / 0.72, 1.5) : 0;
const a = Math.min(1, Math.max(base[i], core));
const idx = i * 4;
d[idx] = 255;
d[idx + 1] = 255;
d[idx + 2] = 255;
d[idx + 3] = Math.round(a * 255);
}
}
ctx.putImageData(img, 0, 0);
}
/**
* The storm layer (23 cloud clusters, each a back / flash / front trio).
*
* @param {object} scene Phaser scene (add / camera / scale / textures)
* @param {object} cfg data/systems.json effect.storm
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
*/
export class StormLayer {
constructor(scene, cfg, fx = {}) {
this.scene = scene;
this.cfg = cfg ?? {};
this.fx = fx ?? {};
this.clusters = [];
this.lastScrollX = 0;
this.lastScrollY = 0;
}
/** Build the cloud textures + one back/flash/front trio per cluster (idempotent). */
create() {
if (this.clusters.length) return;
const scene = this.scene;
const cam = scene.cameras.main;
const w = scene.scale.width;
const h = scene.scale.height;
const clusters = Math.max(1, Math.round(this.cfg.clusters ?? 3));
const [sMin, sMax] = normRange(this.cfg.size, [420, 640]);
const [pMin, pMax] = normRange(this.cfg.parallax, [0.03, 0.06]); // a SKY dome: barely drifts
const [baMin, baMax] = normRange(this.cfg.alpha?.back, [0.30, 0.42]);
const [faMin, faMax] = normRange(this.cfg.alpha?.front, [0.38, 0.50]);
const [iMin, iMax] = normRange(this.cfg.cycle?.interval, [10, 16]);
const strikeDur = Math.max(0.15, Number(this.cfg.cycle?.flicker) || 0.55);
const chargeWindow = Number(this.cfg.cycle?.charge) || 0.45;
const strength = Math.max(0, Number(this.cfg.flashStrength) || 0.9);
const backColor = toColor(this.cfg.back ?? '#6b5fa8', 0x6b5fa8);
const frontColor = toColor(this.cfg.front ?? '#191430', 0x191430);
const flashColor = toColor(this.cfg.flash ?? '#cfd6ff', 0xcfd6ff);
const TEX = 256;
// A few DISTINCT cloud shapes per role (different noise seeds) so the
// trio reads as a volume, not one image repeated. Cached by key; every
// habitable system reuses the set (only tint/layout/phase vary).
const tex = {
back: (i) => canvasTexture(scene, `fx-storm-back-${i}`, TEX, TEX, (ctx, tw, th) =>
drawCloud(ctx, tw, th, { octaves: 4, persistence: 0.55 }, 717 + i * 101)),
flash: (i) => canvasTexture(scene, `fx-storm-flash-${i}`, TEX, TEX, (ctx, tw, th) =>
drawFlash(ctx, tw, th, { octaves: 5, persistence: 0.6 }, 1009 + i * 101)),
front: (i) => canvasTexture(scene, `fx-storm-front-${i}`, TEX, TEX, (ctx, tw, th) =>
drawCloud(ctx, tw, th, { octaves: 4, persistence: 0.62 }, 1297 + i * 101)),
};
// Seeded per-cluster layout + rhythm (xorshift32 from the system fx,
// so the same system looks the same every run).
let s = (Math.imul(0x51ab1234, 2654435761) ^ Math.round((this.fx.phase ?? 0.5) * 1e9)) >>> 0;
const rand = () => {
s ^= s << 13; s >>>= 0;
s ^= s >> 17; s >>>= 0;
s ^= s << 5; s >>>= 0;
return (s >>> 0) / 4294967296;
};
const range = (a, b) => a + (b - a) * rand();
// Clusters live in the SKY band — the upper part of the view. Their
// home is stored in VIEW space (the wanderer model): each frame we add
// a camera-parallax drift and wrap back into the band, so the storm is
// always overhead no matter how far the ship flies. (Placing them in
// world space with a wide margin let them drift out of view — the old
// bug.)
for (let i = 0; i < clusters; i++) {
const S = range(sMin, sMax);
const homeX = (SKY.x + SKY.w * rand()) * w; // across the sky
const homeY = (SKY.y + SKY.h * rand()) * h; // upper half only
const p = range(pMin, pMax);
const interval = range(iMin, iMax);
const phase = rand() * interval; // staggered strikes (seeded)
const bx = (cam.scrollX ?? 0) + homeX;
const by = (cam.scrollY ?? 0) + homeY;
const back = scene.add.image(bx, by, tex.back(i))
.setDepth(DEPTH_BACK).setTint(backColor)
.setScale(S / TEX).setAlpha(baMin)
.setRotation(rand() * Math.PI * 2);
// The flash sits inside the cloud body, near-centre (the hot core is
// the strike — it must not be buried under the front cloud).
const flashOffX = (rand() - 0.5) * 0.08 * S;
const flashOffY = (rand() - 0.5) * 0.08 * S;
const flash = scene.add.image(bx + flashOffX, by + flashOffY, tex.flash(i))
.setDepth(DEPTH_FLASH).setTint(flashColor)
.setScale((0.85 * S) / TEX).setAlpha(0)
.setBlendMode(Phaser.BlendModes.ADD)
.setRotation(back.rotation + (rand() - 0.5) * 0.4);
// The dark front: larger, offset, OVER the flash — the silhouette.
// Loose offset so its fbm holes + edge let the flash read through
// (light wrapping the dark cloud), without burying the core.
const frontOffX = (rand() - 0.5) * 0.16 * S;
const frontOffY = (rand() - 0.5) * 0.14 * S;
const front = scene.add.image(bx + frontOffX, by + frontOffY, tex.front(i))
.setDepth(DEPTH_FRONT).setTint(frontColor)
.setScale((1.12 * S) / TEX).setAlpha(faMin)
.setRotation(back.rotation + (rand() - 0.5) * 0.3);
assignWorld(scene, back);
assignWorld(scene, flash);
assignWorld(scene, front);
this.clusters.push({
back, flash, front,
homeX, homeY, p,
flashOffX, flashOffY, frontOffX, frontOffY,
driftX: 0, driftY: 0, // accumulated parallax drift (view space)
interval, phase, strikeDur, chargeWindow, strength,
backBase: range(baMin, baMax),
frontBase: range(faMin, faMax),
});
}
this.lastScrollX = cam.scrollX;
this.lastScrollY = cam.scrollY;
}
/**
* Per-frame: anchor each trio to the sky band (home + parallax drift,
* wrapped into the band the wanderer model), and drive the storm cycle
* (charge strike afterglow) on each trio's alphas.
*/
update(nowMs, dtMs) {
if (!this.clusters.length) return;
const scene = this.scene;
const cam = scene.cameras.main;
const camX = cam.scrollX;
const camY = cam.scrollY;
const w = scene.scale.width;
const h = scene.scale.height;
const dx = camX - this.lastScrollX;
const dy = camY - this.lastScrollY;
this.lastScrollX = camX;
this.lastScrollY = camY;
// The sky band, in VIEW space: strictly inside the visible view (upper
// ~58%), so the trio is always overhead — no off-screen overhang. The
// wrap is done in view space (camera-independent), then converted to
// world space as camera + view offset — the wanderer model. (The storm
// is a SKY: it stays put while the world flies past; its near-zero
// parallax is the illusion of a dome around the system.)
const vx0 = -MARGIN_X * w;
const vx1 = w + MARGIN_X * w;
const vy0 = BAND_Y.top * h;
const vy1 = BAND_Y.bottom * h;
for (const c of this.clusters) {
// Parallax drift in view space (trails the camera at p), wrapped so
// the trio stays in the band.
c.driftX -= dx * c.p;
c.driftY -= dy * c.p;
const vx = wrapIn(c.homeX + c.driftX, vx0, vx1 - vx0);
const vy = wrapIn(c.homeY + c.driftY, vy0, vy1 - vy0);
c.back.x = camX + vx;
c.back.y = camY + vy;
c.flash.x = c.back.x + c.flashOffX;
c.flash.y = c.back.y + c.flashOffY;
c.front.x = c.back.x + c.frontOffX;
c.front.y = c.back.y + c.frontOffY;
// The storm cycle (pure, seeded, Node-tested).
const env = stormEnvelope(nowMs, c.interval, c.strikeDur, c.phase, c.chargeWindow);
c.back.setAlpha(c.backBase * (0.7 + 0.5 * env.charge + 0.3 * env.flash));
c.flash.setAlpha(env.flash * c.strength);
c.front.setAlpha(c.frontBase * (0.8 + 0.3 * env.charge));
}
}
/** Tear down. */
destroy() {
for (const c of this.clusters) {
c.back.destroy();
c.flash.destroy();
c.front.destroy();
}
this.clusters = [];
}
}

View File

@ -117,6 +117,62 @@ export function orbitCenters(nowMs, periodSec, radius, phase = 0) {
return { cx0: 0.5 + dx, cy0: 0.5 + dy, cx1: 0.5 - dx, cy1: 0.5 - dy };
}
/**
* A smoothstep on a 0..1 domain (mirrors the shader's).
*/
function sstep01(x) {
const t = Math.min(1, Math.max(0, x));
return t * t * (3 - 2 * t);
}
/**
* The HABITABLE storm's envelope at a moment: `{ charge, flash }`, both 0..1.
*
* The cycle (per cluster, seeded by its phase):
* CHARGE a smooth rise over the last `chargeWindow` fraction of the
* cycle (the clouds gathering light before the strike);
* STRIKE at the cycle boundary: a rapid flicker (real lightning
* strobes a bright burst plus two decaying rebounds) and a
* soft afterglow that decays into the next cycle's quiet.
* Deterministic (no Math.random), so the same system storms the same way
* every run. The strike sits at the cycle boundary (t phase mod period).
*
* @param {number} nowMs game clock (ms)
* @param {number} intervalSec seconds per storm cycle
* @param {number} strikeDurSec the flicker's span (seconds)
* @param {number} [phaseSec=0] per-cluster phase offset (seconds)
* @param {number} [chargeWindow=0.45] fraction of the cycle over which charge rises
* @returns {{charge:number, flash:number}} both 0..1
*/
export function stormEnvelope(nowMs, intervalSec, strikeDurSec, phaseSec = 0, chargeWindow = 0.45) {
const period = Math.max(2, Number(intervalSec) || 12);
const W = Math.max(0.15, Number(strikeDurSec) || 0.5);
const cw = Math.min(1, Math.max(0.1, Number(chargeWindow) || 0.45));
const t = nowMs / 1000 - (Number(phaseSec) || 0);
const tLocal = ((t % period) + period) % period; // 0..period; strike at tLocal === 0 (≡ period)
// CHARGE: 0 early in the cycle, rising smoothly to 1 at the strike.
const u = tLocal / period;
const charge = sstep01((u - (1 - cw)) / cw);
// FLASH: time relative to the strike (negative = before it). The
// afterglow is clipped at 3× the flicker span (it is 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]
let flash = 0;
if (dt >= 0 && dt <= 3 * W) {
const gauss = (x, sigma) => Math.exp(-(x * x) / (2 * sigma * sigma));
// A bright primary burst + two decaying rebounds (the strobe) +
// a soft afterglow (light that lingers in the cloud).
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));
flash = Math.min(1, b1 + b2 + b3 + afterglow);
}
return { charge, flash };
}
// --- Color grade (pure; the shader does the same thing per-pixel) ---------
/**