The garden visibly stays dry under the sail. 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 bed, free drama and honest physics. Cheap on purpose. Ray-testing 3k drops against 162 triangles every frame is ~486k intersections for an effect nobody inspects closely. Instead project the sail's triangles ALONG the rain onto the ground into a coarse height grid, a few times a second (the cloth moves slowly next to the rain); per-drop cost is one grid read, and occluded drops get a zero-scale matrix rather than a raycast. Measured 0.041 ms/rebuild, 10x/s = 0.41 ms/s against A's 0.63 ms frame — negligible. Reads rig.pos/rig.tris, so nothing new needed from Lane B. Verified in the real game with a surviving twisted rated rig: 497 grid cells covered, 96% of the garden bed under cover, live drops culled under the cloth and falling in the open yard either side. Screenshot for DESIGN.md. skyfx exposes rainShadowOver(rect) — NOT the same as rig.coverageOver(bed, sunDir). That one is the SUN shadow; this is the RAIN shadow (down the wind). Which drives garden HP is a design call — flagged for A in THREADS. Selftest 134/0/0 (8 new rain-shadow asserts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
634 lines
24 KiB
JavaScript
634 lines
24 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);
|
|
|
|
// ------------------------------------------------------------ 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;
|
|
const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
|
|
|
|
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); },
|
|
|
|
/** 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);
|
|
}
|
|
}
|
|
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(new THREE.Color(0xdfe8ff), 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;
|
|
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;
|
|
}
|