debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that spikes a corner is the one that launches the neighbour's bin. Ground bounce via world.heightAt (contracts.js documents it as ours), sphere-vs-player knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it lights up when Lane B exposes nodes and stays silent until then. skyfx.js: instanced rain that wraps around the camera rather than respawning, storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers (wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst corner, flog when one blows). It modulates Lane A's lights and hands them back on dispose() rather than owning them. weather_demo.html: graybox bench to drive all three before M0 — mock sail, storm scrub, 4x, throw-a-crate. Aligned to contracts.js now that it has landed: relative three imports (there is no importmap), contracts' rng() instead of a local copy, and storm paths resolved off import.meta.url — server.py serves the repo root, so an absolute /world/... would have 404'd at integration. storm_02: fix the southerly change. contracts.js puts north at -Z and the wind vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW buster off the open side of the yard, into the house. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
102 lines
3.7 KiB
JavaScript
102 lines
3.7 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 } from './weather.core.js';
|
|
|
|
export { GUST, validateStorm };
|
|
|
|
// 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. */
|
|
rainAt(t) { return field.rainAt(t); },
|
|
|
|
/** 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;
|
|
}
|