debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that spikes a corner is the one that launches the neighbour's bin. Ground bounce via world.heightAt (contracts.js documents it as ours), sphere-vs-player knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it lights up when Lane B exposes nodes and stays silent until then. skyfx.js: instanced rain that wraps around the camera rather than respawning, storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers (wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst corner, flog when one blows). It modulates Lane A's lights and hands them back on dispose() rather than owning them. weather_demo.html: graybox bench to drive all three before M0 — mock sail, storm scrub, 4x, throw-a-crate. Aligned to contracts.js now that it has landed: relative three imports (there is no importmap), contracts' rng() instead of a local copy, and storm paths resolved off import.meta.url — server.py serves the repo root, so an absolute /world/... would have 404'd at integration. storm_02: fix the southerly change. contracts.js puts north at -Z and the wind vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW buster off the open side of the yard, into the house. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
384 lines
15 KiB
JavaScript
384 lines
15 KiB
JavaScript
'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. */
|
||
export function valueNoise2(x, z, seed) {
|
||
const ix = Math.floor(x), iz = Math.floor(z);
|
||
const ux = smooth(x - ix), uz = smooth(z - iz);
|
||
const a = hash2(ix, iz, seed), b = hash2(ix + 1, iz, seed);
|
||
const c = hash2(ix, iz + 1, seed), d = hash2(ix + 1, iz + 1, 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 };
|
||
}
|