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