HardYards/web/world/js/skyfx.js

1033 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;
}