HardYards/web/world/js/skyfx.js
m3ultra 383471d0f5 Add debris, skyfx and the Lane C bench; align to landed contracts
debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that
spikes a corner is the one that launches the neighbour's bin. Ground bounce
via world.heightAt (contracts.js documents it as ours), sphere-vs-player
knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it
lights up when Lane B exposes nodes and stays silent until then.

skyfx.js: instanced rain that wraps around the camera rather than respawning,
storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers
(wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst
corner, flog when one blows). It modulates Lane A's lights and hands them back
on dispose() rather than owning them.

weather_demo.html: graybox bench to drive all three before M0 — mock sail,
storm scrub, 4x, throw-a-crate.

Aligned to contracts.js now that it has landed: relative three imports (there
is no importmap), contracts' rng() instead of a local copy, and storm paths
resolved off import.meta.url — server.py serves the repo root, so an absolute
/world/... would have 404'd at integration.

storm_02: fix the southerly change. contracts.js puts north at -Z and the wind
vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due
south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW
buster off the open side of the yard, into the house.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:38:21 +10:00

456 lines
17 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
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 scale = new THREE.Vector3(1, 1, 1);
const zero = new THREE.Vector3();
return {
mesh,
/** @param {THREE.Vector3} camPos @param {THREE.Vector3} w local wind */
step(dt, camPos, w, intensity) {
const n = Math.floor(max * clamp01(intensity));
mesh.count = n;
if (n === 0) return;
const fall = 9 + intensity * 4;
// rain leans into the wind; that lean IS the readout of how hard it's blowing
vel.set(w.x * 0.55, -fall, w.z * 0.55);
const speed = vel.length() || 1;
q.setFromUnitVectors(up, vel.clone().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;
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, tiled by sampling a torus-ish domain so the scroll never seams
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);
amp *= 0.5; f *= 2.07;
}
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; },
/** 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 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
const original = {
background: scene ? scene.background : null,
fog: scene ? scene.fog : null,
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,
get flash() { return flash; },
/** 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 ---
rain.step(dt, camPos, w, intensity);
// --- 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;
scene.fog = original.fog;
}
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;
}