HardYards/web/world/js/skyfx.js
m3ultra 1f99cd9bca Rain gets physical units for ponding; night pass on wildnight
SPRINT4 §Lane C 2/3/4.

PONDING DATA (decision 10). rainAt() was a dimensionless 0..1 — fine for drop
count and opacity, useless for water mass. Lane B would have had to invent the
mm/hr scale, which is exactly the "default-off code tuned by a constant I
invented" they rightly reverted. So the scale is storm data now:
rain.peakMmPerHour (validated 0..300; a rain curve without one is a hard error,
since ponding would silently use the default instead of what the author meant),
plus wind.rainMmPerHour(t) and rainDepthMm(t0,t1). RAIN_TIME_COMPRESSION=40 is
exported from weather.core so B applies it cloth-side rather than either of us
hardcoding 40 twice: how hard it rains is mine, how much water a sail holds is
theirs.

Calibrated to B's own arithmetic, and the numbers land on it:
  storm_02  80 mm/hr severe  -> 50.9 mm = 3.12 kN/corner   (B predicted 3.1)
  storm_03  30 mm/hr moderate -> 8.3 mm = 0.51 kN/corner   (teases a carabiner)
  storm_01   8 mm/hr shower   -> 0.9 mm = 0.06 kN/corner   (harmless, as designed)
against a storm_02 wind load of 0.2-1.1 kN. A flat rig should drown in the wild
night; a hypar pools nothing and won't notice. Asserted with B's arithmetic so
the storms are provably fit for their water before their cloth lands.

DECISION 7 helper for Lane A: sky.gardenExposure(bed, t) = rainAt x (1-shadow),
the whole drain term in one call. Note it moves on its own — the rain shadow
follows the wind, so the southerly change walks the dry patch off the bed and
the drain climbs with no corner having failed.

NIGHT PASS. sky.night is now the author's call (was: inferred from a darkness
threshold), and darkening scene.background did nothing anyway — the cloud dome
covers it at 0.85 opacity, so an overcast-grey texture was what you actually saw.
The dome now tints AND crushes (a lerp alone lands #717273: it runs in linear
space and the texture is baked near-white). Stops at 0.78 because the yard has no
lights and a storm you can't see is a black screen. Lightning now lights the
cloud it's inside (#3e3e3f -> #d4ddf2), and fires on the biggest gusts via
sky.lightningGustPow, not just the three authored strikes — driven off the
telegraph so the flash lands with the gust that earned it.

Also: c.test's storm list was hardcoded to two storms while the node runner globs
the directory, so storm_03 was untested in the browser half. Its own ponding
assert caught it.

Selftest 195/0/0 (was 184). Verified live: night reads as night, flash lights the
cloud, exposure responds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:28:09 +10:00

698 lines
28 KiB
JavaScript

'use strict';
// SHADES — Lane C — skyfx: rain, storm sky, lightning, and the noise of it all.
//
// PLAN3D §5-C.2/§5-C.3. Lane A's world.js owns the calm sky and the base lights;
// this MODULATES them as the storm builds and hands them back on dispose(), so
// two lanes never fight over one scene.
//
// Everything is duck-typed and optional — no sun light, no audio, no sail? Then
// those layers just don't run. Lane A can wire the pieces as they land.
//
// Audio is synthesized, not sampled: web/world/audio/ is empty, we ship no CDN
// and no deps, and a filtered-noise bed tracks wind speed better than a loop.
import * as THREE from '../vendor/three.module.js';
import { rng } from './contracts.js';
import { valueNoise2 } from './weather.core.js';
const lerp = (a, b, k) => a + (b - a) * k;
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const CALM_SKY = new THREE.Color(0x9fc4e8);
const STORM_SKY = new THREE.Color(0x2a2f3a);
const NIGHT_SKY = new THREE.Color(0x11141c);
const WHITE = new THREE.Color(0xffffff);
const FLASH_COL = new THREE.Color(0xdfe8ff);
// ------------------------------------------------------------ rain shadow
/**
* Where the sail is keeping the ground dry (SPRINT2 §Lane C.3).
*
* This is the RAIN shadow, not the sun shadow. Rain arrives along the wind, so
* the dry patch sits downwind of the cloth and slides across the yard as the
* wind swings — at the southerly change it walks right off the garden, which is
* free drama and the honest physics.
*
* Cheap on purpose: ray-testing 3 k drops against 162 triangles every frame is
* ~486 k intersections for an effect nobody inspects closely. Instead we project
* the sail's triangles ALONG the rain onto the ground and rasterise them into a
* coarse grid, a few times a second — the cloth moves slowly next to the rain.
* Per-drop cost is then one projection and one array read.
*
* Reads `rig.pos`/`rig.tris`, which are already the surface Lane A's sail view
* consumes, so this needs nothing new from Lane B.
*/
export class RainShadow {
constructor(o = {}) {
this.n = o.cells ?? 64; // ~0.56 m over a 36 m span
this.half = o.half ?? 18;
this.groundY = o.groundY ?? 0;
this.ceil = new Float32Array(this.n * this.n); // sail height per cell, 0 = open sky
this.live = false;
this.dx = 0; this.dy = -1; this.dz = 0;
}
_idx(gx, gz) {
const i = Math.floor(((gx + this.half) / (this.half * 2)) * this.n);
const j = Math.floor(((gz + this.half) / (this.half * 2)) * this.n);
if (i < 0 || j < 0 || i >= this.n || j >= this.n) return -1;
return j * this.n + i;
}
/** @param {object} rig Lane B's SailRig @param {number} dx,dy,dz unit rain direction */
update(rig, dx, dy, dz) {
this.live = false;
if (!rig || !rig.pos || !rig.tris || dy > -1e-3) return; // rain must fall
this.ceil.fill(0);
this.dx = dx; this.dy = dy; this.dz = dz;
const pos = rig.pos, tris = rig.tris, cellW = (this.half * 2) / this.n;
const gx = [0, 0, 0], gz = [0, 0, 0], gy = [0, 0, 0];
for (let i = 0; i < tris.length; i += 3) {
for (let k = 0; k < 3; k++) {
const a = tris[i + k] * 3;
const vy = pos[a + 1];
const tt = (vy - this.groundY) / -dy; // slide down the rain to the ground
gx[k] = pos[a] + dx * tt;
gz[k] = pos[a + 2] + dz * tt;
gy[k] = vy;
}
const d = (gz[1] - gz[2]) * (gx[0] - gx[2]) + (gx[2] - gx[1]) * (gz[0] - gz[2]);
if (Math.abs(d) < 1e-9) continue; // degenerate once projected
const minX = Math.min(gx[0], gx[1], gx[2]), maxX = Math.max(gx[0], gx[1], gx[2]);
const minZ = Math.min(gz[0], gz[1], gz[2]), maxZ = Math.max(gz[0], gz[1], gz[2]);
for (let px = minX; px <= maxX + cellW; px += cellW) {
for (let pz = minZ; pz <= maxZ + cellW; pz += cellW) {
const c = this._idx(px, pz);
if (c < 0) continue;
// barycentric, with a little slop so cracks between tris don't leak rain
const l1 = ((gz[1] - gz[2]) * (px - gx[2]) + (gx[2] - gx[1]) * (pz - gz[2])) / d;
const l2 = ((gz[2] - gz[0]) * (px - gx[2]) + (gx[0] - gx[2]) * (pz - gz[2])) / d;
const l3 = 1 - l1 - l2;
if (l1 < -0.05 || l2 < -0.05 || l3 < -0.05) continue;
const y = l1 * gy[0] + l2 * gy[1] + l3 * gy[2];
if (y > this.ceil[c]) this.ceil[c] = y;
}
}
this.live = true;
}
}
/** Has a drop here already been stopped by the cloth? */
occluded(x, y, z) {
if (!this.live) return false;
const tt = (y - this.groundY) / -this.dy;
const c = this._idx(x + this.dx * tt, z + this.dz * tt);
if (c < 0) return false;
const ceil = this.ceil[c];
return ceil > 0 && y < ceil; // above the cloth it hasn't hit yet
}
/** 0..1 of a ground rect under cover. Same rect shape as sailRig.coverageOver. */
fractionOver(rect, cols = 6, rows = 4) {
if (!this.live) return 0;
let hit = 0;
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const x = rect.x + ((i + 0.5) / cols - 0.5) * rect.w;
const z = rect.z + ((j + 0.5) / rows - 0.5) * rect.d;
const c = this._idx(x, z);
if (c >= 0 && this.ceil[c] > 0) hit++;
}
}
return hit / (cols * rows);
}
}
/** Rain velocity, m/s. One definition, used by the drops and by the shadow. */
function rainVelocity(w, intensity, out) {
return out.set(w.x * 0.55, -(9 + intensity * 4), w.z * 0.55);
}
// ---------------------------------------------------------------- rain
function createRain(opts) {
const max = opts.maxDrops ?? 3000;
const half = opts.half ?? 18; // box half-extent around the camera
const height = opts.height ?? 24;
const groundY = opts.groundY ?? 0;
const rand = rng(0xd309);
// one thin quadish streak, instanced — cheap and reads as rain in motion
const geo = new THREE.BoxGeometry(0.015, 1, 0.015);
const mat = new THREE.MeshBasicMaterial({
color: 0xb4d2ff, transparent: true, opacity: 0.34,
depthWrite: false, fog: false,
});
const mesh = new THREE.InstancedMesh(geo, mat, max);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.frustumCulled = false;
mesh.renderOrder = 2;
mesh.count = 0;
const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max);
const jitter = new Float32Array(max);
for (let i = 0; i < max; i++) {
px[i] = (rand() * 2 - 1) * half;
py[i] = groundY + rand() * height;
pz[i] = (rand() * 2 - 1) * half;
jitter[i] = 0.75 + rand() * 0.5; // not every drop is the same drop
}
const m = new THREE.Matrix4();
const q = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
const vel = new THREE.Vector3();
const unit = new THREE.Vector3();
const scale = new THREE.Vector3(1, 1, 1);
const zero = new THREE.Vector3();
// zero-scale: an instance that renders to nothing
const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);
return {
mesh,
/**
* @param {THREE.Vector3} camPos
* @param {THREE.Vector3} w local wind
* @param {RainShadow} [shadow] drops under the cloth are not drawn
*/
step(dt, camPos, w, intensity, shadow) {
const n = Math.floor(max * clamp01(intensity));
mesh.count = n;
if (n === 0) return;
// rain leans into the wind; that lean IS the readout of how hard it's blowing
rainVelocity(w, intensity, vel);
const fall = -vel.y;
const speed = vel.length() || 1;
q.setFromUnitVectors(up, unit.copy(vel).divideScalar(speed));
// streak stretches with speed — drizzle is dots, a squall is lines
scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1);
m.compose(zero, q, scale);
const top = groundY + height;
for (let i = 0; i < n; i++) {
const j = jitter[i];
px[i] += w.x * 0.55 * j * dt;
py[i] -= fall * j * dt;
pz[i] += w.z * 0.55 * j * dt;
// wrap the box around the camera instead of respawning — no bookkeeping,
// and the rain is always exactly where the player is looking
let d = px[i] - camPos.x;
if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2;
d = pz[i] - camPos.z;
if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2;
if (py[i] < groundY) py[i] += height;
else if (py[i] > top) py[i] -= height;
// Under the cloth this drop was stopped up there. Keep simulating it —
// it wraps back to the top and rains again beyond the sail's edge — but
// don't draw it. A degenerate matrix is cheaper than reshuffling the
// instance list, and InstancedMesh has no per-instance visibility.
if (shadow && shadow.occluded(px[i], py[i], pz[i])) {
mesh.setMatrixAt(i, HIDDEN);
continue;
}
m.elements[12] = px[i];
m.elements[13] = py[i];
m.elements[14] = pz[i];
mesh.setMatrixAt(i, m);
}
mesh.instanceMatrix.needsUpdate = true;
},
dispose() { geo.dispose(); mat.dispose(); },
};
}
// ------------------------------------------------------------ cloud dome
function cloudTexture(size = 256, seed = 7) {
const cv = document.createElement('canvas');
cv.width = cv.height = size;
const ctx = cv.getContext('2d');
const img = ctx.createImageData(size, size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// fbm at integer frequencies, each octave wrapped at its own period, so
// the texture tiles: it's set to repeat(3,2) and it scrolls forever, and
// an unwrapped octave puts a dead straight seam across the sky.
let n = 0, amp = 0.5, f = 4;
for (let o = 0; o < 4; o++) {
n += amp * valueNoise2((x / size) * f, (y / size) * f, seed + o * 977, f);
amp *= 0.5; f *= 2;
}
const v = clamp01((n - 0.28) * 2.2);
const i = (y * size + x) * 4;
const shade = 150 + v * 70;
img.data[i] = shade; img.data[i + 1] = shade; img.data[i + 2] = shade + 12;
img.data[i + 3] = v * 235;
}
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(3, 2);
return tex;
}
// ---------------------------------------------------------------- audio
// Synthesized layers. WebAudio won't start until a gesture (browser rule), so
// everything is built lazily on unlock() and silently absent before it.
function createAudio(seed = 1) {
let ctx = null, master = null;
let windGain, windFilter, windHowl, howlGain;
let rainGain, rainFilter;
let gustGain, gustFilter;
let noiseBuf = null;
let creakNext = 0, flogNext = 0;
let started = false;
function noiseBuffer(c) {
const len = c.sampleRate * 4;
const buf = c.createBuffer(1, len, c.sampleRate);
const d = buf.getChannelData(0);
const rand = rng(seed ^ 0x0157);
let last = 0;
for (let i = 0; i < len; i++) {
const white = rand() * 2 - 1;
last = (last + 0.02 * white) / 1.02; // brown-ish: weight to the low end
d[i] = last * 3.5;
}
return buf;
}
function loop(buf, dest, filter) {
const src = ctx.createBufferSource();
src.buffer = buf; src.loop = true;
src.connect(filter); filter.connect(dest);
src.start();
return src;
}
return {
get ready() { return started; },
/** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph
* got built — a suspended context is still silent, so the HUD reports this. */
get state() { return ctx ? ctx.state : 'none'; },
/** Current layer gains — for the HUD and for asserting the bed tracks wind. */
levels() {
if (!started) return null;
return {
wind: +windGain.gain.value.toFixed(4),
howl: +howlGain.gain.value.toFixed(4),
rain: +rainGain.gain.value.toFixed(4),
cutoff: Math.round(windFilter.frequency.value),
};
},
/** Call from the first click/keydown. Safe to call repeatedly. */
unlock() {
if (started) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
ctx = new AC();
if (ctx.state === 'suspended') ctx.resume();
master = ctx.createGain();
master.gain.value = 0.55;
master.connect(ctx.destination);
noiseBuf = noiseBuffer(ctx);
// wind bed: brown noise through a lowpass that opens as it blows harder
windGain = ctx.createGain(); windGain.gain.value = 0;
windFilter = ctx.createBiquadFilter();
windFilter.type = 'lowpass'; windFilter.frequency.value = 400;
windGain.connect(master);
loop(noiseBuf, windGain, windFilter);
// howl: a resonant band on top — this is the bit that sounds like a gale
howlGain = ctx.createGain(); howlGain.gain.value = 0;
windHowl = ctx.createBiquadFilter();
windHowl.type = 'bandpass'; windHowl.frequency.value = 500; windHowl.Q.value = 6;
howlGain.connect(master);
loop(noiseBuf, howlGain, windHowl);
rainGain = ctx.createGain(); rainGain.gain.value = 0;
rainFilter = ctx.createBiquadFilter();
rainFilter.type = 'highpass'; rainFilter.frequency.value = 1800;
rainGain.connect(master);
loop(noiseBuf, rainGain, rainFilter);
gustGain = ctx.createGain(); gustGain.gain.value = 0;
gustFilter = ctx.createBiquadFilter();
gustFilter.type = 'bandpass'; gustFilter.frequency.value = 300; gustFilter.Q.value = 2.5;
gustGain.connect(master);
loop(noiseBuf, gustGain, gustFilter);
started = true;
},
/** One-shot filtered noise burst — the workhorse for creak/flog/thunder. */
burst({ freq, q, gain, attack, decay, type = 'bandpass' }) {
if (!started) return;
const now = ctx.currentTime;
const src = ctx.createBufferSource();
src.buffer = noiseBuf;
src.loop = true;
const f = ctx.createBiquadFilter();
f.type = type; f.frequency.value = freq; f.Q.value = q ?? 4;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, now);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), now + attack);
g.gain.exponentialRampToValueAtTime(0.0001, now + attack + decay);
src.connect(f); f.connect(g); g.connect(master);
src.start(now);
src.stop(now + attack + decay + 0.05);
},
/** @param {number} speed m/s @param {number} rain 0..1 */
setLevels(speed, rain) {
if (!started) return;
const now = ctx.currentTime;
const s = clamp01(speed / 32);
// gain and brightness both climb — a 30 m/s wind isn't just a louder 5 m/s one
windGain.gain.setTargetAtTime(0.05 + s * 0.5, now, 0.15);
windFilter.frequency.setTargetAtTime(220 + s * 900, now, 0.2);
howlGain.gain.setTargetAtTime(s * s * 0.28, now, 0.2);
windHowl.frequency.setTargetAtTime(320 + s * 700, now, 0.25);
rainGain.gain.setTargetAtTime(rain * 0.34, now, 0.3);
rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3);
},
/** Telegraph cue: you hear it coming before you feel it. */
whoosh(power, eta) {
if (!started) return;
const now = ctx.currentTime;
const p = clamp01(power / 18);
gustGain.gain.cancelScheduledValues(now);
gustGain.gain.setValueAtTime(gustGain.gain.value, now);
gustGain.gain.linearRampToValueAtTime(0.05 + p * 0.3, now + Math.max(0.05, eta));
gustGain.gain.linearRampToValueAtTime(0.0001, now + Math.max(0.05, eta) + 2.2);
gustFilter.frequency.cancelScheduledValues(now);
gustFilter.frequency.setValueAtTime(220, now);
gustFilter.frequency.linearRampToValueAtTime(240 + p * 700, now + Math.max(0.05, eta) + 0.8);
},
/** Rope creak — rate and pitch both ride the worst corner. */
creak(dt, loadFrac) {
if (!started || loadFrac < 0.35) return;
creakNext -= dt;
if (creakNext > 0) return;
creakNext = lerp(1.1, 0.16, clamp01((loadFrac - 0.35) / 0.65));
this.burst({
freq: 180 + loadFrac * 420, q: 9,
gain: 0.05 + loadFrac * 0.22, attack: 0.012, decay: 0.16,
});
},
/** Freed corner: canvas cracking itself to pieces. */
flog(dt, speed) {
if (!started) return;
flogNext -= dt;
if (flogNext > 0) return;
flogNext = Math.max(0.09, 0.5 - speed * 0.011);
this.burst({ freq: 900 + speed * 26, q: 1.2, gain: 0.1 + clamp01(speed / 30) * 0.3, attack: 0.005, decay: 0.1 });
},
thunder(power) {
if (!started) return;
this.burst({ type: 'lowpass', freq: 90 + power * 60, q: 0.7, gain: 0.25 + power * 0.5, attack: 0.06, decay: 2.6 + power * 1.6 });
},
dispose() { if (ctx) ctx.close(); started = false; },
};
}
// ---------------------------------------------------------------- skyfx
/**
* @param {object} o
* @param {THREE.Scene} o.scene
* @param {THREE.Camera} o.camera
* @param {object} o.wind from weather.js
* @param {THREE.Light} [o.sun] Lane A's directional light — we dim it
* @param {THREE.Light} [o.hemi] Lane A's hemisphere light
* @param {boolean} [o.night] storm_02 is a wild NIGHT
* @param {function} [o.onEvent] (text) HUD ticker
*/
export function createSkyFx(o = {}) {
const { scene, camera, wind } = o;
const sun = o.sun || null;
const hemi = o.hemi || null;
const def = (wind && wind.def) || {};
const skyDef = def.sky || {};
const darkness = skyDef.darkness ?? 0.7;
const scroll = skyDef.cloudScroll ?? 0.06;
// The storm data gets a say: `sky.night` is the author's call, the darkness
// threshold is only a fallback for storms that never state one.
const target = (o.night ?? skyDef.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
// Fire a flash on any gust this strong (m/s of gust power). Storms that don't
// ask stay lit only by their authored strikes.
const lightningGustPow = skyDef.lightningGustPow ?? Infinity;
const firedGusts = new Set();
const rain = createRain({ groundY: o.groundY ?? 0 });
if (scene) scene.add(rain.mesh);
const shadow = new RainShadow({ groundY: o.groundY ?? 0 });
const rainDir = new THREE.Vector3();
let shadowTick = 0;
const audio = createAudio((wind && wind.seed) || 1);
// cloud dome rides the camera so it can't clip the far plane whatever Lane A set
const domeTex = cloudTexture(256, ((wind && wind.seed) || 7) & 0xffff);
const dome = new THREE.Mesh(
new THREE.SphereGeometry(180, 24, 16),
new THREE.MeshBasicMaterial({
map: domeTex, side: THREE.BackSide, transparent: true,
depthWrite: false, fog: false, opacity: 0,
}),
);
dome.renderOrder = -1;
if (scene) scene.add(dome);
// Remember what world.js handed us, so dispose() puts it back exactly.
// Fog is captured BY VALUE, not by reference: step() mutates that very object
// in place, so `scene.fog = original.fog` restores the object we just spent a
// storm wrecking. Lane A caught it — sun and hemi came back exactly and the fog
// stayed where the storm left it. Harmless today only because the next skyfx
// immediately re-drives it, which is exactly the kind of bug that waits.
const ownsFog = !!scene && !scene.fog;
const original = {
background: scene ? scene.background : null,
fog: scene ? scene.fog : null,
fogColor: scene && scene.fog ? scene.fog.color.clone() : null,
fogNear: scene && scene.fog ? scene.fog.near : 0,
fogFar: scene && scene.fog ? scene.fog.far : 0,
sun: sun ? sun.intensity : 0,
hemi: hemi ? hemi.intensity : 0,
};
const baseSky = (scene && scene.background && scene.background.isColor)
? scene.background.clone() : CALM_SKY.clone();
const skyCol = baseSky.clone();
if (scene) {
scene.background = skyCol;
if (!scene.fog) scene.fog = new THREE.Fog(skyCol.getHex(), 30, 140);
}
let flash = 0; // decaying lightning brightness
let flashQueue = []; // {at, power} — double-strike
let lastTelegraph = null;
const camPos = new THREE.Vector3();
const w = new THREE.Vector3();
const fx = {
rain, audio, dome, shadow,
get flash() { return flash; },
/**
* 0..1 of a ground rect the sail is keeping dry, right now.
*
* Lane A: this is NOT `rig.coverageOver(bed, world.sunDir)`. That one is the
* SUN shadow — the summer-afternoon question. This is the RAIN shadow, which
* arrives along the wind, sits downwind of the cloth, and walks across the
* yard when the wind swings. During a storm at night the sun shadow is a
* number about nothing; this is the one that says whether the garden is
* getting hit. Which of the two drives garden HP is a design call, not mine —
* flagged in THREADS. Cheap either way: reads the grid we already built.
*/
rainShadowOver(rect) { return shadow.fractionOver(rect); },
/**
* Decision 7's garden-HP drain term, 0..1, in one call — the combined helper
* I offered Lane A. This is the whole of "is the bed getting hit right now":
*
* hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt;
*
* 0 = bone dry (no rain, or the cloth is over it); 1 = full downpour on open
* ground. Both terms matter and neither is enough alone: a sail over the bed
* in a downpour is 0, and a clear sky with no sail is also 0. It uses the
* grid we already rebuild for the rain, so it costs a few array reads.
*
* Note it moves on its own during storm_02: the rain shadow follows the wind,
* so the southerly change walks the dry patch off the bed and the drain
* starts climbing without a single corner having failed. That's the mechanic,
* not a bug — worth not "fixing" if it looks surprising in the HUD.
*/
gardenExposure(rect, t) {
const rain = wind.rainAt(t);
if (rain <= 0) return 0;
return rain * (1 - shadow.fractionOver(rect));
},
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
unlockAudio() { audio.unlock(); },
/**
* @param {number} dt
* @param {number} t storm time
* @param {object} [world] {sail} — duck-typed, for creak/flog
*/
step(dt, t, world = {}) {
if (!camera) return;
camera.getWorldPosition(camPos);
wind.sample(camPos, t, w);
const speed = Math.hypot(w.x, w.z);
const intensity = wind.rainAt(t);
const storminess = clamp01(Math.max(intensity, speed / 26));
// --- events: lightning + the ticker ---
for (const ev of wind.eventsBetween(t - dt, t)) {
if (ev.type === 'lightning') {
const p = ev.power ?? 0.7;
flashQueue.push({ at: t, power: p });
flashQueue.push({ at: t + 0.09 + p * 0.07, power: p * 0.55 }); // the stutter
// thunder lags the flash — distance you can hear
const delay = (ev.distance ?? 1.2) * 0.9;
flashQueue.push({ at: t + delay, power: 0, thunder: p });
} else if (ev.type === 'windchange' && ev.text && o.onEvent) {
o.onEvent(ev.text);
}
}
// --- lightning on the biggest gusts ---
// The storm's worst moments should light up, not only the three authored
// strikes. Driven off the telegraph (which is also what the HUD banner and
// the audio whoosh read), so the flash lands WITH the gust that earned it.
// Deterministic: the gust timeline is, and each gust fires at most once.
const tgl = wind.gustTelegraph(t);
if (tgl && tgl.power >= lightningGustPow) {
// key on the ramp instant — stable across frames, unique per gust
const key = Math.round((t + tgl.eta) * 100);
if (!firedGusts.has(key)) {
firedGusts.add(key);
const p = Math.min(1, 0.45 + (tgl.power - lightningGustPow) * 0.06);
flashQueue.push({ at: t + tgl.eta, power: p });
flashQueue.push({ at: t + tgl.eta + 0.08 + p * 0.06, power: p * 0.5 });
flashQueue.push({ at: t + tgl.eta + 1.1, power: 0, thunder: p });
}
}
for (let i = flashQueue.length - 1; i >= 0; i--) {
if (flashQueue[i].at <= t) {
const f = flashQueue[i];
if (f.thunder) audio.thunder(f.thunder);
else flash = Math.max(flash, f.power);
flashQueue.splice(i, 1);
}
}
flash *= Math.max(0, 1 - dt * 7);
if (flash < 0.004) flash = 0;
// --- sky ---
skyCol.copy(baseSky).lerp(target, storminess * darkness);
if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
if (scene) {
if (scene.fog) {
scene.fog.color.copy(skyCol);
scene.fog.near = lerp(40, 8, storminess);
scene.fog.far = lerp(160, 55, storminess);
}
}
if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2;
if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2;
dome.position.copy(camPos);
dome.material.opacity = storminess * 0.85;
// Tint the CLOUDS, not just the sky behind them. The dome covers the
// background at ~0.85 opacity, so darkening `scene.background` alone did
// nothing — a "wild night" still read as an overcast afternoon because the
// grey cloud texture was what you were actually looking at.
//
// Both a lerp AND a brightness crush: the lerp alone lands ~#717273 because
// it runs in linear space (84% toward night still reads mid-grey in sRGB)
// and the cloud texture is baked near-white. The crush is what makes night
// look like night. It stops at 0.78 on purpose — the yard has no lights in
// it yet, and a storm you can't see isn't a storm, it's a black screen.
const nightAmt = storminess * darkness;
dome.material.color.copy(WHITE)
.lerp(target, nightAmt * 0.92)
.multiplyScalar(1 - 0.78 * nightAmt);
// and lightning lights the cloud it's inside, which is the whole look
if (flash > 0) dome.material.color.lerp(FLASH_COL, Math.min(0.9, flash));
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
// --- rain ---
// Rebuild the shadow a few times a second, not every frame: the cloth
// moves slowly next to the rain, and this is the only part that costs.
shadowTick -= dt;
if (shadowTick <= 0) {
shadowTick = 0.1;
rainVelocity(w, intensity, rainDir);
const len = rainDir.length() || 1;
shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len);
}
rain.step(dt, camPos, w, intensity, shadow);
// --- audio ---
audio.setLevels(speed, intensity);
const tg = wind.gustTelegraph(t);
if (tg && tg !== lastTelegraph) {
// fires once per gust, right as the telegraph opens
if (!lastTelegraph || Math.abs(tg.eta - (lastTelegraph.eta - dt)) > 0.05) {
audio.whoosh(tg.power, tg.eta);
}
}
lastTelegraph = tg;
const sail = world.sail;
if (sail && sail.corners) {
let worst = 0, broken = false;
for (const c of sail.corners) {
if (c.broken) { broken = true; continue; }
const rating = c.hw && c.hw.rating ? c.hw.rating : 1;
worst = Math.max(worst, c.load / rating);
}
audio.creak(dt, worst);
if (broken) audio.flog(dt, speed);
}
},
/** Hand Lane A's scene back exactly as we found it. */
dispose() {
if (scene) {
scene.remove(rain.mesh);
scene.remove(dome);
scene.background = original.background;
if (ownsFog) {
scene.fog = null; // we brought it; we take it
} else if (original.fog) {
scene.fog = original.fog;
original.fog.color.copy(original.fogColor);
original.fog.near = original.fogNear;
original.fog.far = original.fogFar;
}
}
if (sun) sun.intensity = original.sun;
if (hemi) hemi.intensity = original.hemi;
rain.dispose();
dome.geometry.dispose();
dome.material.dispose();
domeTex.dispose();
audio.dispose();
},
};
return fx;
}