Rain honestly walks under a sail (a droplet's terminal velocity is ~9 m/s, so a 30 m/s crosswind blows it in at ~73° off vertical), which is why a perfect rig scored 54% garden vs 48% for no rig at all. Hail is dense: terminal ~22 m/s, and a dense stone couples weakly to the crosswind, so even a gale leans it ≤20°. Steep hail is blocked by overhead cloth, so the garden score becomes rig-responsive without faking the rain physics — and it was always DESIGN.md canon (hail shreds gardens; drainage answers rain, later). weather.core: `hail` block in storm JSON — authored bursts (envelopes) plus one synced to every gust at/above `withGustsAbove`, so the biggest gusts arrive WITH ice. `hailAt(t)` (max over live bursts), `hailSize`, validator. Gust-synced bursts key off the deterministic gust timeline and draw ZERO randomness, so tuning hail can't re-time the storm — asserted, same guarantee as the downdraft. storm_02 bursts on the southerly change (peak 1.0 at t=56.5, 11.4 hail-seconds); storm_03 a mild 0.5; storm_01 none. skyfx: `hailVelocity` (steep, ≤20° lean, terminal-velocity reasoning cited so nobody re-opens the rain-angle argument), a second RainShadow fed the steep vector, `gardenHailExposure(bed, t)` in the gardenExposure mold (A wires the drain), instanced falling stones (hidden under the cloth so you SEE the sail working), and hail audio: ground clatter that fades as the sail intercepts, plus the cloth DRUM that rises exactly as the sail catches hail — the "my sail is earning its money" sound. Decision-13 gate proven: no-sail garden takes 4.4× the hail of a bed under a good rig over a full storm_02 (bar is ≥2×), through the real gardenHailExposure and B's SailRig. Verified live too (router-patched): stones fall visibly steeper than the rain beside them, and a bed-covering rig cuts hail exposure roughly in half at the burst. Selftest 214/0/0 (was 207); node 36/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
4.3 KiB
JavaScript
115 lines
4.3 KiB
JavaScript
'use strict';
|
|
// SHADES — Lane C — weather: the wind field everyone samples.
|
|
//
|
|
// Implements the contracts.js wind surface (PLAN3D §4):
|
|
// wind.sample(pos, t) -> Vector3 m/s, includes gusts & local effects
|
|
// wind.gustTelegraph(t) -> {eta, dir, power} | null
|
|
//
|
|
// All the maths lives in weather.core.js (pure, no imports). This file is just
|
|
// the THREE adapter + storm loading, so the sim stays node-testable and the
|
|
// determinism rule can't be broken by accident.
|
|
|
|
import * as THREE from '../vendor/three.module.js';
|
|
import { createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION } from './weather.core.js';
|
|
|
|
export { GUST, validateStorm, RAIN_TIME_COMPRESSION };
|
|
|
|
// Resolved against this module, not the server root: server.py serves the repo
|
|
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
|
// import.meta.url is right under both, and under whatever Lane A does next.
|
|
const STORM_DIR = new URL('../data/storms', import.meta.url).href;
|
|
|
|
/** Fetch + validate a storm def. Throws loud on bad data — storms are content. */
|
|
export async function loadStorm(name, dir = STORM_DIR) {
|
|
const url = `${dir}/${name}.json`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`weather: cannot load ${url} (${res.status})`);
|
|
const def = await res.json();
|
|
const { ok, errors } = validateStorm(def, name);
|
|
if (!ok) throw new Error(`weather: ${url} is invalid:\n ${errors.join('\n ')}`);
|
|
return def;
|
|
}
|
|
|
|
/**
|
|
* @param {object} def parsed storm JSON
|
|
* @param {object} [opts] {seed} — same seed + same def = same storm, every run
|
|
* @returns the `wind` object from contracts.js
|
|
*/
|
|
export function createWind(def, opts = {}) {
|
|
const field = createWindField(def, opts);
|
|
const scratch = { x: 0, y: 0, z: 0 };
|
|
|
|
const wind = {
|
|
/**
|
|
* Wind velocity at a world position, m/s.
|
|
* @param {THREE.Vector3} pos
|
|
* @param {number} t storm time, seconds
|
|
* @param {THREE.Vector3} [out] pass one to avoid allocating — sail.js
|
|
* samples per-face per-frame, so this matters
|
|
*/
|
|
sample(pos, t, out) {
|
|
const v = out || new THREE.Vector3();
|
|
field.vecAt(pos.x, pos.z, t, scratch);
|
|
return v.set(scratch.x, scratch.y, scratch.z);
|
|
},
|
|
|
|
/** Scalar speed — for HUD, rain, grass. Cheaper than sample(); no allocation. */
|
|
speedAt(pos, t) {
|
|
return field.speedAt(pos.x, pos.z, t);
|
|
},
|
|
|
|
/** {eta, dir, power} while a gust is inbound but hasn't risen yet, else null. */
|
|
gustTelegraph(t) {
|
|
return field.telegraph(t);
|
|
},
|
|
|
|
/**
|
|
* Register wind shadows (trees, house). Lane A: call after the yard is built.
|
|
* Until then there are simply no shadows — nothing breaks.
|
|
* @param {Array<{x,z,radius,strength,length}>} list
|
|
*/
|
|
setShelters(list) { field.setShelters(list); return wind; },
|
|
|
|
/** Convenience: take shadows straight off world.anchors' tree entries. */
|
|
setSheltersFromTrees(trees, o = {}) {
|
|
return wind.setShelters(trees.map((tr) => ({
|
|
x: tr.pos ? tr.pos.x : tr.x,
|
|
z: tr.pos ? tr.pos.z : tr.z,
|
|
radius: o.radius ?? tr.radius ?? 3,
|
|
strength: o.strength ?? 0.45,
|
|
length: o.length ?? 14,
|
|
})));
|
|
},
|
|
|
|
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
|
|
eventsBetween(a, b) { return field.eventsBetween(a, b); },
|
|
|
|
/** 0..1 rain intensity — drives drop count and opacity. */
|
|
rainAt(t) { return field.rainAt(t); },
|
|
|
|
/** 0..1 hail intensity (SPRINT5 decision 13). Zero for a hail-free storm. */
|
|
hailAt(t) { return field.hailAt(t); },
|
|
|
|
/** Stone-size scalar — audio pitch, visual scale, damage weight. */
|
|
get hailSize() { return field.hailSize; },
|
|
|
|
/** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */
|
|
rainMmPerHour(t) { return field.rainMmPerHour(t); },
|
|
|
|
/** Real-world mm of water delivered over (t0,t1]. Multiply by
|
|
* RAIN_TIME_COMPRESSION cloth-side — see weather.core. */
|
|
rainDepthMm(t0, t1) { return field.rainDepthMm(t0, t1); },
|
|
|
|
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
|
|
dirAt(t) { return field.dirAt(t); },
|
|
|
|
get duration() { return field.duration; },
|
|
get gusts() { return field.gusts; },
|
|
get def() { return field.def; },
|
|
get seed() { return field.seed; },
|
|
core: field,
|
|
};
|
|
|
|
return wind;
|
|
}
|