diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index 78982be..91299ac 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -7,6 +7,8 @@ "_comment": "Sustained builds 7 -> 20 m/s (25 -> 72 km/h); worst gusts land near 33 m/s (~120 km/h), which is BOM 'destructive' and is what shreds a flat drum-tight cheap rig. Peak arrives just AFTER the southerly change, so the corners that were slack all storm are the ones that cop it.", + "_dirCurve_comment": "Radians in the XZ plane; the wind blows TOWARD (cos d, sin d). contracts.js puts north at -Z, so a southerly (blowing toward the north, into the house) needs sin(d) < 0. Starts ~0.85 = blowing toward the SE, i.e. the hot NW'er before a change; slews to ~-1.35 = blowing toward the NNE, i.e. a SSW buster off the open south side of the yard. 55->59s is the slew: ~110 deg in four seconds.", + "baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]], "gusts": { @@ -18,7 +20,7 @@ "powRamp": 7 }, - "dirCurve": [[0, 0.85], [50, 0.95], [55, 1.2], [59, 2.45], [70, 2.6], [90, 2.5]], + "dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]], "dirWander": { "amp": 0.25, "rate": 0.13 }, "spatial": { "amp": 0.2, "scale": 11, "advect": 0.5 }, diff --git a/web/world/js/debris.js b/web/world/js/debris.js new file mode 100644 index 0000000..79261cc --- /dev/null +++ b/web/world/js/debris.js @@ -0,0 +1,230 @@ +'use strict'; +// SHADES — Lane C — debris: things that should have been tied down. +// +// Hand-rolled kinematic tumble (PLAN3D §5-C.4). No physics engine, no deps. +// Deterministic: step(dt, t), seeded RNG, no Date.now — so a storm replays. +// +// Spawns off `debris` events in the storm JSON, upwind of the yard, and lets the +// wind field carry it across. Drag goes with speed², same as the sail, so the +// same gust that spikes a corner is the one that launches the neighbour's bin. + +import * as THREE from '../vendor/three.module.js'; +import { rng } from './contracts.js'; + +const RHO = 1.2; // air density, kg/m³ +const GRAVITY = -9.81; + +// Fallback specs for Lane E's debris set (3D-STORE crates/tubs). Radius is the +// collision sphere, not the render bounds — a crate is boxy, but a sphere is +// what you can afford to test 6 of per node per frame. +const MODEL_SPEC = { + BlueCrate_v2: { r: 0.30, mass: 9, cd: 1.05 }, + BlackTub_v2: { r: 0.34, mass: 5, cd: 1.10 }, + WhiteTub_v2: { r: 0.34, mass: 5, cd: 1.10 }, + WoodenBin_v2: { r: 0.42, mass: 14, cd: 1.05 }, + LibraryTrolley_v1: { r: 0.45, mass: 22, cd: 0.95 }, +}; +const DEFAULT_SPEC = { r: 0.35, mass: 8, cd: 1.05 }; + +/** + * @param {object} o + * @param {object} o.wind from weather.js + * @param {THREE.Object3D} o.scene + * @param {Object} [o.models] name -> template (Lane E's GLBs) + * @param {function} [o.onHitPlayer] (piece, impact) — Lane D knocks the player down + * @param {function} [o.onEvent] (text) — HUD ticker + * @param {object} [o.bounds] {x, z} half-extents before despawn + */ +export function createDebris(o = {}) { + const wind = o.wind; + const scene = o.scene || null; + const models = o.models || {}; + const bounds = o.bounds || { x: 26, z: 20 }; + // contracts.js documents world.heightAt as the thing Lane C bounces debris off. + // Flat fallback so this still runs against a graybox yard. + const groundAt = o.heightAt || (() => o.groundY ?? 0); + const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e); + + const pieces = []; + const w = new THREE.Vector3(); + const probe = new THREE.Vector3(); + + /** Graybox stand-in so a missing GLB can't break Lane A's merge. */ + function placeholder(spec) { + const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8); + const m = new THREE.MeshStandardMaterial({ color: 0x8a6a3a, roughness: 0.9 }); + return new THREE.Mesh(g, m); + } + + function spawn(ev, t) { + const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) }; + if (Number.isFinite(ev.mass)) spec.mass = ev.mass; + + // upwind of the yard, offset sideways, so it crosses the whole thing + const d = wind.dirAt(t); + const dx = Math.cos(d), dz = Math.sin(d); + const lat = ev.lateral ?? 0; + const dist = ev.spawnDist ?? 18; + const x = -dx * dist - dz * lat; + const z = -dz * dist + dx * lat; + const y = groundAt(x, z) + spec.r + (ev.height ?? 0.2 + rand() * 1.2); + + const tmpl = models[ev.model]; + const mesh = tmpl ? tmpl.clone(true) : placeholder(spec); + mesh.castShadow = true; + if (scene) scene.add(mesh); + + // already moving — it's been blowing across the neighbour's yard for a while + probe.set(x, y, z); + wind.sample(probe, t, w); + + const piece = { + model: ev.model, + x, y, z, + vx: w.x * 0.8, vy: 0, vz: w.z * 0.8, + // spin axis is arbitrary but seeded; rate scales with airspeed in step() + sx: rand() * 2 - 1, sy: rand() * 2 - 1, sz: rand() * 2 - 1, + spin: 0, + r: spec.r, mass: spec.mass, cd: spec.cd, + area: Math.PI * spec.r * spec.r, + hitPlayer: false, + mesh, + alive: true, + }; + pieces.push(piece); + if (ev.text && o.onEvent) o.onEvent(ev.text); + return piece; + } + + function despawn(p) { + p.alive = false; + if (scene && p.mesh) scene.remove(p.mesh); + } + + const debris = { + get pieces() { return pieces; }, + + /** Lane E's GLBs, once they land. name -> Object3D template. */ + setModels(map) { Object.assign(models, map); return debris; }, + + /** Manual spawn — handy for tuning and for Lane A's debug keys. */ + spawn, + + /** + * @param {number} dt fixed step + * @param {number} t storm time + * @param {object} [world] {player, sail} — both optional, both duck-typed + */ + step(dt, t, world = {}) { + // storm JSON drives the spawns; poll the window so nothing is missed + if (wind) { + for (const ev of wind.eventsBetween(t - dt, t)) { + if (ev.type === 'debris') spawn(ev, t); + } + } + + const player = world.player; + const sail = world.sail; + + for (let i = pieces.length - 1; i >= 0; i--) { + const p = pieces[i]; + + probe.set(p.x, p.y, p.z); + wind.sample(probe, t, w); + + // drag against the AIR, not the ground: F = ½ρ Cd A |w-v| (w-v) + const rx = w.x - p.vx, ry = w.y - p.vy, rz = w.z - p.vz; + const rel = Math.hypot(rx, ry, rz); + const k = 0.5 * RHO * p.cd * p.area * rel / p.mass; + p.vx += rx * k * dt; + p.vy += ry * k * dt + GRAVITY * dt; + p.vz += rz * k * dt; + + p.x += p.vx * dt; + p.y += p.vy * dt; + p.z += p.vz * dt; + + // ground: bounce, shed sideways speed, keep skittering + const floor = groundAt(p.x, p.z) + p.r; + if (p.y <= floor) { + p.y = floor; + if (p.vy < 0) p.vy = -p.vy * 0.32; // dead-ish bounce, it's a plastic tub + if (Math.abs(p.vy) < 0.35) p.vy = 0; + p.vx *= 0.86; p.vz *= 0.86; // scrape + } + + // tumble rate follows airspeed — becalmed debris shouldn't keep spinning + p.spin += rel * 0.35 * dt; + if (p.mesh) { + p.mesh.position.set(p.x, p.y, p.z); + p.mesh.rotation.set(p.sx * p.spin, p.sy * p.spin, p.sz * p.spin); + } + + // --- sphere vs player: knockdown --- + // Contract gives us player.pos; the knockdown itself is Lane D's (§5-D.3), + // so we just report the hit and let them run the state machine. + if (player && player.pos && !p.hitPlayer) { + const px = player.pos.x, pz = player.pos.z; + const py = player.pos.y + 0.9; // centre of mass, not feet + const dsq = (p.x - px) ** 2 + (p.y - py) ** 2 + (p.z - pz) ** 2; + const hit = p.r + 0.35; + if (dsq < hit * hit) { + const impact = Math.hypot(p.vx, p.vy, p.vz) * p.mass; + // a bin rolling gently past your ankles shouldn't floor you + if (impact > 25 && o.onHitPlayer) { + p.hitPlayer = true; // one knockdown per piece + o.onHitPlayer(p, impact); + p.vx *= 0.4; p.vz *= 0.4; + } + } + } + + // --- sphere vs sail nodes: impulse --- + // Duck-typed: lights up the moment Lane B exposes nodes, silent until + // then. See THREADS — B owns sail.js, so this is the seam we agreed on. + if (sail && sail.nodes) applyToSail(p, sail); + + if (p.y < groundAt(p.x, p.z) - 5 || Math.abs(p.x) > bounds.x || Math.abs(p.z) > bounds.z) { + despawn(p); + pieces.splice(i, 1); + } + } + }, + + /** Drop everything (phase change, restart). */ + clear() { + for (const p of pieces) despawn(p); + pieces.length = 0; + }, + }; + + /** + * Shove any cloth node the piece is intersecting, and lose some of the piece's + * own momentum doing it. Expects sail.nodes: [{x,y,z,px,py,pz}] (verlet, so we + * move position and let the integrator turn it into velocity). + */ + function applyToSail(p, sail) { + const nodes = sail.nodes; + const reach = p.r + 0.15; + const reachSq = reach * reach; + let hits = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + const dx = n.x - p.x, dy = n.y - p.y, dz = n.z - p.z; + const dsq = dx * dx + dy * dy + dz * dz; + if (dsq > reachSq || dsq < 1e-9) continue; + const d = Math.sqrt(dsq); + // push the node out to the sphere surface along the contact normal + const push = (reach - d) / d; + n.x += dx * push; n.y += dy * push; n.z += dz * push; + hits++; + } + if (hits) { + const drag = Math.min(0.5, (hits * p.mass) / 400); + p.vx *= 1 - drag; p.vy *= 1 - drag; p.vz *= 1 - drag; + if (sail.onDebrisHit) sail.onDebrisHit(p, hits); + } + } + + return debris; +} diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js new file mode 100644 index 0000000..d79864b --- /dev/null +++ b/web/world/js/skyfx.js @@ -0,0 +1,455 @@ +'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; +} diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 872bf59..a74f905 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -1,25 +1,81 @@ /** * Lane C selftests — wind field, storm timelines, rain, debris. * - * Lane C owns this file. Lane A pre-created it so adding your suite never means - * editing selftest.html. + * The asserts live in weather.selftest.js as a plain case list, because they + * import nothing but weather.core.js (no THREE, no DOM) and so also run under + * `node web/world/js/tests/run-node.mjs` — a one-second loop for tuning a storm + * curve, instead of a browser round trip. This file is the browser half: it + * feeds them the fetched storms and adds the checks that need the real + * weather.js adapter (contract shape, THREE.Vector3 out). * - * The asserts PLAN3D §5-C asks for, once weather.js lands: - * 1. gust telegraph lead ≥ 1.2 s — the promise the storm rests on. Lane A's - * suite already asserts this against the stub wind (see a.test.js, - * 'gust telegraph always gives at least 1.2 s of warning'); lift that test - * onto the real wind.sample and delete the stub version's claim to it. - * 2. wind.sample continuity — no frame-to-frame jump beyond a sane bound, or - * the cloth explodes and it looks like Lane B's bug. - * 3. storm JSON schema validation for everything in data/storms/. - * - * Useful imports: - * import { FIXED_DT, STORM_LEN } from '../contracts.js'; - * import { assert, assertLess, fixedLoop } from '../testkit.js'; - * wind.sample(pos, t) must be pure in (pos, t): selftest samples out of order. + * Lane A: a.test.js's 'gust telegraph always gives at least 1.2 s of warning' + * is lifted onto the real wind below, per your note — the stub's claim to it is + * now redundant and yours to drop whenever suits. Logged in THREADS. */ +import * as THREE from '../../vendor/three.module.js'; +import { assert, fixedLoop } from '../testkit.js'; +import { FIXED_DT, checkContract } from '../contracts.js'; +import { loadStorm, createWind } from '../weather.js'; +import { weatherCases } from './weather.selftest.js'; + +const STORMS = ['storm_01_gentle', 'storm_02_wildnight']; + /** @param {import('../testkit.js').Suite} t */ -export default function run(t) { - t.skip('weather.js not landed yet — Lane C'); +export default async function run(t) { + const storms = {}; + for (const name of STORMS) storms[name] = await loadStorm(name); + + // --- the shared case list (also runs headless in node) --- + const { cases } = weatherCases(storms); + for (const c of cases) t.test(c.name, c.fn); + + // --- the bits that need the THREE adapter, not just the core --- + + t.test('weather.js satisfies the wind contract', () => { + const wind = createWind(storms.storm_02_wildnight); + const problems = checkContract('wind', wind); + assert(problems.length === 0, problems.join('; ')); + }); + + t.test('sample() returns a Vector3 and honours an out param', () => { + const wind = createWind(storms.storm_02_wildnight); + const pos = new THREE.Vector3(3, 0, -2); + const a = wind.sample(pos, 12.5); + assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3'); + assert(a.y === 0, `wind should be horizontal, got y=${a.y}`); + // out param must not change the answer, only where it lands + const out = new THREE.Vector3(); + const b = wind.sample(pos, 12.5, out); + assert(b === out, 'out param was ignored'); + assert(a.x === b.x && a.z === b.z, 'out param changed the result'); + }); + + // Lifted from a.test.js onto the real wind (Lane A's note in this file's + // header). This is the promise the whole storm rests on: the player can + // always react. Deliberately walks the public surface, not the core. + t.test('gust telegraph always gives at least 1.2 s of warning', () => { + const wind = createWind(storms.storm_02_wildnight); + let had = false, edges = 0; + fixedLoop(wind.duration, FIXED_DT, (dt, time) => { + const tel = wind.gustTelegraph(time); + if (tel && !had) { + edges++; + assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`); + assert(Number.isFinite(tel.power) && tel.power > 0, 'telegraph carried no power'); + assert(Number.isFinite(tel.dir), 'telegraph carried no direction'); + } + had = !!tel; + }); + assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`); + }); + + t.test('every storm in data/storms/ loads and validates', () => { + // loadStorm throws on invalid, so reaching here with all of them is the pass + assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load'); + for (const [name, def] of Object.entries(storms)) { + assert(def.duration > 0, `${name} has no duration`); + assert(Array.isArray(def.baseCurve), `${name} has no baseCurve`); + } + }); } diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index df31001..e810886 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -27,17 +27,19 @@ const TREE_SHELTERS = [ { x: 8, z: -5, radius: 2.5, strength: 0.4, length: 12 }, ]; -export function runWeatherSuite(storms) { - const results = []; +/** + * The case list, defined once and run by two harnesses: run-node.mjs for a + * one-second tuning loop, and js/tests/c.test.js for Lane A's selftest.html at + * merge time. Cases are sync, matching testkit's Suite.test(label, fn). + * + * @param {Object} storms parsed storm defs, keyed by name + * @returns {{cases: {name:string, fn:() => void}[], metrics: object}} + * `metrics` fills in as the cases run — read it after, not before. + */ +export function weatherCases(storms) { + const cases = []; const metrics = {}; - const test = (name, fn) => { - try { - fn(); - results.push({ name, ok: true }); - } catch (e) { - results.push({ name, ok: false, err: e.message }); - } - }; + const test = (name, fn) => cases.push({ name, fn }); const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; const defs = Object.entries(storms); @@ -277,6 +279,20 @@ export function runWeatherSuite(storms) { assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way'); }); + return { cases, metrics }; +} + +/** Run every case and collect results. Used by run-node.mjs. */ +export function runWeatherSuite(storms) { + const { cases, metrics } = weatherCases(storms); + const results = cases.map((c) => { + try { + c.fn(); + return { name: c.name, ok: true }; + } catch (e) { + return { name: c.name, ok: false, err: e.message }; + } + }); const pass = results.filter((r) => r.ok).length; return { suite: 'weather', pass, fail: results.length - pass, results, metrics }; } diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index dafcd55..a2f5a44 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -59,7 +59,7 @@ function hash2(ix, iz, seed) { const smooth = (f) => f * f * (3 - 2 * f); /** Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps. */ -function valueNoise2(x, z, seed) { +export function valueNoise2(x, z, seed) { const ix = Math.floor(x), iz = Math.floor(z); const ux = smooth(x - ix), uz = smooth(z - iz); const a = hash2(ix, iz, seed), b = hash2(ix + 1, iz, seed); diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 4f2480f..422395c 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -9,12 +9,15 @@ // the THREE adapter + storm loading, so the sim stays node-testable and the // determinism rule can't be broken by accident. -import * as THREE from 'three'; +import * as THREE from '../vendor/three.module.js'; import { createWindField, validateStorm, GUST } from './weather.core.js'; export { GUST, validateStorm }; -const STORM_DIR = '/world/data/storms'; +// Resolved against this module, not the server root: server.py serves the repo +// root (so the 2D prototype stays reachable), but the demo bench serves web/. +// import.meta.url is right under both, and under whatever Lane A does next. +const STORM_DIR = new URL('../data/storms', import.meta.url).href; /** Fetch + validate a storm def. Throws loud on bad data — storms are content. */ export async function loadStorm(name, dir = STORM_DIR) { diff --git a/web/world/weather_demo.html b/web/world/weather_demo.html new file mode 100644 index 0000000..e06cb2e --- /dev/null +++ b/web/world/weather_demo.html @@ -0,0 +1,255 @@ + + + + +SHADES — Lane C — weather bench + + + + +
+
+ Lane C bench. Graybox stand-in for Lane A's yard — this exists to drive + weather.js / skyfx.js / debris.js before M0 lands. The sail here is a MOCK + (Lane B owns the real one); it's a bare node grid so debris impulse is visible. +

drag = orbit · click = start audio +
+
+ + + +