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-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": [
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (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
|
||||
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.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),
|
||||
`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 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)
|
||||
|
||||
- [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 { 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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) ---------
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue