'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); const SUN_STORM = new THREE.Color(0xc4cedb); // the noon disc gone slate (gate 2.1) // ------------------------------------------------------------ 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; // D's aftermath find (S13): count:0 with frustumCulled off and depthWrite // off still draws instance 0's identity matrix — a ~5 cm always-on-top // white sliver at the world origin, QA's "ghost near the shed table". // visible is the honest gate; count alone is not. mesh.visible = n > 0; 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. const MASTER_GAIN = 0.55; function createAudio(seed = 1) { let ctx = null, master = null; let muted = false; 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'; }, /** A's M key (gate 3.3). A flag, not just a gain write, so muting works * before unlock() has built the graph and survives it. */ setMute(on) { muted = !!on; if (master) master.gain.value = muted ? 0 : MASTER_GAIN; }, get muted() { return muted; }, /** Real bus gain, for asserts — null until unlock() builds the graph. */ get masterGain() { return master ? master.gain.value : null; }, /** 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(); // honour a mute that landed BEFORE the first gesture built the graph — // M on the splash screen must not be forgotten by the unlock master.gain.value = muted ? 0 : MASTER_GAIN; 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.6; // radians of horizon the wall spans (~149°) 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 ~12° BELOW 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 A. // // SPRINT13 gate 2.2 — why the band overshoots the equator: the old band // stopped AT the horizontal plane through the camera, but from in-yard eye // height the ground's true horizon sits ~0.6° BELOW that plane (the dip), // so a sliver of bright sky showed under the wall's base and the bank read // as a floating slab — the QA sighting, reproduced on dev_skyfx before this // fix. Overshooting to −12° buries the base behind the ground from any eye // height that matters (it survives scale.y's 0.3 floor: −37 m compressed to // −11 m is still −3.7° elevation, below the dip). The ground plane occludes // the overshoot by depth, so nothing is drawn twice. const frontGeo = new THREE.SphereGeometry(170, 24, 10, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.27); { // 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 // (now sub-horizon) bottom edge. // The 1.6 exponent is gate 2.2's other half: 0.75 left the outer tenth of // the arc at ~0.36 alpha, which from in-yard eye height is the "hard // vertical seam" the QA saw at the bank's ends. 1.6 holds the same solid // core (the arc is wider to compensate) but takes the ends to <0.12. 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)), 1.6); 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, sunColor: sun ? sun.color.clone() : null, sunRadius: sun && sun.shadow ? sun.shadow.radius : 1, }; 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 gradeNow = 0; // the storm grade, gate 2.1 — pure in t, see step() let flashQueue = []; // {at, power} — double-strike let lastTelegraph = null; const camPos = new THREE.Vector3(); const w = new THREE.Vector3(); const fx = { rain, hail, 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 — the storm grade driving sky/sun/shadow-softness (gate 2.1). * storminess with a floor under the author's darkness dial; pure in t. */ get stormGrade() { return gradeNow; }, /** 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(); }, /** * A's M key (gate 3.3): mute the whole audio bus. main.js feature-detects * this (`typeof sky.setMute === 'function'`) and only advertises M once it * exists — so this method appearing is what lights the key up. Safe at any * time: before unlock it sets a flag the unlock honours, after unlock it * writes the master gain directly. */ setMute(on) { audio.setMute(on); }, get muted() { return audio.muted; }, /** * @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)); // SPRINT13 gate 2.1 — the STORM GRADE. The QA pass played the southerly at // 65 km/h under a noon-blue sky, and the arithmetic says why: everything // below multiplied the weather by the author's palette (`darkness`), so a // darkness-0.5 storm at full blow only ever moved 0.35 toward slate — the // author's dial could zero the weather's say. The grade gives the weather // a floor: at full storminess even a darkness-0 sky goes half way to // slate, while the wild night (0.94) keeps reading as the night it is. // Same curve the rain already uses (storminess IS max(rain, wind)), pure // in t, computed above the render gate so a test can read it headless. gradeNow = storminess * lerp(0.5, 1, darkness); // --- 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, gradeNow); 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, gradeNow) + flash * 2.2; // The sun loses its noon edge two ways as the grade builds: the warm // disc goes slate (colour), and the shadows go soft (radius — the // renderer is PCFSoft, so radius is real blur, not a no-op). Softness // keys on STORMINESS, not the graded value: overcast is overcast even // under a light-palette storm, and razor-sharp noon shadows at 65 km/h // were the QA sighting this whole block answers. if (original.sunColor) sun.color.copy(original.sunColor).lerp(SUN_STORM, gradeNow * 0.85); if (sun.shadow) sun.shadow.radius = lerp(original.sunRadius, 7, storminess); } if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, gradeNow) + 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 = gradeNow; 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 (original.sunColor) sun.color.copy(original.sunColor); if (sun.shadow) sun.shadow.radius = original.sunRadius; } 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; }