diff --git a/assets/videos/ice-land-03.mp4 b/assets/videos/ice-land-03.mp4 index 2f4ba28..58ee7b7 100644 Binary files a/assets/videos/ice-land-03.mp4 and b/assets/videos/ice-land-03.mp4 differ diff --git a/assets/videos/ice-takeoff-03.mp4 b/assets/videos/ice-takeoff-03.mp4 new file mode 100644 index 0000000..6c2816d Binary files /dev/null and b/assets/videos/ice-takeoff-03.mp4 differ diff --git a/data/landing.json b/data/landing.json index 88fb490..6012073 100644 --- a/data/landing.json +++ b/data/landing.json @@ -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": [ diff --git a/data/systems.json b/data/systems.json index dc51e19..fc613c2 100644 --- a/data/systems.json +++ b/data/systems.json @@ -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 }, diff --git a/dev/system-effects.test.mjs b/dev/system-effects.test.mjs index 0592548..7ebf2dd 100644 --- a/dev/system-effects.test.mjs +++ b/dev/system-effects.test.mjs @@ -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; diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 0ecb86d..5413fa5 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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..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=` — 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 diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 6ac2903..e93eedd 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(); diff --git a/js/visuals/EmberField.js b/js/visuals/EmberField.js index 624954c..44c700e 100644 --- a/js/visuals/EmberField.js +++ b/js/visuals/EmberField.js @@ -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); + } } } diff --git a/js/visuals/NebulaAtmosphere.js b/js/visuals/NebulaAtmosphere.js index df8d490..234b99c 100644 --- a/js/visuals/NebulaAtmosphere.js +++ b/js/visuals/NebulaAtmosphere.js @@ -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); } diff --git a/js/visuals/StormLayer.js b/js/visuals/StormLayer.js new file mode 100644 index 0000000..5b5f0cd --- /dev/null +++ b/js/visuals/StormLayer.js @@ -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 = []; + } +} diff --git a/js/visuals/SystemEffectsMath.js b/js/visuals/SystemEffectsMath.js index 8f9685a..caacff9 100644 --- a/js/visuals/SystemEffectsMath.js +++ b/js/visuals/SystemEffectsMath.js @@ -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) --------- /**