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:
parent
729ab09d3e
commit
6dc0e99dee
Binary file not shown.
Binary file not shown.
|
|
@ -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-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": "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": "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": "ice-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "ice-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-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" }
|
||||||
],
|
],
|
||||||
|
|
||||||
"stationVideos": [
|
"stationVideos": [
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,22 @@
|
||||||
"description": "Temperate, well-lit, and quietly crowded with life. Rare.",
|
"description": "Temperate, well-lit, and quietly crowded with life. Rare.",
|
||||||
"theme": { "color": "#7ce8a4" },
|
"theme": { "color": "#7ce8a4" },
|
||||||
"distribution": { "weight": 10 },
|
"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": {
|
"attributes": {
|
||||||
"star": {
|
"star": {
|
||||||
"classes": { "G": 70, "K": 30 },
|
"classes": { "G": 70, "K": 30 },
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,13 @@
|
||||||
* (brightness → saturation → tint → split → flash) is exact and
|
* (brightness → saturation → tint → split → flash) is exact and
|
||||||
* identity when the grade is empty;
|
* identity when the grade is empty;
|
||||||
* - the DATA CONTRACT (data/systems.json): every type carries an
|
* - 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
|
* the redDwarf bundle has a flare + grade + particles; the binary
|
||||||
* bundle has two centers + orbit + a split grade + a wanderer; padding
|
* bundle has a split grade + a two-light lift + a breath + a wanderer;
|
||||||
* covers the worst-case displacement (amplitude × flare boost ×
|
* 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);
|
* centers × width);
|
||||||
* - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the
|
* - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the
|
||||||
* requested type wins, ties fall to roster order, missing type => null;
|
* 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))
|
(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', (() => {
|
check('hexToRgb01: parses #rrggbb and bad input falls to white', (() => {
|
||||||
const w = M.hexToRgb01('#ffffff');
|
const w = M.hexToRgb01('#ffffff');
|
||||||
const b = M.hexToRgb01('#0000ff');
|
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 types = config.section('systems.types', {});
|
||||||
const typeIds = Object.keys(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);
|
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('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('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 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;
|
return live && still;
|
||||||
})());
|
})());
|
||||||
check('nebula: a steady ripple, nothing else', (() => {
|
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)
|
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', (() => {
|
check('every ripple bundle has sane numbers + padding covering the worst case', (() => {
|
||||||
const maxDim = Math.max(1280, 720);
|
const maxDim = Math.max(1280, 720);
|
||||||
return ['nebula', 'redDwarf'].every((t) => {
|
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
|
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) -------------------------------
|
// --- 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);
|
fx.update((grade.breathPeriod * 1000) / 4);
|
||||||
return grade.uBreath !== b0 && Math.abs(grade.uBreath) <= grade.breathDepth;
|
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)', (() => {
|
check('facade: apply() twice replaces (no duplicate filters)', (() => {
|
||||||
fx.apply('redDwarf');
|
fx.apply('redDwarf');
|
||||||
return fakeCamMain.filters.internal.list.length === 2;
|
return fakeCamMain.filters.internal.list.length === 2;
|
||||||
|
|
|
||||||
|
|
@ -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
|
- 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
|
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
|
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,
|
- **nebula** — the RIPPLE (1 center, screen anchor): concentric rings,
|
||||||
the light unsettled.
|
the light unsettled.
|
||||||
- **redDwarf** — a SUPER-subtle RIPPLE (1 center, star anchor; the base
|
- **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
|
wandering companion star (content). NO ripple — two stars, one steady
|
||||||
light. The two-color void needs a neutral content base to paint on (the
|
light. The two-color void needs a neutral content base to paint on (the
|
||||||
clear color bypasses the filter) — see `void` below.
|
clear color bypasses the filter) — see `void` below.
|
||||||
|
- **habitable** — an ELECTRIFIED STORM in the upper sky (2–3 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
|
The star itself is invisible flavor; the shimmer / glow / wander is its
|
||||||
character.
|
character.
|
||||||
- Two kinds of sub-effect:
|
- 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.
|
both filters read, so the wave and the light flash as one.
|
||||||
- **Content** (world-space sprites, Canvas-safe, behind the art):
|
- **Content** (world-space sprites, Canvas-safe, behind the art):
|
||||||
`haze` (redDwarf red haze over the void → `js/visuals/RedHaze.js`, depth
|
`haze` (redDwarf red haze over the void → `js/visuals/RedHaze.js`, depth
|
||||||
−1, BEHIND the stars), `void` (binary's NEUTRAL dark+grit void base →
|
−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
|
habitable's FOREST-NIGHT green-black base → `js/visuals/VoidBase.js`,
|
||||||
breath paint on, since the clear color bypasses the filter),
|
depth −1 — the surface the lift/haze paint on, since the clear color
|
||||||
`particles` (redDwarf ember dust → `js/visuals/EmberField.js`), and
|
bypasses the filter),
|
||||||
`wanderer` (binary companion star → `js/visuals/WandererStar.js`).
|
`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.05–0.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),
|
- Files: `js/visuals/SystemEffects.js` (ripple + grade filters + facade),
|
||||||
`js/visuals/SystemEffectsMath.js` (pure UV / flare / orbit / grade / lift
|
`js/visuals/SystemEffectsMath.js` (pure UV / flare / orbit / grade / lift /
|
||||||
math), `js/visuals/UiCameras.js` (the UI camera split),
|
storm-envelope math), `js/visuals/UiCameras.js` (the UI camera split),
|
||||||
`js/visuals/RedHaze.js` (the gritty red haze), `js/visuals/VoidBase.js`
|
`js/visuals/RedHaze.js` (the gritty red haze), `js/visuals/VoidBase.js`
|
||||||
(the binary's neutral void base), `js/visuals/EmberField.js` (ember dust),
|
(the neutral void base), `js/visuals/EmberField.js` (ember dust +
|
||||||
`js/visuals/WandererStar.js` (companion star),
|
twinkle), `js/visuals/WandererStar.js` (companion star),
|
||||||
|
`js/visuals/StormLayer.js` (the habitable storm),
|
||||||
`js/galaxy/FxSystems.js` (demo system picker),
|
`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
|
- **How the filters draw (Phaser 4.2.1):** each filter is a
|
||||||
`Phaser.Filters.Controller` on `cameras.main`'s `filters.internal` list —
|
`Phaser.Filters.Controller` on `cameras.main`'s `filters.internal` list —
|
||||||
when any internal filter is active the camera pass auto-composites (FBO +
|
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
|
nebula (a different seed → a different gas color) — fly around and
|
||||||
judge the look.
|
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 Miller–Urey: 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.05–0.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 = 10–16 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 ~10–16 s per cluster, staggered.
|
||||||
|
|
||||||
## Roadmap (working list, intentionally rough)
|
## Roadmap (working list, intentionally rough)
|
||||||
|
|
||||||
- [x] v0.1 foundation — menu → New Game → click-to-fly ship
|
- [x] v0.1 foundation — menu → New Game → click-to-fly ship
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { EmberField } from '../visuals/EmberField.js';
|
||||||
import { WandererStar } from '../visuals/WandererStar.js';
|
import { WandererStar } from '../visuals/WandererStar.js';
|
||||||
import { RedHaze } from '../visuals/RedHaze.js';
|
import { RedHaze } from '../visuals/RedHaze.js';
|
||||||
import { VoidBase } from '../visuals/VoidBase.js';
|
import { VoidBase } from '../visuals/VoidBase.js';
|
||||||
|
import { StormLayer } from '../visuals/StormLayer.js';
|
||||||
import { SystemEffects } from '../visuals/SystemEffects.js';
|
import { SystemEffects } from '../visuals/SystemEffects.js';
|
||||||
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
|
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
|
||||||
import { pickFxSystem } from '../galaxy/FxSystems.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 = new VoidBase(this, _fx.void, this.systemContent?.fx ?? {});
|
||||||
this.voidBase.create();
|
this.voidBase.create();
|
||||||
}
|
}
|
||||||
|
if (_fx?.storm) {
|
||||||
|
this.storm = new StormLayer(this, _fx.storm, this.systemContent?.fx ?? {});
|
||||||
|
this.storm.create();
|
||||||
|
}
|
||||||
if (_fx?.particles) {
|
if (_fx?.particles) {
|
||||||
this.embers = new EmberField(this, _fx.particles.color, _fx.particles, this.systemContent?.fx ?? {});
|
this.embers = new EmberField(this, _fx.particles.color, _fx.particles, this.systemContent?.fx ?? {});
|
||||||
this.embers.create();
|
this.embers.create();
|
||||||
|
|
@ -1696,6 +1701,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.nebula?.update(); // the gas: parallax + slow self-swirl
|
this.nebula?.update(); // the gas: parallax + slow self-swirl
|
||||||
this.haze?.update(_time, delta); // redDwarf red haze: follow the camera
|
this.haze?.update(_time, delta); // redDwarf red haze: follow the camera
|
||||||
this.voidBase?.update(_time, delta); // binary void base: 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.embers?.update(_time, delta); // redDwarf ember dust: drift + parallax
|
||||||
this.wanderer?.update(_time, delta); // binary companion star: slow wander
|
this.wanderer?.update(_time, delta); // binary companion star: slow wander
|
||||||
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
||||||
|
|
@ -4308,6 +4314,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.nebula?.destroy();
|
this.nebula?.destroy();
|
||||||
this.haze?.destroy();
|
this.haze?.destroy();
|
||||||
this.voidBase?.destroy();
|
this.voidBase?.destroy();
|
||||||
|
this.storm?.destroy();
|
||||||
this.embers?.destroy();
|
this.embers?.destroy();
|
||||||
this.wanderer?.destroy();
|
this.wanderer?.destroy();
|
||||||
this.compass?.destroy();
|
this.compass?.destroy();
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,14 @@ export class EmberField {
|
||||||
alpha = [0.4, 0.9],
|
alpha = [0.4, 0.9],
|
||||||
parallax = [0.25, 0.5],
|
parallax = [0.25, 0.5],
|
||||||
speed = [8, 25],
|
speed = [8, 25],
|
||||||
|
twinkle = null,
|
||||||
} = this.cfg;
|
} = 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 texKey = canvasTexture(scene, 'fx-ember-spark', 32, 32, (ctx) => drawSpark(ctx, 32));
|
||||||
const w = scene.scale.width;
|
const w = scene.scale.width;
|
||||||
const h = scene.scale.height;
|
const h = scene.scale.height;
|
||||||
|
|
@ -99,11 +105,12 @@ export class EmberField {
|
||||||
const by = top + rand() * (h + 2 * MARGIN);
|
const by = top + rand() * (h + 2 * MARGIN);
|
||||||
const ang = rand() * Math.PI * 2;
|
const ang = rand() * Math.PI * 2;
|
||||||
const spd = range(speed[0], speed[1]);
|
const spd = range(speed[0], speed[1]);
|
||||||
|
const baseAlpha = range(alpha[0], alpha[1]);
|
||||||
const sp = scene.add
|
const sp = scene.add
|
||||||
.image(bx, by, texKey)
|
.image(bx, by, texKey)
|
||||||
.setDepth(DEPTH)
|
.setDepth(DEPTH)
|
||||||
.setTint(this.color)
|
.setTint(this.color)
|
||||||
.setAlpha(range(alpha[0], alpha[1]))
|
.setAlpha(baseAlpha)
|
||||||
.setBlendMode(Phaser.BlendModes.ADD);
|
.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
const sz = range(size[0], size[1]);
|
const sz = range(size[0], size[1]);
|
||||||
sp.setScale(sz / 16); // 32px texture, scale to ~size px
|
sp.setScale(sz / 16); // 32px texture, scale to ~size px
|
||||||
|
|
@ -112,6 +119,10 @@ export class EmberField {
|
||||||
vx: Math.cos(ang) * spd,
|
vx: Math.cos(ang) * spd,
|
||||||
vy: Math.sin(ang) * spd,
|
vy: Math.sin(ang) * spd,
|
||||||
p: range(parallax[0], parallax[1]),
|
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;
|
this.lastScrollX = cam.scrollX;
|
||||||
|
|
@ -143,6 +154,13 @@ export class EmberField {
|
||||||
// Keep the field full: wrap into the current camera window.
|
// Keep the field full: wrap into the current camera window.
|
||||||
e.sp.x = wrapIn(e.sp.x, left, spanX);
|
e.sp.x = wrapIn(e.sp.x, left, spanX);
|
||||||
e.sp.y = wrapIn(e.sp.y, top, spanY);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,21 +163,17 @@ export class NebulaAtmosphere {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paint one soft cloud into a square canvas: value-noise fbm for the
|
* 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
|
* internal structure, multiplied by a wide radial falloff so the puff has
|
||||||
* no perceptible silhouette — it is fully transparent by the inscribed
|
* no perceptible silhouette — fully transparent by the inscribed circle,
|
||||||
* circle and dissolves gradually out, so no border or corner reads as a
|
* dissolving gently out. (The single source of the cloud shape; both
|
||||||
* hard edge. White RGB; the alpha channel carries the density (the
|
* drawCloud and the storm's flash use it.)
|
||||||
* sprite's tint sets the color).
|
|
||||||
*/
|
*/
|
||||||
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 octaves = Math.max(1, Math.round(tex.octaves ?? 4));
|
||||||
const persistence = clamp01(tex.persistence ?? 0.55);
|
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 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++) {
|
for (let y = 0; y < h; y++) {
|
||||||
const v = y / h;
|
const v = y / h;
|
||||||
for (let x = 0; x < w; x++) {
|
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.
|
// Gentle internal structure (fbm) — wispy density, not speckled dust.
|
||||||
const structure = smoothstep(0.30, 0.80, n);
|
const structure = smoothstep(0.30, 0.80, n);
|
||||||
|
|
||||||
const a = radial * structure;
|
out[y * w + x] = 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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);
|
ctx.putImageData(img, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (Miller–Urey). 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 (2–3 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
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) ---------
|
// --- Color grade (pure; the shader does the same thing per-pixel) ---------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue