diff --git a/THREADS.md b/THREADS.md index 815eb57..5502da5 100644 --- a/THREADS.md +++ b/THREADS.md @@ -254,3 +254,94 @@ Format: `[lane letter] YYYY-MM-DD — note` fascia sits at 2.80 (2.90 m ridge), and the facade is 9.20 m against a 30 m north edge. Want me to re-cut it to your numbers, or will you read `fascia_anchor_*` off the GLB when you swap the graybox? Either way it's one constant for me — the script regenerates everything. +[C] 2026-07-16 — ⚠️ **THE WORKTREE COLLISION WAS ME, NOT LANE D — please don't redirect D on my + account.** Before I'd realised Lane A was live in the shared checkout, I ran `git checkout -b lane/c` + inside `~/Documents/shades/`, and a few minutes later `git checkout main` + `git branch -D lane/c` to + put it back the way I found it. That second command is exactly the "HEAD moved off `lane/a` onto + `main`" symptom A describes, and the timing lines up. Nothing was lost — the delete reported + `was 8338c0f`, i.e. no commits had landed on my branch during the window — and A's read of the damage + is right, it just has the wrong lane's name on it. Lane C has been on `~/Documents/shades-laneC/` + since. (Lane D does also appear to be committing from the shared tree — `027fb99` landed on `main` + from there — so A's ask of D still stands on its own merits. I'm only disowning the checkout.) + Apologies for the noise; PLAN3D §0's clone rule earns its keep. + +[C] 2026-07-16 — **LANE C LANDED on `lane/c` — weather.js, skyfx.js, debris.js, 2 storms.** Rebased on + M0; `c.test.js` is live (19 asserts) and Lane A's selftest reads **37 pass / 3 skip**. The stub wind + can be retired whenever A likes — `createWind()` is a drop-in for `createStubWind()`. + · `wind.sample(pos,t)` / `wind.gustTelegraph(t)` per contract; `checkContract('wind', …)` clean. + · Gusts are a **precomputed timeline**, not an integrator. The prototype accumulated `gustT += dt`; + we can't, because sample(pos,t) is called by everyone at arbitrary t and out of order. Same + envelope though — telegraph 1.5 / ramp 0.8 / hold 1.7 / fade 1.0, straight off the prototype. + · Storms are **data**: `data/storms/*.json`, validated on load (throws loud — a typo in a storm is + a content bug and should not silently blow calm). Tune curves without touching code. + +[C] 2026-07-16 — **CONTRACT — one addition, backward compatible.** `wind.sample(pos, t, out?)` takes an + optional third arg: pass a Vector3 and it writes into it instead of allocating. Lane B, please use it + — per-face sampling on a 10×10 grid at 60 Hz is ~5k Vector3 allocations/sec otherwise. Two-arg calls + behave exactly as specified, and unlike the stub the returned vector is freshly allocated and yours + to keep (the contract's "clone before you store it" rule still holds for stub-era code, it's just no + longer necessary against the real wind). + +[C] 2026-07-16 — **ASKS, one per lane. All degrade silently — nothing here blocks a merge.** + · **Lane A** — trees don't shelter anything until you tell me where they are: + `wind.setSheltersFromTrees(world.anchors.filter(a => a.type === 'tree'))` after the yard builds. + Unset = no wind shadows, which is just a flatter yard. Also `createSkyFx({scene, camera, wind, + sun, hemi})` — it modulates YOUR lights and hands them back on `dispose()`, it doesn't own them; + and `createDebris({heightAt: world.heightAt})` so debris bounces off your terrain, not y=0. + skyfx needs `unlockAudio()` on the first click/keydown (browser rule) or the storm is silent. + · **Lane B** — ❓ **the debris-vs-sail seam is your call.** I have the impulse maths but not your + nodes: contracts exposes `sailRig.corners`, not the cloth. Two options — (a) I keep driving it and + you expose `sailRig.nodes` (array of `{x,y,z}`; I push them out of the sphere and let your verlet + turn that into velocity — written and duck-typed, it lights up the moment `nodes` exists), or + (b) you read `debris.pieces` (`{x,y,z,vx,vy,vz,r,mass}`) in `sail.step` and do it yourself, since + you own the integrator. I'd take (b) if you want the momentum bookkeeping in one place. Say which + and I'll match it. + · **Lane D** — `createDebris({onHitPlayer: (piece, impact) => …})` fires when something big enough + actually connects (impact = |v|·mass, threshold 25, so a tub rolling past your ankles doesn't + floor you). The knockdown state machine is yours per §5-D.3; I only report the hit. + · **Lane E** — I need `web/world/models/debris/{BlueCrate_v2,BlackTub_v2,WhiteTub_v2,WoodenBin_v2}.glb` + (your §5-E.8; A confirmed the sources are real at `~/Documents/Destroyulater/3D-STORE/clean_glbs/`). + Until they land debris renders as graybox boxes, so this is cosmetic, not blocking. + `debris.setModels({name: Object3D})`. Collision is one sphere per piece — radii in `MODEL_SPEC` in + debris.js assume a ~0.6 m crate; if you scale them differently, tell me rather than fighting it. + +[C] 2026-07-16 — **Lane B: tune cloth ρ against these, not against the stub.** Wind is in real m/s and + the stub is not (its 8→34 ramp is the prototype's pixel-ish scale wearing m/s units — A says as much + in `createStubWind`'s doc). `storm_01_gentle`: sustained peaks 6.5, worst gust 11.3 m/s (41 km/h) — + the sail should breathe and nothing should break. `storm_02_wildnight`: sustained peaks 20 (72 km/h), + worst gust 32.3 (116 km/h, BOM 'destructive'), southerly change swings 2.09 rad at t=55–59 with the + peak landing just after it. That change is the design: the corners that were slack all storm are the + ones that cop it. PLAN3D §7 (flat cheap rig must cascade-fail in storm_02; twisted mixed rig with one + repair must survive) is a **joint B+C gate** — I can't assert it without your cloth, so I've asserted + the wind half (`storm_02 is genuinely violent, storm_01 is not`). Ping me when sail.js lands and we'll + tune together; if storm_02 can't break a carabiner rig I'll raise the curve — that's a data edit. + +[C] 2026-07-16 — Notes on my own files, so nobody trips over them: + · `weather.core.js` imports **nothing** — no THREE, no DOM, no Date.now. Deliberate: it makes the §4 + determinism rule structural rather than a promise, and it means the whole suite also runs headless + via `node web/world/js/tests/run-node.mjs` (~1 s, no browser, no server). Tuning a storm curve + through a browser round trip is miserable. `weather.js` is the thin THREE adapter over it. + · Cost of that: `weather.core.js` carries its own copy of mulberry32 rather than importing + `contracts.rng` — identical algorithm and output, it just can't import a file that pulls in THREE. + `debris.js` and `skyfx.js` do use `contracts.rng`. Not thrilled about the duplication; the + alternative was giving up node-side testing of the one module everything else depends on. + · Asserts live in `js/tests/weather.selftest.js` as a plain case list; `c.test.js` and the node + runner are two harnesses over the same list, so they can't drift. + · `weather_demo.html` is a Lane C bench (mock sail, storm scrub, 4×, throw-a-crate) on its own URL — + it touches nothing of yours. Delete it whenever it stops earning its place. + · **Lane A:** a.test.js's 'gust telegraph always gives at least 1.2 s of warning' is now also + asserted against the real wind in c.test.js, per your note in the stub. Yours to drop when the + stub goes. + +[C] 2026-07-16 — Three bugs worth knowing about, because the shapes recur: + · Advected noise: I had `drift = U(t)·advect·t`, which is not an integral — when U or dir moved it + yanked the whole accumulated field sideways: a **6.8 m/s jump in one frame** at the southerly + change. Now integrated once at build time into a table. If you ever advect anything by time, + integrate it. + · Debris friction: `v *= 0.86` per frame while grounded is `0.86^60` per second — glue, not scrape. + It pinned a 9 kg crate at 0.7 m/s in a 19 m/s wind. Anything per-frame that should be per-second + needs dt. + · `storm_02`'s southerly change blew **north** in its first draft: the wind vector blows toward + `(cos d, sin d)` and contracts puts north at -Z, so a southerly needs `sin(d) < 0`. Worth a second + look at anything that reasons about wind direction. + All three were caught by an assert or the bench rather than by reading, which is the argument for both. diff --git a/web/world/data/storms/storm_01_gentle.json b/web/world/data/storms/storm_01_gentle.json new file mode 100644 index 0000000..d76f06a --- /dev/null +++ b/web/world/data/storms/storm_01_gentle.json @@ -0,0 +1,29 @@ +{ + "name": "Sea Breeze", + "blurb": "Fresh afternoon breeze, chance of a light shower. Good day to test a rig.", + "rating": 1, + "seed": 1017, + "duration": 90, + + "baseCurve": [[0, 3.0], [20, 5.0], [50, 6.5], [75, 6.0], [90, 5.5]], + + "gusts": { + "firstAt": 4, + "minGap": 6, + "maxGap": 14, + "powBase": 2, + "powRand": 3, + "powRamp": 2 + }, + + "dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]], + "dirWander": { "amp": 0.35, "rate": 0.09 }, + + "spatial": { "amp": 0.15, "scale": 12, "advect": 0.5 }, + + "events": [], + + "rain": { "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] }, + + "sky": { "darkness": 0.15, "cloudScroll": 0.02 } +} diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json new file mode 100644 index 0000000..91299ac --- /dev/null +++ b/web/world/data/storms/storm_02_wildnight.json @@ -0,0 +1,41 @@ +{ + "name": "Wild Night", + "blurb": "Front crossing after dark. Southerly change around the hour mark, damaging gusts. Rig for the swing, not the lull.", + "rating": 4, + "seed": 20260716, + "duration": 90, + + "_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": { + "firstAt": 3, + "minGap": 5.5, + "maxGap": 11, + "powBase": 3, + "powRand": 5, + "powRamp": 7 + }, + + "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 }, + + "events": [ + { "t": 38, "type": "debris", "model": "BlueCrate_v2", "lateral": -3.5, "mass": 9, "text": "a crate comes through the fence line" }, + { "t": 52, "type": "lightning", "power": 0.7 }, + { "t": 55, "type": "windchange", "telegraph": 6, "over": 6, "text": "the wind swings around" }, + { "t": 64, "type": "lightning", "power": 1.0 }, + { "t": 66, "type": "debris", "model": "BlackTub_v2", "lateral": 2.0, "mass": 5, "text": "someone's tub is airborne" }, + { "t": 74, "type": "debris", "model": "WoodenBin_v2", "lateral": -1.0, "mass": 14, "text": "the neighbour's bin lets go" }, + { "t": 79, "type": "lightning", "power": 0.5 } + ], + + "rain": { "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] }, + + "sky": { "darkness": 0.8, "cloudScroll": 0.09 } +} diff --git a/web/world/js/debris.js b/web/world/js/debris.js new file mode 100644 index 0000000..bd8f1e9 --- /dev/null +++ b/web/world/js/debris.js @@ -0,0 +1,254 @@ +'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; +// Deceleration while resting on the ground, m/s². Drag in a 19 m/s wind gives a +// 9 kg crate ~7 m/s², so it still skitters downwind — which is the whole point. +const GROUND_FRICTION = 3.5; + +// 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, + phase: rand() * 6.283, // so two crates don't hop in lockstep + 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; + + // A tumbling bluff body doesn't just get shoved, it gets picked up: lift + // flips sign as it rolls, which is why a bin HOPS across a yard instead + // of sliding. Wind is horizontal (weather.js keeps y=0), so without this + // there is no vertical force at all once it's down and it just skates. + p.vy += (0.5 * rel * rel * Math.sin(p.spin * 1.7 + p.phase) / p.mass) * dt; + + p.x += p.vx * dt; + p.y += p.vy * dt; + p.z += p.vz * dt; + + // ground + const floor = groundAt(p.x, p.z) + p.r; + if (p.y <= floor) { + p.y = floor; + if (p.vy < -0.5) { + // a real impact: bounce, and lose some tangential speed to the hit + p.vy = -p.vy * 0.32; // dead-ish, it's a plastic tub + p.vx *= 0.72; p.vz *= 0.72; + } else { + if (p.vy < 0) p.vy = 0; + // Resting: rolling friction as a dt-scaled DECELERATION, not a + // per-frame multiplier. `v *= 0.86` every frame is 0.86^60 per + // second — that isn't scrape, it's glue, and it pinned a 9 kg crate + // at 0.7 m/s in a 19 m/s wind. + const sp = Math.hypot(p.vx, p.vz); + if (sp > 1e-4) { + const drop = Math.min(sp, GROUND_FRICTION * dt); + p.vx -= (p.vx / sp) * drop; + p.vz -= (p.vz / sp) * drop; + } + } + } + + // 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..030576e --- /dev/null +++ b/web/world/js/skyfx.js @@ -0,0 +1,470 @@ +'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; +} 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/run-node.mjs b/web/world/js/tests/run-node.mjs new file mode 100644 index 0000000..9896d7b --- /dev/null +++ b/web/world/js/tests/run-node.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +'use strict'; +// SHADES — Lane C — headless runner for the weather suite. +// +// node web/world/js/tests/run-node.mjs +// +// The same suite Lane A's selftest.html runs, but with no browser and no server, +// so tuning a storm curve is a one-second loop. Exits non-zero on failure. + +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { runWeatherSuite } from './weather.selftest.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const stormDir = join(here, '..', '..', 'data', 'storms'); + +const storms = {}; +for (const f of readdirSync(stormDir).filter((f) => f.endsWith('.json')).sort()) { + storms[f.replace(/\.json$/, '')] = JSON.parse(readFileSync(join(stormDir, f), 'utf8')); +} + +const r = runWeatherSuite(storms); + +for (const res of r.results) { + console.log(res.ok ? ` ok ${res.name}` : ` FAIL ${res.name}\n ${res.err}`); +} +console.log('\n metrics (Lane B: tune cloth rho against these):'); +for (const [k, v] of Object.entries(r.metrics)) console.log(` ${k.padEnd(32)} ${v}`); +console.log(`\n ${r.pass} passed, ${r.fail} failed\n`); + +process.exit(r.fail ? 1 : 0); diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js new file mode 100644 index 0000000..29ca7d6 --- /dev/null +++ b/web/world/js/tests/weather.selftest.js @@ -0,0 +1,299 @@ +'use strict'; +// SHADES — Lane C — weather selftest. +// +// Imports the pure core only (no THREE, no DOM), so this runs in node OR from +// Lane A's selftest.html. Fixed-dt loops, never rAF (rAF pauses in hidden tabs +// — PLAN3D §0). +// +// node web/world/js/tests/run-node.mjs +// +// Lane A: `import { runWeatherSuite } from './js/tests/weather.selftest.js'` and +// call it with the fetched storm defs. + +import { + createWindField, validateStorm, gustEnvelope, GUST, +} from '../weather.core.js'; + +const DT = 1 / 60; + +// yard is ~30×20 m, origin at centre — probe the corners and the middle +const PROBES = [ + { x: 0, z: 0 }, { x: -14, z: -9 }, { x: 14, z: -9 }, + { x: -14, z: 9 }, { x: 14, z: 9 }, { x: 5, z: -3 }, +]; + +// t1 and t2 from Lane A's landed yard (THREADS: "yard layout is now FACT") +const TREE_SHELTERS = [ + { x: -9, z: 2, radius: 3, strength: 0.45, length: 14 }, + { x: 8, z: -2, radius: 2.5, strength: 0.4, length: 12 }, +]; + +/** + * 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) => cases.push({ name, fn }); + const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; + + const defs = Object.entries(storms); + + // ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ---- + // The whole gust read is "you SEE it coming, then it hits". If the lead ever + // collapses, the storm stops being fair and starts being a dice roll. + for (const [name, def] of defs) { + test(`${name}: gust telegraph lead >= 1.2s`, () => { + const field = createWindField(def); + assert(field.gusts.length > 0, 'storm scheduled no gusts at all'); + const step = 1 / 240; + let worst = Infinity; + for (const g of field.gusts) { + // when does THIS gust first actually push? + let rise = null; + for (let t = g.t0; t < g.endAt; t += step) { + if (gustEnvelope(t - g.t0, g.pow) > 1e-6) { rise = t; break; } + } + assert(rise !== null, `gust at t=${g.t0.toFixed(2)} never rises`); + + // when did the HUD first get told about it? + let announced = null; + for (let t = Math.max(0, g.t0 - 2); t < rise; t += step) { + const tg = field.telegraph(t); + if (tg && Math.abs(tg.eta - (g.rampAt - t)) < 1e-6) { announced = t; break; } + } + assert(announced !== null, `gust at t=${g.t0.toFixed(2)} was never telegraphed`); + + const lead = rise - announced; + worst = Math.min(worst, lead); + assert(lead >= 1.2, + `gust at t=${g.t0.toFixed(2)}: telegraph lead ${lead.toFixed(3)}s < 1.2s`); + + // and the telegraph window must be silent — no force before the ramp + for (let t = g.t0; t < g.rampAt; t += step) { + assert(gustEnvelope(t - g.t0, g.pow) === 0, + `gust at t=${g.t0.toFixed(2)} pushes during its telegraph window`); + } + } + metrics[`${name}.worstTelegraphLead`] = +worst.toFixed(3); + }); + } + + // ---- 2. wind.sample continuity ---- + // Sail load goes with wind², so a discontinuity here is an impulse that can + // snap a corner out of nowhere. Both axes matter: time (a standing player) + // and space (a running one). + const MAX_JUMP = 1.5; // m/s per 1/60 frame + for (const [name, def] of defs) { + test(`${name}: wind continuity in time (< ${MAX_JUMP} m/s per frame)`, () => { + const field = createWindField(def).setShelters(TREE_SHELTERS); + const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 }; + let worst = 0, worstT = 0, worstP = null; + for (const p of PROBES) { + field.vecAt(p.x, p.z, 0, a); + for (let t = DT; t <= field.duration; t += DT) { + field.vecAt(p.x, p.z, t, b); + const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z); + if (d > worst) { worst = d; worstT = t; worstP = p; } + a.x = b.x; a.y = b.y; a.z = b.z; + } + } + metrics[`${name}.maxTemporalJump`] = +worst.toFixed(4); + assert(worst < MAX_JUMP, + `jump ${worst.toFixed(3)} m/s at t=${worstT.toFixed(2)} probe=(${worstP.x},${worstP.z})`); + }); + + test(`${name}: wind continuity in space (< ${MAX_JUMP} m/s per 0.1 m)`, () => { + const field = createWindField(def).setShelters(TREE_SHELTERS); + const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 }; + let worst = 0, worstAt = null; + // sweep the yard at the storm's angriest moments, straight through both trees + for (const t of [10, 30, 57, 64, 80]) { + for (let z = -10; z <= 10; z += 1) { + field.vecAt(-15, z, t, a); + for (let x = -15 + 0.1; x <= 15; x += 0.1) { + field.vecAt(x, z, t, b); + const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z); + if (d > worst) { worst = d; worstAt = { x: +x.toFixed(1), z, t }; } + a.x = b.x; a.y = b.y; a.z = b.z; + } + } + } + metrics[`${name}.maxSpatialJump`] = +worst.toFixed(4); + assert(worst < MAX_JUMP, + `jump ${worst.toFixed(3)} m/s at ${JSON.stringify(worstAt)}`); + }); + } + + // ---- 3. storm JSON validator ---- + for (const [name, def] of defs) { + test(`${name}: validates`, () => { + const { ok, errors } = validateStorm(def, name); + assert(ok, errors.join('; ')); + }); + } + + // A validator that only ever says yes isn't a validator. + test('validator rejects broken storms', () => { + const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight)); + const cases = [ + ['duration missing', (d) => { delete d.duration; }], + ['duration negative', (d) => { d.duration = -5; }], + ['baseCurve absent', (d) => { delete d.baseCurve; }], + ['baseCurve non-monotonic t', (d) => { d.baseCurve = [[0, 5], [50, 9], [20, 7], [90, 6]]; }], + ['baseCurve negative speed', (d) => { d.baseCurve = [[0, 5], [90, -2]]; }], + ['baseCurve ends before duration', (d) => { d.baseCurve = [[0, 5], [40, 9]]; }], + ['dirCurve absent', (d) => { delete d.dirCurve; }], + ['gusts absent', (d) => { delete d.gusts; }], + ['gusts.minGap zero', (d) => { d.gusts.minGap = 0; }], + ['gusts.maxGap < minGap', (d) => { d.gusts.minGap = 9; d.gusts.maxGap = 4; }], + ['gusts overlap (minGap < gust length)', (d) => { d.gusts.minGap = 2; }], + ['debris event with no model', (d) => { d.events = [{ t: 10, type: 'debris' }]; }], + ['event with no type', (d) => { d.events = [{ t: 10 }]; }], + ['windchange that dirCurve never delivers', (d) => { + d.dirCurve = [[0, 0.9], [90, 1.0]]; + d.events = [{ t: 55, type: 'windchange', telegraph: 6 }]; + }], + ]; + for (const [label, mutate] of cases) { + const d = base(); + mutate(d); + const { ok } = validateStorm(d, 'broken'); + assert(!ok, `validator ACCEPTED a storm with: ${label}`); + } + }); + + // ---- 4. determinism ---- + // Everything downstream (selftest fast-forward, Lane B's byte-equal load + // traces) rests on this. Two builds of the same storm must be indiscernible. + test('same def + same seed => identical trace', () => { + const def = storms.storm_02_wildnight; + const a = createWindField(def).setShelters(TREE_SHELTERS); + const b = createWindField(def).setShelters(TREE_SHELTERS); + const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 }; + for (let t = 0; t <= def.duration; t += DT) { + for (const p of PROBES) { + a.vecAt(p.x, p.z, t, va); + b.vecAt(p.x, p.z, t, vb); + assert(va.x === vb.x && va.z === vb.z, + `diverged at t=${t.toFixed(3)} probe=(${p.x},${p.z})`); + } + } + assert(a.gusts.length === b.gusts.length, 'gust timelines differ in length'); + a.gusts.forEach((g, i) => { + assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `gust ${i} differs`); + }); + }); + + test('different seed => different storm', () => { + const def = storms.storm_02_wildnight; + const a = createWindField(def, { seed: 1 }); + const b = createWindField(def, { seed: 2 }); + const same = a.gusts.length === b.gusts.length + && a.gusts.every((g, i) => g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow); + assert(!same, 'seed is being ignored — every storm would be identical'); + }); + + // ---- 5. sampling order must not matter ---- + // sample() is called by sail/player/debris/rain in whatever order the frame + // happens to run. If it ever depends on call order, storms stop replaying. + test('sample order independent', () => { + const def = storms.storm_02_wildnight; + // same fixed t values, walked in two different orders, on two fields + const times = [0, 3.5, 17.3, 4.1, 55.0, 88.9, 63.2, 21.7, 39.9, 70.4]; + const sorted = [...times].sort((a, b) => a - b); + const inOrder = createWindField(def).setShelters(TREE_SHELTERS); + const shuffled = createWindField(def).setShelters(TREE_SHELTERS); + const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 }; + + const seen = new Map(); + for (const t of sorted) { + inOrder.vecAt(3, -2, t, va); + seen.set(t, { x: va.x, z: va.z }); + } + for (const t of times) { // deliberately out of order, and jumping backwards + shuffled.vecAt(3, -2, t, vb); + const want = seen.get(t); + assert(vb.x === want.x && vb.z === want.z, + `sampling order changed the wind at t=${t}: ${vb.x},${vb.z} vs ${want.x},${want.z}`); + } + }); + + // ---- 6. the storms are what the design says they are (PLAN3D §7) ---- + // storm_02 has to be able to destroy a flat cheap rig; storm_01 must not. + test('storm_02 is genuinely violent, storm_01 is not', () => { + const wild = createWindField(storms.storm_02_wildnight); + const gentle = createWindField(storms.storm_01_gentle); + const peak = (f) => { + let mx = 0, mxBase = 0; + for (let t = 0; t <= f.duration; t += DT) { + mx = Math.max(mx, f.uniformSpeed(t)); + mxBase = Math.max(mxBase, f.uniformSpeed(t) - f.gustOnly(t)); + } + return { mx, mxBase }; + }; + const w = peak(wild), g = peak(gentle); + metrics['storm_02.peakGustSpeed'] = +w.mx.toFixed(2); + metrics['storm_02.peakSustained'] = +w.mxBase.toFixed(2); + metrics['storm_01.peakGustSpeed'] = +g.mx.toFixed(2); + metrics['storm_01.peakSustained'] = +g.mxBase.toFixed(2); + assert(w.mx >= 30, `storm_02 peaks at only ${w.mx.toFixed(1)} m/s — won't break a cheap rig`); + assert(w.mxBase >= 18, `storm_02 sustained peaks at only ${w.mxBase.toFixed(1)} m/s`); + assert(g.mx <= 15, `storm_01 peaks at ${g.mx.toFixed(1)} m/s — too wild for the gentle storm`); + assert(w.mx > g.mx * 2, 'storm_02 should be far worse than storm_01'); + }); + + // ---- 7. wind change actually swings the wind ---- + test('storm_02 southerly change swings the wind', () => { + const f = createWindField(storms.storm_02_wildnight); + const ev = (storms.storm_02_wildnight.events || []).find((e) => e.type === 'windchange'); + assert(ev, 'storm_02 has no windchange event'); + const before = f.dirAt(ev.t - 5); + const after = f.dirAt(ev.t + 8); + const swing = Math.abs(after - before); + metrics['storm_02.changeSwingRad'] = +swing.toFixed(3); + assert(swing > 0.9, `change only swings ${swing.toFixed(2)} rad — should be a real slew`); + // and the player must be warned before it lands + assert((ev.telegraph ?? 0) >= 4, 'windchange telegraph is too short to react to'); + }); + + // ---- 8. shelters ---- + test('tree wind shadow bites downwind and nowhere else', () => { + const def = storms.storm_02_wildnight; + const bare = createWindField(def); + const shad = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]); + const t = 30; + const d = bare.dirAt(t); + const dx = Math.cos(d), dz = Math.sin(d); + const lee = { x: dx * 5, z: dz * 5 }; // 5 m downwind of the tree + const luv = { x: -dx * 5, z: -dz * 5 }; // 5 m upwind + const leeS = shad.speedAt(lee.x, lee.z, t), leeB = bare.speedAt(lee.x, lee.z, t); + const luvS = shad.speedAt(luv.x, luv.z, t), luvB = bare.speedAt(luv.x, luv.z, t); + metrics['shelter.leeDrop'] = +(1 - leeS / leeB).toFixed(3); + assert(leeS < leeB * 0.85, `lee side only dropped to ${(leeS / leeB).toFixed(2)}× — shadow too weak`); + 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 new file mode 100644 index 0000000..6d4b4ed --- /dev/null +++ b/web/world/js/weather.core.js @@ -0,0 +1,399 @@ +'use strict'; +// SHADES — Lane C — wind field core. +// +// Pure math. Zero imports: no THREE, no DOM, no Date.now, no rAF. Everything is +// a closed-form function of (pos, t) given a storm def + seed, which buys us: +// - selftest can fast-forward a 90 s storm and get identical numbers every run +// - consumers can sample any t, in any order, as often as they like +// - the determinism rule (PLAN3D §4) is structural, not a promise +// +// weather.js wraps this to expose the contracts.js surface (Vector3 in/out). +// The prototype scheduled gusts by INTEGRATING (wind.gustT += dt). We can't — +// sample(pos,t) is called by everyone at arbitrary t. So gusts are precomputed +// into a timeline from a seeded PRNG at storm load; the envelope shape below is +// a faithful port of prototype/game.js, just read from t instead of accumulated. + +// ---------- gust envelope (ported from prototype/game.js windVec) ---------- +// telegraph: wind hasn't risen yet, but you can SEE it coming (grass, band, audio) +export const GUST = Object.freeze({ + TELEGRAPH: 1.5, // gt < 1.5 → 0 "it's coming" + RAMP: 0.8, // 1.5 .. 2.3 → 0 → pow + HOLD: 1.7, // 2.3 .. 4.0 → pow + FADE: 1.0, // 4.0 .. 5.0 → pow → 0 + TOTAL: 5.0, +}); +const RAMP_AT = GUST.TELEGRAPH; // 1.5 +const HOLD_AT = RAMP_AT + GUST.RAMP; // 2.3 +const FADE_AT = HOLD_AT + GUST.HOLD; // 4.0 +const END_AT = FADE_AT + GUST.FADE; // 5.0 + +/** Gust strength at local gust time gt (seconds since telegraph began). */ +export function gustEnvelope(gt, pow) { + if (gt <= 0 || gt >= END_AT) return 0; + if (gt < RAMP_AT) return 0; // telegraph window + if (gt < HOLD_AT) return pow * (gt - RAMP_AT) / GUST.RAMP; + if (gt < FADE_AT) return pow; + return pow * (END_AT - gt) / GUST.FADE; +} + +// ---------- deterministic noise ---------- +// mulberry32 — small, fast, good enough, and identical in every JS engine. +export function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// int32 hash — Math.imul keeps it exact (plain * would drift past 2^31 as a double) +function hash2(ix, iz, seed) { + let h = (Math.imul(ix, 374761393) + Math.imul(iz, 668265263) + Math.imul(seed, 1274126177)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +const smooth = (f) => f * f * (3 - 2 * f); + +/** + * Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps. + * + * @param {number} [period] Wrap the lattice at this many cells, making the noise + * tile seamlessly over [0, period). The wind doesn't want this (the yard would + * repeat); a scrolling cloud texture does, or every wrap boundary is a visible + * straight edge in the sky. Pass an integer that matches your frequency. + */ +export function valueNoise2(x, z, seed, period = 0) { + const ix = Math.floor(x), iz = Math.floor(z); + const ux = smooth(x - ix), uz = smooth(z - iz); + // branch, not a closure: this is the wind's hot path (the cloth alone samples + // it thousands of times a second) and a per-call allocation would show up. + let x0 = ix, x1 = ix + 1, z0 = iz, z1 = iz + 1; + if (period > 0) { + x0 = ((x0 % period) + period) % period; + x1 = ((x1 % period) + period) % period; + z0 = ((z0 % period) + period) % period; + z1 = ((z1 % period) + period) % period; + } + const a = hash2(x0, z0, seed), b = hash2(x1, z0, seed); + const c = hash2(x0, z1, seed), d = hash2(x1, z1, seed); + return (a + (b - a) * ux) * (1 - uz) + (c + (d - c) * ux) * uz; +} + +export function smoothstep(e0, e1, x) { + if (e0 === e1) return x < e0 ? 0 : 1; + const f = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))); + return smooth(f); +} + +// ---------- curves ---------- +/** Piecewise-linear [[t,v],...] lookup, clamped at both ends. */ +export function sampleCurve(curve, t) { + if (!curve || curve.length === 0) return 0; + if (t <= curve[0][0]) return curve[0][1]; + const last = curve[curve.length - 1]; + if (t >= last[0]) return last[1]; + for (let i = 1; i < curve.length; i++) { + if (t <= curve[i][0]) { + const [ta, va] = curve[i - 1], [tb, vb] = curve[i]; + const span = tb - ta; + return span <= 0 ? vb : va + (vb - va) * ((t - ta) / span); + } + } + return last[1]; +} + +/** Shortest-arc angle lerp — so a curve crossing ±π doesn't spin the long way. */ +export function lerpAngle(a, b, k) { + const TAU = Math.PI * 2; + let d = ((b - a + Math.PI) % TAU + TAU) % TAU - Math.PI; + return a + d * k; +} + +function sampleAngleCurve(curve, t) { + if (!curve || curve.length === 0) return 0; + if (t <= curve[0][0]) return curve[0][1]; + const last = curve[curve.length - 1]; + if (t >= last[0]) return last[1]; + for (let i = 1; i < curve.length; i++) { + if (t <= curve[i][0]) { + const [ta, va] = curve[i - 1], [tb, vb] = curve[i]; + const span = tb - ta; + return span <= 0 ? vb : lerpAngle(va, vb, (t - ta) / span); + } + } + return last[1]; +} + +// ---------- gust timeline ---------- +// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON. +export function buildGustTimeline(def, seed) { + const g = def.gusts || {}; + const rng = mulberry32(seed >>> 0); + const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12; + const out = []; + let t = g.firstAt ?? 3; + // hard cap: a malformed gap can't spin us forever + while (t < def.duration && out.length < 512) { + const p = def.duration > 0 ? t / def.duration : 0; + const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p; + out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL }); + t += minGap + rng() * Math.max(0, maxGap - minGap); + } + return out; +} + +// ---------- the field ---------- +/** + * @param {object} def parsed storm JSON (see data/storms/*.json) + * @param {object} [opts] {seed} + */ +export function createWindField(def, opts = {}) { + const seed = (opts.seed ?? def.seed ?? 1) >>> 0; + const duration = def.duration ?? 90; + const gusts = buildGustTimeline(def, seed); + const sp = def.spatial || {}; + const amp = sp.amp ?? 0.18; // ±18% speed across the yard + const scale = sp.scale ?? 12; // metres per noise cell — yard is 30×20 + const advect = sp.advect ?? 0.5; // noise drifts downwind (frozen turbulence) + const wander = def.dirWander || {}; + const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13; + const nSeed = (seed ^ 0x9e3779b9) | 0; + + let shelters = []; + + /** Spatially-uniform part: base curve + every gust envelope live at t. */ + function uniformSpeed(t) { + let s = sampleCurve(def.baseCurve, t); + for (let i = 0; i < gusts.length; i++) { + const g = gusts[i]; + if (t <= g.t0) break; // sorted — nothing later can be live + if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow); + } + return s; + } + + function gustOnly(t) { + let s = 0; + for (let i = 0; i < gusts.length; i++) { + const g = gusts[i]; + if (t <= g.t0) break; + if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow); + } + return s; + } + + function dirAt(t) { + return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate); + } + + // ---- noise drift ---- + // The noise pattern rides downwind with the mean flow (Taylor's frozen + // turbulence), so a gust visibly travels ACROSS the yard instead of blinking on + // everywhere at once. That displacement is an integral, D(t) = ∫ advect·U·dir dτ, + // and it has to be integrated as one: the obvious closed form `U(t)·advect·t` + // is not the integral, and it whips the whole accumulated field sideways the + // instant U or dir moves — a 6.8 m/s single-frame jump at the southerly change, + // which the continuity assert caught. So integrate once at build time into an + // immutable table; sampling stays a pure function of t. + // Mean flow only (base curve, no gusts): eddies are carried by the wind, they + // don't surf their own gust, and it keeps the drift rate smooth. + const DRIFT_DT = 0.25; + const driftX = [], driftZ = []; + { + let dx = 0, dz = 0; + const n = Math.ceil((duration + 2) / DRIFT_DT) + 2; + for (let i = 0; i < n; i++) { + driftX.push(dx); driftZ.push(dz); + const tt = i * DRIFT_DT; + const u = sampleCurve(def.baseCurve, tt) * advect; + const d = dirAt(tt); + dx += Math.cos(d) * u * DRIFT_DT; + dz += Math.sin(d) * u * DRIFT_DT; + } + } + const drift = { x: 0, z: 0 }; + function driftAt(t) { + if (t <= 0) { drift.x = 0; drift.z = 0; return drift; } + const f = t / DRIFT_DT; + let i = Math.floor(f); + if (i > driftX.length - 2) i = driftX.length - 2; // past the end: extrapolate + const k = f - i; + drift.x = driftX[i] + (driftX[i + 1] - driftX[i]) * k; + drift.z = driftZ[i] + (driftZ[i + 1] - driftZ[i]) * k; + return drift; + } + + /** Speed multiplier: smooth noise, carried downwind. */ + function spatialFactor(x, z, t) { + if (amp <= 0) return 1; + const d = driftAt(t); + const nx = (x - d.x) / scale; + const nz = (z - d.z) / scale; + const n = 0.65 * valueNoise2(nx, nz, nSeed) + + 0.35 * valueNoise2(nx * 2.2 + 31.7, nz * 2.2 + 11.3, nSeed ^ 0x51ed270b); + return 1 + (n - 0.5) * 2 * amp; + } + + /** Trees knock a hole downwind of themselves. Cheap, and very juicy. */ + function shelterFactor(x, z, dirX, dirZ) { + let f = 1; + for (let i = 0; i < shelters.length; i++) { + const s = shelters[i]; + const rx = x - s.x, rz = z - s.z; + const along = rx * dirX + rz * dirZ; // >0 = downwind of the tree + if (along <= 0 || along >= s.length) continue; + const perp = Math.abs(rx * dirZ - rz * dirX); + if (perp >= s.radius) continue; + // ramp in over the first half-radius so the shadow can't snap on at along=0 + const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along)); + const fPerp = 1 - smoothstep(0, s.radius, perp); + f *= 1 - s.strength * fAlong * fPerp; + } + return f; + } + + const field = { + def, + seed, + gusts, + duration, + + /** + * Trees/house register wind shadows. Lane A calls this after building the + * yard; unset = no shadows, so nothing breaks before world.js lands. + * @param {Array<{x,z,radius,strength,length}>} list + */ + setShelters(list) { + shelters = (list || []).map((s) => ({ + x: s.x, z: s.z, + radius: s.radius ?? 2.5, + strength: Math.min(1, Math.max(0, s.strength ?? 0.45)), + length: s.length ?? (s.radius ?? 2.5) * 4, + })); + return field; + }, + get shelters() { return shelters; }, + + /** Scalar wind speed (m/s) at a point. The cheap path — no allocation. */ + speedAt(x, z, t) { + const uni = uniformSpeed(t); + const d = dirAt(t); + const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d)); + return s > 0 ? s : 0; + }, + + dirAt, + uniformSpeed, + gustOnly, + + /** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */ + vecAt(x, z, t, out) { + const uni = uniformSpeed(t); + const d = dirAt(t); + const dirX = Math.cos(d), dirZ = Math.sin(d); + let s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ); + if (s < 0) s = 0; + out.x = dirX * s; + out.y = 0; // wind is horizontal; lift is the sail's job (Lane B) + out.z = dirZ * s; + return out; + }, + + /** + * The next gust that has been telegraphed but hasn't started ramping. + * eta = seconds until the wind actually rises. Null when nothing's inbound. + */ + telegraph(t) { + for (let i = 0; i < gusts.length; i++) { + const g = gusts[i]; + if (t < g.t0) return null; // sorted — next one hasn't telegraphed yet + if (t < g.rampAt) { + return { eta: g.rampAt - t, dir: dirAt(g.rampAt), power: g.pow }; + } + } + return null; + }, + + /** Storm events (windchange/debris) fired in (a, b]. Pure — replayable. */ + eventsBetween(a, b) { + const evs = def.events || []; + const out = []; + for (let i = 0; i < evs.length; i++) { + if (evs[i].t > a && evs[i].t <= b) out.push(evs[i]); + } + return out; + }, + + /** 0..1 rain intensity for skyfx. */ + rainAt(t) { + const r = def.rain; + if (!r) return 0; + if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t))); + return Math.min(1, Math.max(0, r.intensity ?? 0)); + }, + }; + + return field; +} + +// ---------- storm JSON validator ---------- +// Storms are data so design can tune without code (PLAN3D §4) — which means a +// typo is a data bug, and data bugs should fail loud, not silently blow calm. +export function validateStorm(def, name = 'storm') { + const errors = []; + const bad = (m) => errors.push(`${name}: ${m}`); + const isCurve = (c) => Array.isArray(c) && c.length > 0 + && c.every((p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite)); + const monotonic = (c) => c.every((p, i) => i === 0 || p[0] >= c[i - 1][0]); + + if (!def || typeof def !== 'object') { bad('not an object'); return { ok: false, errors }; } + if (!Number.isFinite(def.duration) || def.duration <= 0) bad('duration must be a positive number'); + + if (!isCurve(def.baseCurve)) bad('baseCurve must be [[t,speed],...] of finite numbers'); + else { + if (!monotonic(def.baseCurve)) bad('baseCurve t must be non-decreasing'); + if (def.baseCurve.some((p) => p[1] < 0)) bad('baseCurve speed must be >= 0'); + const end = def.baseCurve[def.baseCurve.length - 1][0]; + if (Number.isFinite(def.duration) && end < def.duration) { + bad(`baseCurve ends at t=${end} but storm runs to ${def.duration} — tail would flatline`); + } + } + + if (!isCurve(def.dirCurve)) bad('dirCurve must be [[t,radians],...] of finite numbers'); + else if (!monotonic(def.dirCurve)) bad('dirCurve t must be non-decreasing'); + + const g = def.gusts; + if (!g || typeof g !== 'object') bad('gusts block missing'); + else { + const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12; + if (!(minGap > 0)) bad('gusts.minGap must be > 0 (else the timeline never advances)'); + if (maxGap < minGap) bad('gusts.maxGap must be >= minGap'); + // Overlapping gusts stack, and a stacked telegraph is unreadable to the player. + if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`); + if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0'); + } + + for (const e of def.events || []) { + if (!Number.isFinite(e.t)) bad(`event ${JSON.stringify(e)} has no finite t`); + if (!e.type) bad(`event at t=${e.t} has no type`); + if (e.type === 'debris' && !e.model) bad(`debris event at t=${e.t} has no model`); + // A windchange event is HUD metadata; dirCurve is the physics. If they drift + // apart the player gets warned about a swing that never comes. + if (e.type === 'windchange' && isCurve(def.dirCurve)) { + const before = sampleAngleCurve(def.dirCurve, e.t - 0.5); + const after = sampleAngleCurve(def.dirCurve, e.t + (e.over ?? 6)); + const swing = Math.abs(lerpAngle(before, after, 1) - before); + if (swing < 0.5) { + bad(`windchange at t=${e.t} promises a swing but dirCurve only turns ${swing.toFixed(2)} rad by t=${e.t + (e.over ?? 6)}`); + } + } + } + + if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]'); + + return { ok: errors.length === 0, errors }; +} diff --git a/web/world/js/weather.js b/web/world/js/weather.js new file mode 100644 index 0000000..422395c --- /dev/null +++ b/web/world/js/weather.js @@ -0,0 +1,101 @@ +'use strict'; +// SHADES — Lane C — weather: the wind field everyone samples. +// +// Implements the contracts.js wind surface (PLAN3D §4): +// wind.sample(pos, t) -> Vector3 m/s, includes gusts & local effects +// wind.gustTelegraph(t) -> {eta, dir, power} | null +// +// All the maths lives in weather.core.js (pure, no imports). This file is just +// 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 '../vendor/three.module.js'; +import { createWindField, validateStorm, GUST } from './weather.core.js'; + +export { GUST, validateStorm }; + +// 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) { + const url = `${dir}/${name}.json`; + const res = await fetch(url); + if (!res.ok) throw new Error(`weather: cannot load ${url} (${res.status})`); + const def = await res.json(); + const { ok, errors } = validateStorm(def, name); + if (!ok) throw new Error(`weather: ${url} is invalid:\n ${errors.join('\n ')}`); + return def; +} + +/** + * @param {object} def parsed storm JSON + * @param {object} [opts] {seed} — same seed + same def = same storm, every run + * @returns the `wind` object from contracts.js + */ +export function createWind(def, opts = {}) { + const field = createWindField(def, opts); + const scratch = { x: 0, y: 0, z: 0 }; + + const wind = { + /** + * Wind velocity at a world position, m/s. + * @param {THREE.Vector3} pos + * @param {number} t storm time, seconds + * @param {THREE.Vector3} [out] pass one to avoid allocating — sail.js + * samples per-face per-frame, so this matters + */ + sample(pos, t, out) { + const v = out || new THREE.Vector3(); + field.vecAt(pos.x, pos.z, t, scratch); + return v.set(scratch.x, scratch.y, scratch.z); + }, + + /** Scalar speed — for HUD, rain, grass. Cheaper than sample(); no allocation. */ + speedAt(pos, t) { + return field.speedAt(pos.x, pos.z, t); + }, + + /** {eta, dir, power} while a gust is inbound but hasn't risen yet, else null. */ + gustTelegraph(t) { + return field.telegraph(t); + }, + + /** + * Register wind shadows (trees, house). Lane A: call after the yard is built. + * Until then there are simply no shadows — nothing breaks. + * @param {Array<{x,z,radius,strength,length}>} list + */ + setShelters(list) { field.setShelters(list); return wind; }, + + /** Convenience: take shadows straight off world.anchors' tree entries. */ + setSheltersFromTrees(trees, o = {}) { + return wind.setShelters(trees.map((tr) => ({ + x: tr.pos ? tr.pos.x : tr.x, + z: tr.pos ? tr.pos.z : tr.z, + radius: o.radius ?? tr.radius ?? 3, + strength: o.strength ?? 0.45, + length: o.length ?? 14, + }))); + }, + + /** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */ + eventsBetween(a, b) { return field.eventsBetween(a, b); }, + + /** 0..1 rain intensity. */ + rainAt(t) { return field.rainAt(t); }, + + /** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */ + dirAt(t) { return field.dirAt(t); }, + + get duration() { return field.duration; }, + get gusts() { return field.gusts; }, + get def() { return field.def; }, + get seed() { return field.seed; }, + core: field, + }; + + return wind; +} diff --git a/web/world/weather_demo.html b/web/world/weather_demo.html new file mode 100644 index 0000000..0c5801f --- /dev/null +++ b/web/world/weather_demo.html @@ -0,0 +1,263 @@ + + + + +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 +
+
+ + + +