Three bugs the bench found once it was actually running a storm: - Debris was glued to the floor. The scrape `v *= 0.86` ran every frame while grounded, which is 0.86^60 per second — it pinned a 9 kg crate at 0.7 m/s in a 19 m/s wind. Friction now applies on impact, with dt-scaled rolling friction while resting. A crate crosses the whole yard at ~6 m/s. - Debris slid instead of tumbling: wind is horizontal, so once down there was no vertical force at all and it skated at constant height. Added tumbling lift that flips sign as it rolls — bins hop now. - The cloud dome had a dead straight seam across the sky. The fbm claimed to tile and didn't; now each octave wraps at its own integer period. Also: shelters and the bench now use Lane A's landed yard coords (t1 -9,2 / t2 8,-2, gardenBed 1,2) instead of my guesses, so shelter tuning means something. Verified in-browser against a real storm: crate crosses the yard and the t=74 bin spawns from storm JSON; sail node shoved 0.32 m; a 14 kg bin at 20 m/s knocks the player down and a tub drifting at 0.3 m/s correctly does not; lightning peaks 0.88. Lane A's selftest: 37 pass / 3 skip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
400 lines
15 KiB
JavaScript
400 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.
|
||
*
|
||
* @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 };
|
||
}
|