1033 lines
45 KiB
JavaScript
1033 lines
45 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 , hailBlockFor, smoothstep } 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);
|
||
}
|
||
|
||
// Hail falls STEEP, and that is the whole of decision 13. A 1 cm stone's
|
||
// terminal velocity is ~22 m/s (a raindrop's is ~9), and a dense stone couples
|
||
// only weakly to the crosswind, so even a 30 m/s gale leans it no more than
|
||
// ~20° off vertical — where a sail overhead still blocks it. (Rain at 9 m/s in
|
||
// the same gale comes in at atan(30/9) ≈ 73°, nearly sideways, which is why it
|
||
// walks under the sail and can't score the rig.) Do NOT re-open the rain-angle
|
||
// argument here; steep is the point.
|
||
const HAIL_FALL = 22; // m/s terminal, ~1 cm ice
|
||
const HAIL_LEAN_COUPLING = 0.3; // dense stones catch little wind
|
||
const HAIL_MAX_LEAN = Math.tan(20 * Math.PI / 180); // cap ~20° off vertical
|
||
|
||
/** Hail velocity, m/s. Steep — see the note above. Used by stones and shadow. */
|
||
function hailVelocity(w, out) {
|
||
const cap = HAIL_FALL * HAIL_MAX_LEAN;
|
||
let hx = w.x * HAIL_LEAN_COUPLING, hz = w.z * HAIL_LEAN_COUPLING;
|
||
const mag = Math.hypot(hx, hz);
|
||
if (mag > cap) { const s = cap / mag; hx *= s; hz *= s; }
|
||
return out.set(hx, -HAIL_FALL, hz);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- hail
|
||
// Instanced falling stones, same wrap-around-the-camera trick as the rain but
|
||
// fewer, whiter, faster and much steeper. Stones under the cloth are hidden so
|
||
// you SEE the sail doing its job. Count scales with intensity; a hail-free storm
|
||
// draws nothing.
|
||
function createHail(opts) {
|
||
const max = opts.maxStones ?? 1300;
|
||
const half = opts.half ?? 16;
|
||
const height = opts.height ?? 22;
|
||
const groundY = opts.groundY ?? 0;
|
||
const rand = rng(0x4a11);
|
||
|
||
const geo = new THREE.BoxGeometry(0.05, 0.05, 0.05); // a little cube reads as a stone
|
||
const mat = new THREE.MeshBasicMaterial({
|
||
color: 0xeaf2ff, transparent: true, opacity: 0.9, depthWrite: false, fog: false,
|
||
});
|
||
const mesh = new THREE.InstancedMesh(geo, mat, max);
|
||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||
mesh.frustumCulled = false;
|
||
mesh.renderOrder = 3;
|
||
mesh.count = 0;
|
||
|
||
const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max);
|
||
const jit = 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;
|
||
jit[i] = 0.85 + rand() * 0.3;
|
||
}
|
||
|
||
const m = new THREE.Matrix4();
|
||
const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);
|
||
const top = groundY + height;
|
||
|
||
return {
|
||
mesh,
|
||
step(dt, camPos, vel, intensity, size, shadow) {
|
||
const n = Math.floor(max * clamp01(intensity));
|
||
mesh.count = n;
|
||
if (n === 0) return;
|
||
const s = 0.6 + size * 0.9; // bigger stones read bigger
|
||
m.makeScale(s, s, s);
|
||
for (let i = 0; i < n; i++) {
|
||
const j = jit[i];
|
||
px[i] += vel.x * j * dt;
|
||
py[i] += vel.y * j * dt; // vel.y is negative
|
||
pz[i] += vel.z * j * dt;
|
||
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;
|
||
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(); },
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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 hailGain, hailFilter, drumGain, drumFilter;
|
||
let frontGain, frontFilter;
|
||
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);
|
||
|
||
// hail clatter: bright, hard highpass — ice on concrete
|
||
hailGain = ctx.createGain(); hailGain.gain.value = 0;
|
||
hailFilter = ctx.createBiquadFilter();
|
||
hailFilter.type = 'highpass'; hailFilter.frequency.value = 3000;
|
||
hailGain.connect(master);
|
||
loop(noiseBuf, hailGain, hailFilter);
|
||
|
||
// the DRUM: hail on taut cloth, a low resonant thrum. This is the "my sail
|
||
// is earning its money" sound — it only speaks when the sail is actually
|
||
// catching hail, so a rig over the bed sounds different from bare sky.
|
||
drumGain = ctx.createGain(); drumGain.gain.value = 0;
|
||
drumFilter = ctx.createBiquadFilter();
|
||
drumFilter.type = 'bandpass'; drumFilter.frequency.value = 140; drumFilter.Q.value = 3;
|
||
drumGain.connect(master);
|
||
loop(noiseBuf, drumGain, drumFilter);
|
||
|
||
// the change front's distant rumble: very low, very slow — a storm you
|
||
// can hear over the fence but not yet feel (SPRINT12 gate 3.4)
|
||
frontGain = ctx.createGain(); frontGain.gain.value = 0;
|
||
frontFilter = ctx.createBiquadFilter();
|
||
frontFilter.type = 'lowpass'; frontFilter.frequency.value = 85;
|
||
frontGain.connect(master);
|
||
loop(noiseBuf, frontGain, frontFilter);
|
||
|
||
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);
|
||
},
|
||
|
||
/**
|
||
* @param {number} intensity 0..1 hail
|
||
* @param {number} onCloth 0..1 of the hail the sail overhead is catching
|
||
* @param {number} size stone-size scalar — bigger stones drum lower
|
||
*/
|
||
setHail(intensity, onCloth, size) {
|
||
if (!started) return;
|
||
const now = ctx.currentTime;
|
||
// clatter fades as the cloth intercepts more of the storm — some ice still
|
||
// reaches the ground past the sail, but the open-ground roar drops
|
||
hailGain.gain.setTargetAtTime(intensity * (1 - onCloth * 0.7) * 0.3, now, 0.15);
|
||
hailFilter.frequency.setTargetAtTime(2400 + (2 - size) * 700, now, 0.2);
|
||
// the drum rises exactly as the sail catches hail — the payoff sound
|
||
drumGain.gain.setTargetAtTime(intensity * onCloth * 0.5, now, 0.12);
|
||
drumFilter.frequency.setTargetAtTime(110 + (2 - size) * 45, now, 0.2);
|
||
},
|
||
|
||
/** @param {number} level 0..1 change-front progress — the approaching rumble. */
|
||
setFront(level) {
|
||
if (!started) return;
|
||
// slow time-constant on purpose: a front swells, it doesn't tick
|
||
frontGain.gain.setTargetAtTime(level * 0.2, ctx.currentTime, 0.5);
|
||
},
|
||
|
||
/** 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;
|
||
|
||
// hail rides its own steep shadow — that's the whole of decision 13: the sail
|
||
// blocks steep hail (shadow ≈ its footprint) where it can't block slanted rain.
|
||
const hail = createHail({ groundY: o.groundY ?? 0 });
|
||
if (scene) scene.add(hail.mesh);
|
||
const hailShadow = new RainShadow({ groundY: o.groundY ?? 0 });
|
||
const hailDir = new THREE.Vector3();
|
||
const hailWind = new THREE.Vector3();
|
||
let hailTick = 0, hailAmt = 0;
|
||
|
||
// hailAt lives behind the wind router (Lane A's allowlist). If a stale router
|
||
// doesn't forward it, degrade to no hail rather than crashing — but it must be
|
||
// forwarded or the garden score (decision 13) is inert. Flagged to A in THREADS.
|
||
const hailIntensity = (t) => (wind && typeof wind.hailAt === 'function' ? wind.hailAt(t) : 0);
|
||
// what the sail is made of, refreshed from step()'s {sail} — 0 (membrane) until told otherwise
|
||
let sailPorosity = 0;
|
||
const hailStone = () => (wind && wind.hailSize != null ? wind.hailSize : 1);
|
||
|
||
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);
|
||
|
||
// ------------------------------------------------------ the change front
|
||
// SPRINT12 gate 3.4 — "the change announces itself." Every windchange event
|
||
// gets an IN-WORLD tell: a dark front wall on the horizon, standing in the
|
||
// quarter the new wind will come FROM, rising from a smudge to a wall across
|
||
// the FRONT_LEAD seconds before the change and clearing overhead after it.
|
||
// A real southerly buster looks exactly like this — the shelf cloud arrives
|
||
// before the wind does — so the tell is weather, not UI.
|
||
//
|
||
// Why this telegraphs without spoiling: it's a continuous growth with no
|
||
// discrete pop, so a player watching the YARD learns "that wall means the
|
||
// swing" on night 2 (storm_03, change at 30 s, wall from ~18 s) — and on
|
||
// night 3 the SAME wall is already standing at t≈6, which is the corner
|
||
// block's whole thesis ("looks like night 2 and isn't") made visible. The
|
||
// exact moment stays the storm's secret; the direction and the approach do
|
||
// not, because in the real sky they never are.
|
||
//
|
||
// Deterministic off (dt, t) like everything here: progress is a pure
|
||
// function of t, and the wall's azimuth comes from dirAt() sampled at a
|
||
// fixed post-change instant — def + seed in, same wall out, every run.
|
||
const FRONT_LEAD = 12; // s before the change the wall starts rising
|
||
const FRONT_FADE = 8; // s after the swing completes for it to clear
|
||
const FRONT_ARC = 2.2; // radians of horizon the wall spans (~126°)
|
||
const FRONT_MAX_OP = 0.85;
|
||
const frontEvents = (def.events || [])
|
||
.filter((e) => e.type === 'windchange' && Number.isFinite(e.t))
|
||
.map((e) => ({ t0: e.t, over: e.over ?? 6, az: null }));
|
||
// Band from ~34° above the horizon down to it, centred (in local space) on
|
||
// azimuth π: SphereGeometry's (x,z) = (-cosφ, sinφ)·sinθ, so φ=0 sits at
|
||
// atan2(z,x) = π. rotation.y = π − A then carries the centre to azimuth A.
|
||
const frontGeo = new THREE.SphereGeometry(170, 24, 8, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.20);
|
||
{
|
||
// Soft edges via vertex alpha, or the band reads as a floating rectangle
|
||
// (checked by eye on dev_skyfx.html — the hard phi/theta cut was exactly
|
||
// that). Full at the horizon and the arc's centre; fading to nothing at
|
||
// the arc ends and across the top, so it sits ON the horizon like a bank
|
||
// of weather instead of hanging in the sky like a screen.
|
||
// Sphere uv: x runs 0..1 across the arc, y is 1 at the band top, 0 at the
|
||
// horizon edge.
|
||
const uv = frontGeo.attributes.uv;
|
||
const rgba = new Float32Array(uv.count * 4);
|
||
for (let i = 0; i < uv.count; i++) {
|
||
const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 0.75);
|
||
const up = 1 - smoothstep(0.45, 1, uv.getY(i));
|
||
rgba[i * 4] = 1; rgba[i * 4 + 1] = 1; rgba[i * 4 + 2] = 1;
|
||
rgba[i * 4 + 3] = across * up;
|
||
}
|
||
frontGeo.setAttribute('color', new THREE.BufferAttribute(rgba, 4));
|
||
}
|
||
const frontMesh = new THREE.Mesh(
|
||
frontGeo,
|
||
new THREE.MeshBasicMaterial({
|
||
color: 0x0c1016, side: THREE.BackSide, transparent: true, vertexColors: true,
|
||
opacity: 0, depthWrite: false, fog: false,
|
||
}),
|
||
);
|
||
frontMesh.renderOrder = -1; // with the dome; nearer radius wins the overlay
|
||
frontMesh.visible = false;
|
||
if (scene) scene.add(frontMesh);
|
||
let frontLevel = 0, frontRise = 0, frontAz = null;
|
||
|
||
// 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, hailShadow, front: frontMesh,
|
||
get flash() { return flash; },
|
||
/** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */
|
||
get hailAmount() { return hailAmt; },
|
||
/** 0..1 — how far risen the change-front wall is (gate 3.4). Pure in t. */
|
||
get changeFront() { return frontLevel; },
|
||
/** Azimuth (radians, XZ) the front stands in — the quarter the new wind
|
||
* comes FROM — or null while no front is up. For D's judging and asserts. */
|
||
get changeFrontAz() { return frontAz; },
|
||
|
||
/**
|
||
* 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));
|
||
},
|
||
|
||
/** 0..1 of a rect the sail is keeping hail off, this frame (steep shadow). */
|
||
hailShadowOver(rect) { return hailShadow.fractionOver(rect); },
|
||
|
||
/**
|
||
* Decision 13's garden-damage feed, 0..1, the gardenExposure mold but for
|
||
* HAIL — this is what makes the garden score respond to the rig. Steep stones
|
||
* mean the sail's shadow ≈ its footprint, so a rig over the bed genuinely
|
||
* shelters it even in a gale, unlike rain (which walks under and can't score
|
||
* the sail — the whole reason a perfect rig used to tie with no rig at all).
|
||
*
|
||
* hp -= sky.gardenHailExposure(bed, t) * HAIL_DAMAGE * dt
|
||
* + sky.gardenExposure(bed, t) * SMALL_RAIN_DRAIN * dt; // A wires both
|
||
*
|
||
* 0 = no hail, or the cloth is catching it; 1 = full burst on the open bed.
|
||
*/
|
||
gardenHailExposure(rect, t) {
|
||
const h = hailIntensity(t);
|
||
if (h <= 0) return 0;
|
||
// The cloth only blocks what it can catch. hailBlockFor is Lane C's ruling
|
||
// (weather.core): porosity is about AIR and WATER, not ice — a 2 cm stone
|
||
// cannot pass a 2 mm weave, so porous and membrane stop the big hail
|
||
// identically, and the ONE real difference is the finest pea hail
|
||
// rattling through an open knit. Wired here because this is the only
|
||
// place that knows both the stone size and what the sail is made of.
|
||
// Measured: storm_02 (1.3) and the ice night (1.4) -> shade cloth blocks
|
||
// 100%; the southerly's pea hail (0.7) -> it blocks 74%, and THAT is the
|
||
// night membrane earns its keep. Lane B, SPRINT9 fabric landing.
|
||
const size = (wind && wind.def && wind.def.hail && wind.def.hail.size) || 1;
|
||
const block = hailBlockFor(size, sailPorosity);
|
||
return h * (1 - hailShadow.fractionOver(rect) * block);
|
||
},
|
||
|
||
/** 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
|
||
*
|
||
* Runs headless. The camera is a RENDERING concern and must never gate the
|
||
* shadow grids — see the note at the physics/view split below.
|
||
*/
|
||
step(dt, t, world = {}) {
|
||
// A camera means "there is a view to place things in", not "the weather is
|
||
// real". This used to read `if (!camera) return;` at the top, which meant a
|
||
// harness with no camera silently skipped the shadow rebuild and then
|
||
// scored every rig as if the sail did not exist — hp 36 was the bare-bed
|
||
// constant, and it cost gate 0 two sprints of four lanes' time before A
|
||
// found it. The grids are physics; only the view and the audio are the
|
||
// camera's business. Fall back to the yard centre so the wind still has a
|
||
// place to be sampled.
|
||
const rendering = !!camera;
|
||
if (rendering) camera.getWorldPosition(camPos);
|
||
else camPos.set(0, 1.7, 0);
|
||
// Refresh what the sail is made of — gardenHailExposure needs it, and a
|
||
// re-rig between phases can change the fabric. Read live rather than
|
||
// cached at construction, for the same reason the shadow grids are rebuilt
|
||
// every step: this must never quietly describe a sail that isn't there.
|
||
if (world.sail && Number.isFinite(world.sail.porosity)) sailPorosity = world.sail.porosity;
|
||
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;
|
||
|
||
// --- PHYSICS: the shadow grids. Camera or not, always. ---
|
||
// Everything that scores a rig — gardenExposure, gardenHailExposure,
|
||
// rainShadowOver, hailShadowOver — reads these grids. They are what the
|
||
// sail DOES, so they rebuild before any view work and above the render
|
||
// gate. Rebuilt a few times a second, not every frame: the cloth moves
|
||
// slowly next to the weather, and this is the only part that costs.
|
||
shadowTick -= dt;
|
||
if (shadowTick <= 0) {
|
||
shadowTick = 0.1;
|
||
rainVelocity(w, intensity, rainDir);
|
||
const rl = rainDir.length() || 1;
|
||
shadow.update(world.sail, rainDir.x / rl, rainDir.y / rl, rainDir.z / rl);
|
||
}
|
||
hailAmt = hailIntensity(t);
|
||
const stone = hailStone();
|
||
// The hail shadow follows the STEEP hail vector, not the wind. It barely
|
||
// moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
|
||
hailTick -= dt;
|
||
if (hailTick <= 0) {
|
||
hailTick = 0.12;
|
||
hailWind.set(w.x, 0, w.z);
|
||
hailVelocity(hailWind, hailDir);
|
||
const hl = hailDir.length() || 1;
|
||
hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl);
|
||
}
|
||
|
||
// --- the change front: progress is pure in t; the view applies it below.
|
||
// Computed above the render gate so a headless HUD (or a test) can read
|
||
// changeFront the same way it reads hailAmount.
|
||
frontLevel = 0; frontRise = 0; frontAz = null;
|
||
for (const ev of frontEvents) {
|
||
const rise = smoothstep(ev.t0 - FRONT_LEAD, ev.t0, t);
|
||
const lvl = rise * (1 - smoothstep(ev.t0 + ev.over, ev.t0 + ev.over + FRONT_FADE, t));
|
||
if (lvl > frontLevel) {
|
||
// the settled post-change heading, sampled at a FIXED instant so the
|
||
// wall doesn't wander with the gusts; +π = the quarter it comes FROM
|
||
if (ev.az == null && typeof wind.dirAt === 'function') {
|
||
ev.az = wind.dirAt(ev.t0 + ev.over + 4) + Math.PI;
|
||
}
|
||
frontLevel = lvl; frontRise = rise; frontAz = ev.az;
|
||
}
|
||
}
|
||
|
||
// Past here is the VIEW and the NOISE — drops to place, a dome to tint, a
|
||
// gale to hear. All of it needs somewhere to stand. A headless harness has
|
||
// the numbers it came for and can stop here.
|
||
if (!rendering) return;
|
||
|
||
// --- 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;
|
||
|
||
// --- the front wall (progress computed above the render gate) ---
|
||
// Grows upward as it rises (scale.y keys the RISE, so it keeps its full
|
||
// height while fading through the overhead pass), darkens as it comes.
|
||
// Deliberately a silhouette: no flash tint — lightning brightens the sky
|
||
// BEHIND it, which is what makes a shelf cloud read as a wall.
|
||
frontMesh.visible = frontLevel > 0.002;
|
||
if (frontMesh.visible) {
|
||
frontMesh.position.copy(camPos);
|
||
if (frontAz != null) frontMesh.rotation.y = Math.PI - frontAz;
|
||
frontMesh.scale.y = 0.3 + 0.7 * frontRise;
|
||
frontMesh.material.opacity = frontLevel * FRONT_MAX_OP;
|
||
}
|
||
|
||
// --- rain + hail drops (the grids they read were built above) ---
|
||
rain.step(dt, camPos, w, intensity, shadow);
|
||
hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow);
|
||
// how much of the hail the sail overhead is catching, for the drum sound —
|
||
// sample right above the player/camera so it's "is it drumming over ME"
|
||
const onCloth = hailAmt > 0 ? hailShadow.fractionOver({ x: camPos.x, z: camPos.z, w: 3, d: 3 }) : 0;
|
||
|
||
// --- audio ---
|
||
audio.setLevels(speed, intensity);
|
||
audio.setHail(hailAmt, onCloth, stone);
|
||
// the front carries its own distant rumble — you HEAR the change coming
|
||
// the way you see it, and both are the same pure function of t
|
||
audio.setFront(frontLevel);
|
||
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; }
|
||
// Effective rating is hardware × anchor hint (SPRINT12, B's wiring):
|
||
// a carport corner fails at 0.22× its shackle, and a creak keyed on
|
||
// the shackle alone would go quiet exactly where the steel is worst.
|
||
const hint = c.anchor && c.anchor.ratingHint != null ? c.anchor.ratingHint : 1;
|
||
const rating = (c.hw && c.hw.rating ? c.hw.rating : 1) * hint;
|
||
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(hail.mesh);
|
||
scene.remove(dome);
|
||
scene.remove(frontMesh);
|
||
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();
|
||
hail.dispose();
|
||
dome.geometry.dispose();
|
||
dome.material.dispose();
|
||
frontMesh.geometry.dispose();
|
||
frontMesh.material.dispose();
|
||
domeTex.dispose();
|
||
audio.dispose();
|
||
},
|
||
};
|
||
|
||
return fx;
|
||
}
|