`ready` only meant the graph got built — a suspended AudioContext is still silent, so there was no way to tell whether the storm was actually audible. The HUD now reports the real context state. Verified through it: context runs on first gesture; 7.5 -> 17.8 m/s takes the wind bed 0.16 -> 0.36 gain while the howl layer goes 0.016 -> 0.104 and the cutoff opens 428 -> 767 Hz, so a gale reads as a gale and not just a louder breeze. Rain tracks its curve 0.03 -> 0.32. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
471 lines
17 KiB
JavaScript
471 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 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 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;
|
|
}
|