Merge remote-tracking branch 'origin/lane/c'
# Conflicts: # THREADS.md
This commit is contained in:
commit
e7639c4264
91
THREADS.md
91
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.
|
||||
|
||||
29
web/world/data/storms/storm_01_gentle.json
Normal file
29
web/world/data/storms/storm_01_gentle.json
Normal file
@ -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 }
|
||||
}
|
||||
41
web/world/data/storms/storm_02_wildnight.json
Normal file
41
web/world/data/storms/storm_02_wildnight.json
Normal file
@ -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 }
|
||||
}
|
||||
254
web/world/js/debris.js
Normal file
254
web/world/js/debris.js
Normal file
@ -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<string,THREE.Object3D>} [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;
|
||||
}
|
||||
470
web/world/js/skyfx.js
Normal file
470
web/world/js/skyfx.js
Normal file
@ -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;
|
||||
}
|
||||
@ -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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
32
web/world/js/tests/run-node.mjs
Normal file
32
web/world/js/tests/run-node.mjs
Normal file
@ -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);
|
||||
299
web/world/js/tests/weather.selftest.js
Normal file
299
web/world/js/tests/weather.selftest.js
Normal file
@ -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<string, 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 };
|
||||
}
|
||||
399
web/world/js/weather.core.js
Normal file
399
web/world/js/weather.core.js
Normal file
@ -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 };
|
||||
}
|
||||
101
web/world/js/weather.js
Normal file
101
web/world/js/weather.js
Normal file
@ -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;
|
||||
}
|
||||
263
web/world/weather_demo.html
Normal file
263
web/world/weather_demo.html
Normal file
@ -0,0 +1,263 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SHADES — Lane C — weather bench</title>
|
||||
<style>
|
||||
:root { --ink:#d8d8e0; --gold:#ffd23d; --neon:#3dff8b; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; overflow:hidden; background:#000;
|
||||
font:13px/1.45 "Courier New", ui-monospace, monospace; color:var(--ink); }
|
||||
canvas { display:block; }
|
||||
#hud { position:fixed; top:10px; left:10px; background:rgba(6,6,12,.75); padding:8px 12px;
|
||||
border:1px solid #26263a; z-index:3; min-width:250px; }
|
||||
#hud b { color:var(--gold); }
|
||||
#hud .warn { color:#ff6; font-weight:bold; }
|
||||
#hud .bad { color:#f66; font-weight:bold; }
|
||||
#ctl { position:fixed; bottom:10px; left:10px; background:rgba(6,6,12,.8); padding:8px 12px;
|
||||
border:1px solid #26263a; z-index:3; }
|
||||
#ctl button { background:#1d1d2b; color:var(--ink); border:1px solid #666; font:inherit;
|
||||
padding:4px 9px; cursor:pointer; }
|
||||
#ctl button:hover { border-color:var(--neon); color:var(--neon); }
|
||||
#ctl input[type=range] { width:220px; vertical-align:middle; }
|
||||
#note { position:fixed; top:10px; right:10px; background:rgba(6,6,12,.75); padding:8px 12px;
|
||||
border:1px solid #26263a; z-index:3; max-width:280px; color:#8a8a99; }
|
||||
.bar { display:inline-block; width:90px; height:7px; border:1px solid #555; vertical-align:middle; }
|
||||
.bar i { display:block; height:100%; background:var(--neon); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div id="hud"></div>
|
||||
<div id="note">
|
||||
<b>Lane C bench.</b> 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.
|
||||
<br><br>drag = orbit · click = start audio
|
||||
</div>
|
||||
<div id="ctl"></div>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from './vendor/three.module.js';
|
||||
import { loadStorm, createWind } from './js/weather.js';
|
||||
import { createSkyFx } from './js/skyfx.js';
|
||||
import { createDebris } from './js/debris.js';
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(2, devicePixelRatio));
|
||||
renderer.shadowMap.enabled = true;
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9fc4e8);
|
||||
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 500);
|
||||
|
||||
// --- graybox yard: 30×20 m, origin centre (stands in for Lane A's world.js) ---
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(30, 20),
|
||||
new THREE.MeshStandardMaterial({ color: 0x4a7c3f, roughness: 1 }),
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.receiveShadow = true;
|
||||
scene.add(ground);
|
||||
|
||||
// Lane A's landed yard (THREADS): t1 (-9,2), t2 (8,-2), house edge at z=-9.9
|
||||
const TREES = [{ x: -9, z: 2 }, { x: 8, z: -2 }];
|
||||
for (const tr of TREES) {
|
||||
const trunk = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.2, 0.28, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5a3d24 }),
|
||||
);
|
||||
trunk.position.set(tr.x, 2, tr.z);
|
||||
trunk.castShadow = true;
|
||||
scene.add(trunk);
|
||||
const canopy = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(3, 12, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x285f23 }),
|
||||
);
|
||||
canopy.position.set(tr.x, 5, tr.z);
|
||||
canopy.castShadow = true;
|
||||
scene.add(canopy);
|
||||
}
|
||||
// house edge along north (-Z), for scale
|
||||
const house = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(30, 3.2, 1),
|
||||
new THREE.MeshStandardMaterial({ color: 0x8a8f96 }),
|
||||
);
|
||||
house.position.set(0, 1.6, -10.4);
|
||||
scene.add(house);
|
||||
// the thing you're protecting — Lane A's gardenBed rect
|
||||
const bed = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(6, 0.25, 4),
|
||||
new THREE.MeshStandardMaterial({ color: 0x6b4a2f }),
|
||||
);
|
||||
bed.position.set(1, 0.12, 2);
|
||||
scene.add(bed);
|
||||
// 1.7 m reference person
|
||||
const ref = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.25, 1.2, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffd27a }),
|
||||
);
|
||||
ref.position.set(2, 0.85, 2);
|
||||
ref.castShadow = true;
|
||||
scene.add(ref);
|
||||
const player = { pos: ref.position, carrying: null, busy: false };
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xfff4e0, 2.2);
|
||||
sun.position.set(-12, 18, 6);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(1024, 1024);
|
||||
scene.add(sun);
|
||||
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 0.9);
|
||||
scene.add(hemi);
|
||||
|
||||
// --- MOCK sail (Lane B owns the real cloth) — a bare node grid so we can see
|
||||
// debris shove it and drive the creak/flog audio off corner loads.
|
||||
const N = 9;
|
||||
const nodes = [];
|
||||
for (let v = 0; v < N; v++) {
|
||||
for (let u = 0; u < N; u++) {
|
||||
nodes.push({ x: -4 + (u / (N - 1)) * 8, y: 3.2, z: -3 + (v / (N - 1)) * 6 });
|
||||
}
|
||||
}
|
||||
const sailGeo = new THREE.BufferGeometry();
|
||||
sailGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(nodes.length * 3), 3));
|
||||
const sailPts = new THREE.Points(sailGeo, new THREE.PointsMaterial({ color: 0xe8c46a, size: 0.14 }));
|
||||
scene.add(sailPts);
|
||||
const mockSail = {
|
||||
nodes,
|
||||
corners: [
|
||||
{ anchorId: 'h1', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
|
||||
{ anchorId: 'h3', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
|
||||
{ anchorId: 'p1', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
|
||||
{ anchorId: 'p2', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
|
||||
],
|
||||
};
|
||||
|
||||
// --- weather ---
|
||||
const params = new URLSearchParams(location.search);
|
||||
const stormName = params.get('storm') || 'storm_02_wildnight';
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
wind.setShelters(TREES.map((t) => ({ x: t.x, z: t.z, radius: 3, strength: 0.45, length: 14 })));
|
||||
|
||||
const ticker = [];
|
||||
const sky = createSkyFx({ scene, camera, wind, sun, hemi, onEvent: (s) => ticker.unshift(s) });
|
||||
const debris = createDebris({
|
||||
wind, scene, player,
|
||||
onEvent: (s) => ticker.unshift(s),
|
||||
onHitPlayer: (p, impact) => ticker.unshift(`KNOCKED DOWN by ${p.model} (${impact.toFixed(0)})`),
|
||||
});
|
||||
addEventListener('pointerdown', () => sky.unlockAudio(), { once: true });
|
||||
|
||||
// --- controls ---
|
||||
let t = 0, playing = true, rate = 1;
|
||||
const ctl = document.getElementById('ctl');
|
||||
ctl.innerHTML = `
|
||||
<button id="play">pause</button>
|
||||
<button id="r1">1×</button><button id="r4">4×</button><button id="r0">0.25×</button>
|
||||
<button id="reset">reset</button>
|
||||
<button id="break">break a corner</button>
|
||||
<button id="crate">throw a crate</button>
|
||||
<input id="scrub" type="range" min="0" max="${def.duration}" step="0.1" value="0">
|
||||
`;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
$('play').onclick = () => { playing = !playing; $('play').textContent = playing ? 'pause' : 'play'; };
|
||||
$('r1').onclick = () => { rate = 1; };
|
||||
$('r4').onclick = () => { rate = 4; };
|
||||
$('r0').onclick = () => { rate = 0.25; };
|
||||
$('reset').onclick = () => { t = 0; debris.clear(); ticker.length = 0; mockSail.corners.forEach((c) => { c.broken = false; }); };
|
||||
$('break').onclick = () => { const c = mockSail.corners.find((x) => !x.broken); if (c) { c.broken = true; ticker.unshift(`${c.hw.name} BLOWS at ${c.anchorId.toUpperCase()}!`); } };
|
||||
$('crate').onclick = () => debris.spawn({ model: 'BlueCrate_v2', lateral: (Math.random() * 6 - 3), text: 'crate!' }, t);
|
||||
$('scrub').oninput = (e) => { t = parseFloat(e.target.value); debris.clear(); };
|
||||
|
||||
let yaw = 0.7, pitch = 0.28, dist = 26, dragging = false, lx = 0, ly = 0;
|
||||
addEventListener('pointerdown', (e) => { dragging = true; lx = e.clientX; ly = e.clientY; });
|
||||
addEventListener('pointerup', () => { dragging = false; });
|
||||
addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
yaw -= (e.clientX - lx) * 0.005; pitch = Math.min(1.3, Math.max(0.05, pitch + (e.clientY - ly) * 0.004));
|
||||
lx = e.clientX; ly = e.clientY;
|
||||
});
|
||||
addEventListener('wheel', (e) => { dist = Math.min(60, Math.max(8, dist + e.deltaY * 0.02)); });
|
||||
|
||||
function resize() {
|
||||
const w = innerWidth, h = innerHeight;
|
||||
renderer.setSize(w, h);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
addEventListener('resize', resize); resize();
|
||||
|
||||
// --- loop: fixed-dt sim, rAF only drives the clock (PLAN3D §0) ---
|
||||
const DT = 1 / 60;
|
||||
let acc = 0, last = performance.now();
|
||||
const hud = document.getElementById('hud');
|
||||
const probe = new THREE.Vector3();
|
||||
const w = new THREE.Vector3();
|
||||
const posAttr = sailGeo.getAttribute('position');
|
||||
|
||||
function frame(now) {
|
||||
const real = Math.min(0.1, (now - last) / 1000);
|
||||
last = now;
|
||||
if (playing) acc += real * rate;
|
||||
|
||||
while (acc >= DT) {
|
||||
acc -= DT;
|
||||
t += DT;
|
||||
if (t > def.duration) t = 0;
|
||||
|
||||
// mock cloth: nodes just bob with local wind so debris has something to hit
|
||||
for (const n of nodes) {
|
||||
probe.set(n.x, n.y, n.z);
|
||||
wind.sample(probe, t, w);
|
||||
const sp = Math.hypot(w.x, w.z);
|
||||
n.y += ((3.2 + Math.sin(t * 3 + n.x) * sp * 0.02) - n.y) * 0.08;
|
||||
}
|
||||
// mock loads so the creak layer has something to track
|
||||
probe.set(0, 3.2, 0);
|
||||
const sp = wind.speedAt(probe, t);
|
||||
mockSail.corners.forEach((c, i) => {
|
||||
c.load = c.broken ? 0 : sp * sp * 0.021 * (0.7 + i * 0.16);
|
||||
});
|
||||
|
||||
debris.step(DT, t, { player, sail: mockSail });
|
||||
sky.step(DT, t, { sail: mockSail });
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) posAttr.setXYZ(i, nodes[i].x, nodes[i].y, nodes[i].z);
|
||||
posAttr.needsUpdate = true;
|
||||
|
||||
camera.position.set(
|
||||
Math.sin(yaw) * Math.cos(pitch) * dist,
|
||||
Math.sin(pitch) * dist + 1.5,
|
||||
Math.cos(yaw) * Math.cos(pitch) * dist,
|
||||
);
|
||||
camera.lookAt(0, 2, 0);
|
||||
|
||||
$('scrub').value = t.toFixed(1);
|
||||
probe.set(0, 1.7, 0);
|
||||
wind.sample(probe, t, w);
|
||||
const speed = Math.hypot(w.x, w.z);
|
||||
const tg = wind.gustTelegraph(t);
|
||||
const worst = Math.max(...mockSail.corners.map((c) => (c.broken ? 0 : c.load / c.hw.rating)));
|
||||
hud.innerHTML = `
|
||||
<div><b>${def.name}</b> — ${stormName}</div>
|
||||
<div>t <b>${t.toFixed(1)}</b> / ${def.duration}s (${rate}×)</div>
|
||||
<div>wind <b>${speed.toFixed(1)}</b> m/s (${(speed * 3.6).toFixed(0)} km/h)</div>
|
||||
<div>dir ${(wind.dirAt(t)).toFixed(2)} rad</div>
|
||||
<div>rain <span class="bar"><i style="width:${wind.rainAt(t) * 100}%"></i></span></div>
|
||||
<div>worst <span class="bar"><i style="width:${Math.min(100, worst * 100)}%;background:${worst > 0.8 ? '#f66' : '#3dff8b'}"></i></span></div>
|
||||
<div>debris ${debris.pieces.length} audio ${sky.audio.ready ? sky.audio.state : '(click)'}</div>
|
||||
<div>flash ${sky.flash.toFixed(2)}</div>
|
||||
${tg ? `<div class="warn">GUST INBOUND ${tg.eta.toFixed(1)}s pow ${tg.power.toFixed(0)}</div>` : '<div> </div>'}
|
||||
${ticker.slice(0, 3).map((s) => `<div class="bad">${s}</div>`).join('')}
|
||||
`;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
window.__bench = { wind, sky, debris, mockSail, def, get t() { return t; } };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user