The gust-only downdraft could not satisfy B's 60% no-free-lunch bar and the §7
twisted-survives gate together: the downdraft peaked at the gust peak, where
the horizontal peaked too, so a flat sail never reached 60% of a pitched one's
load without a spike so violent it also broke the twisted rig. The integrator
measured the pincer (0.58 -> 48% and still breaks twisted).
Fix: the downdraft is now a fraction of the LOCAL total wind speed, not of gust
power (weather.core verticalAt = -frac * localHoriz). It presses a flat roof
steadily across the whole storm — peak total 32.6 m/s dwarfs peak gust power
12.6 — so the ratio clears 60% at a gentle fraction, with no gust-peak spike.
It rides the local speed, so a tree's wind shadow shelters from falling air too.
speedAt() stays horizontal (a wind meter doesn't read falling air).
Field renamed downdraft -> downdraftOfTotal; the validator rejects the old name
rather than silently re-meaning it. The vertical now carries NO rng draws at
all, so the determinism guarantee (tuning can't re-time gusts) is structural,
not just separate-stream.
Measured both gates myself with B's SailRig (8-direction flat-vs-pitched sweep +
§7 legs). Target for storm_02 is 0.45: 69% of-max / 60% worst-heading on the
bar, twisted rated rig survives with ~21% margin. HELD at 0.12 this commit —
the current yard's only twisted quad ('h1,t2,p1,t1', ~190 m2) starts losing a
corner near 0.15 in the exact solver, so 0.45 would red B's §7. 0.12 ~= the old
gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s). Bump to 0.45 is a one-number
joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7.
storm_03_southerly: campaign ramp between gentle and wildnight — peak gust 21
m/s, sustained 13, one moderate southerly change. Auto-swept by the suite.
Selftest 170/0/0 (all three §7 legs green). Verified live: downdraft/horizontal
ratio is exactly 0.12 in-game, rain occlusion still covers the bed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
452 lines
18 KiB
JavaScript
452 lines
18 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 const DEFAULT_DOWNDRAFT = 0.22;
|
||
|
||
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;
|
||
// SPRINT3 decision 8: the downdraft is a fraction of TOTAL wind speed, not of
|
||
// gust power. `downdraftOfTotal` is the field name; `downdraft` is read as a
|
||
// legacy alias so an un-migrated storm doesn't silently lose its vertical.
|
||
const gd = def.gusts || {};
|
||
const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT;
|
||
|
||
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);
|
||
}
|
||
|
||
/** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow.
|
||
* The one place the local-speed maths lives; speedAt/vecAt/verticalAt share it. */
|
||
function localHoriz(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;
|
||
}
|
||
|
||
/**
|
||
* Vertical wind, m/s. NEGATIVE = downward. A fraction of the LOCAL horizontal
|
||
* speed at this point and time.
|
||
*
|
||
* Why a horizontal sail must pay: cloth pressure goes with dot(wind, normal),
|
||
* a flat panel's normal points at the sky, so in a purely horizontal wind the
|
||
* dot is ~0 and "lie it flat and ignore the storm" wins — the opposite of the
|
||
* game. A descending component hits a flat panel square on.
|
||
*
|
||
* SPRINT3 decision 8 — fraction of TOTAL, not of gust power. Under gust-only
|
||
* semantics the downdraft peaked exactly at the gust peak, where the horizontal
|
||
* ALSO peaked, so a flat sail could never reach 60% of a pitched one's load
|
||
* (B measured 34%) without a downdraft so violent it also killed the twisted
|
||
* rig the §7 gate needs to survive. The two gates pincered. Riding total speed
|
||
* instead spreads the load across the whole storm: a flat roof is pressed
|
||
* steadily (peak total 32.6 m/s dwarfs peak gust power 12.6), so the ratio
|
||
* clears 60% at a gentle fraction, without a spike at the gust peak. It follows
|
||
* the LOCAL speed, so a tree's wind shadow shelters from falling air too.
|
||
*/
|
||
function verticalAt(x, z, t) {
|
||
if (downFrac <= 0) return 0;
|
||
return -downFrac * localHoriz(x, z, t);
|
||
}
|
||
|
||
// ---- 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 — HORIZONTAL only, which is what an
|
||
* anemometer reads and what the HUD, rain and grass want. The gust downdraft
|
||
* is deliberately not in here: a wind meter jumping because air is falling
|
||
* past it would read as a bug. Use vecAt/sample for the full 3D vector.
|
||
* The cheap path — no allocation.
|
||
*/
|
||
speedAt(x, z, t) {
|
||
return localHoriz(x, z, t);
|
||
},
|
||
|
||
dirAt,
|
||
uniformSpeed,
|
||
gustOnly,
|
||
verticalAt,
|
||
get downFrac() { return downFrac; },
|
||
|
||
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
|
||
vecAt(x, z, t, out) {
|
||
const d = dirAt(t);
|
||
const dirX = Math.cos(d), dirZ = Math.sin(d);
|
||
const s = localHoriz(x, z, t);
|
||
out.x = dirX * s;
|
||
out.y = -downFrac * s; // the downdraft rides the local speed — see verticalAt()
|
||
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');
|
||
// `downdraft` (gust-only, pre-SPRINT3) is still accepted but flagged, so an
|
||
// un-migrated storm loads visibly wrong rather than silently at a third power.
|
||
if (g.downdraft != null && g.downdraftOfTotal == null) {
|
||
bad('gusts.downdraft is the old gust-only field — rename to downdraftOfTotal (SPRINT3 decision 8); it now means a fraction of TOTAL wind speed');
|
||
}
|
||
const dd = g.downdraftOfTotal ?? g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||
if (!Number.isFinite(dd) || dd < 0 || dd > 1) {
|
||
bad(`gusts.downdraftOfTotal must be 0..1 — the fraction of TOTAL wind speed that blows DOWN — got ${dd}`);
|
||
}
|
||
}
|
||
|
||
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 };
|
||
}
|