HardYards/web/world/js/weather.core.js
m3ultra 11f27c493c Fix debris ground friction, cloud seam and yard coords
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>
2026-07-16 21:50:41 +10:00

400 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 };
}