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