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>
231 lines
8.2 KiB
JavaScript
231 lines
8.2 KiB
JavaScript
'use strict';
|
|
// SHADES — Lane C — debris: things that should have been tied down.
|
|
//
|
|
// Hand-rolled kinematic tumble (PLAN3D §5-C.4). No physics engine, no deps.
|
|
// Deterministic: step(dt, t), seeded RNG, no Date.now — so a storm replays.
|
|
//
|
|
// Spawns off `debris` events in the storm JSON, upwind of the yard, and lets the
|
|
// wind field carry it across. Drag goes with speed², same as the sail, so the
|
|
// same gust that spikes a corner is the one that launches the neighbour's bin.
|
|
|
|
import * as THREE from '../vendor/three.module.js';
|
|
import { rng } from './contracts.js';
|
|
|
|
const RHO = 1.2; // air density, kg/m³
|
|
const GRAVITY = -9.81;
|
|
|
|
// Fallback specs for Lane E's debris set (3D-STORE crates/tubs). Radius is the
|
|
// collision sphere, not the render bounds — a crate is boxy, but a sphere is
|
|
// what you can afford to test 6 of per node per frame.
|
|
const MODEL_SPEC = {
|
|
BlueCrate_v2: { r: 0.30, mass: 9, cd: 1.05 },
|
|
BlackTub_v2: { r: 0.34, mass: 5, cd: 1.10 },
|
|
WhiteTub_v2: { r: 0.34, mass: 5, cd: 1.10 },
|
|
WoodenBin_v2: { r: 0.42, mass: 14, cd: 1.05 },
|
|
LibraryTrolley_v1: { r: 0.45, mass: 22, cd: 0.95 },
|
|
};
|
|
const DEFAULT_SPEC = { r: 0.35, mass: 8, cd: 1.05 };
|
|
|
|
/**
|
|
* @param {object} o
|
|
* @param {object} o.wind from weather.js
|
|
* @param {THREE.Object3D} o.scene
|
|
* @param {Object<string,THREE.Object3D>} [o.models] name -> template (Lane E's GLBs)
|
|
* @param {function} [o.onHitPlayer] (piece, impact) — Lane D knocks the player down
|
|
* @param {function} [o.onEvent] (text) — HUD ticker
|
|
* @param {object} [o.bounds] {x, z} half-extents before despawn
|
|
*/
|
|
export function createDebris(o = {}) {
|
|
const wind = o.wind;
|
|
const scene = o.scene || null;
|
|
const models = o.models || {};
|
|
const bounds = o.bounds || { x: 26, z: 20 };
|
|
// contracts.js documents world.heightAt as the thing Lane C bounces debris off.
|
|
// Flat fallback so this still runs against a graybox yard.
|
|
const groundAt = o.heightAt || (() => o.groundY ?? 0);
|
|
const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e);
|
|
|
|
const pieces = [];
|
|
const w = new THREE.Vector3();
|
|
const probe = new THREE.Vector3();
|
|
|
|
/** Graybox stand-in so a missing GLB can't break Lane A's merge. */
|
|
function placeholder(spec) {
|
|
const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8);
|
|
const m = new THREE.MeshStandardMaterial({ color: 0x8a6a3a, roughness: 0.9 });
|
|
return new THREE.Mesh(g, m);
|
|
}
|
|
|
|
function spawn(ev, t) {
|
|
const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) };
|
|
if (Number.isFinite(ev.mass)) spec.mass = ev.mass;
|
|
|
|
// upwind of the yard, offset sideways, so it crosses the whole thing
|
|
const d = wind.dirAt(t);
|
|
const dx = Math.cos(d), dz = Math.sin(d);
|
|
const lat = ev.lateral ?? 0;
|
|
const dist = ev.spawnDist ?? 18;
|
|
const x = -dx * dist - dz * lat;
|
|
const z = -dz * dist + dx * lat;
|
|
const y = groundAt(x, z) + spec.r + (ev.height ?? 0.2 + rand() * 1.2);
|
|
|
|
const tmpl = models[ev.model];
|
|
const mesh = tmpl ? tmpl.clone(true) : placeholder(spec);
|
|
mesh.castShadow = true;
|
|
if (scene) scene.add(mesh);
|
|
|
|
// already moving — it's been blowing across the neighbour's yard for a while
|
|
probe.set(x, y, z);
|
|
wind.sample(probe, t, w);
|
|
|
|
const piece = {
|
|
model: ev.model,
|
|
x, y, z,
|
|
vx: w.x * 0.8, vy: 0, vz: w.z * 0.8,
|
|
// spin axis is arbitrary but seeded; rate scales with airspeed in step()
|
|
sx: rand() * 2 - 1, sy: rand() * 2 - 1, sz: rand() * 2 - 1,
|
|
spin: 0,
|
|
r: spec.r, mass: spec.mass, cd: spec.cd,
|
|
area: Math.PI * spec.r * spec.r,
|
|
hitPlayer: false,
|
|
mesh,
|
|
alive: true,
|
|
};
|
|
pieces.push(piece);
|
|
if (ev.text && o.onEvent) o.onEvent(ev.text);
|
|
return piece;
|
|
}
|
|
|
|
function despawn(p) {
|
|
p.alive = false;
|
|
if (scene && p.mesh) scene.remove(p.mesh);
|
|
}
|
|
|
|
const debris = {
|
|
get pieces() { return pieces; },
|
|
|
|
/** Lane E's GLBs, once they land. name -> Object3D template. */
|
|
setModels(map) { Object.assign(models, map); return debris; },
|
|
|
|
/** Manual spawn — handy for tuning and for Lane A's debug keys. */
|
|
spawn,
|
|
|
|
/**
|
|
* @param {number} dt fixed step
|
|
* @param {number} t storm time
|
|
* @param {object} [world] {player, sail} — both optional, both duck-typed
|
|
*/
|
|
step(dt, t, world = {}) {
|
|
// storm JSON drives the spawns; poll the window so nothing is missed
|
|
if (wind) {
|
|
for (const ev of wind.eventsBetween(t - dt, t)) {
|
|
if (ev.type === 'debris') spawn(ev, t);
|
|
}
|
|
}
|
|
|
|
const player = world.player;
|
|
const sail = world.sail;
|
|
|
|
for (let i = pieces.length - 1; i >= 0; i--) {
|
|
const p = pieces[i];
|
|
|
|
probe.set(p.x, p.y, p.z);
|
|
wind.sample(probe, t, w);
|
|
|
|
// drag against the AIR, not the ground: F = ½ρ Cd A |w-v| (w-v)
|
|
const rx = w.x - p.vx, ry = w.y - p.vy, rz = w.z - p.vz;
|
|
const rel = Math.hypot(rx, ry, rz);
|
|
const k = 0.5 * RHO * p.cd * p.area * rel / p.mass;
|
|
p.vx += rx * k * dt;
|
|
p.vy += ry * k * dt + GRAVITY * dt;
|
|
p.vz += rz * k * dt;
|
|
|
|
p.x += p.vx * dt;
|
|
p.y += p.vy * dt;
|
|
p.z += p.vz * dt;
|
|
|
|
// ground: bounce, shed sideways speed, keep skittering
|
|
const floor = groundAt(p.x, p.z) + p.r;
|
|
if (p.y <= floor) {
|
|
p.y = floor;
|
|
if (p.vy < 0) p.vy = -p.vy * 0.32; // dead-ish bounce, it's a plastic tub
|
|
if (Math.abs(p.vy) < 0.35) p.vy = 0;
|
|
p.vx *= 0.86; p.vz *= 0.86; // scrape
|
|
}
|
|
|
|
// tumble rate follows airspeed — becalmed debris shouldn't keep spinning
|
|
p.spin += rel * 0.35 * dt;
|
|
if (p.mesh) {
|
|
p.mesh.position.set(p.x, p.y, p.z);
|
|
p.mesh.rotation.set(p.sx * p.spin, p.sy * p.spin, p.sz * p.spin);
|
|
}
|
|
|
|
// --- sphere vs player: knockdown ---
|
|
// Contract gives us player.pos; the knockdown itself is Lane D's (§5-D.3),
|
|
// so we just report the hit and let them run the state machine.
|
|
if (player && player.pos && !p.hitPlayer) {
|
|
const px = player.pos.x, pz = player.pos.z;
|
|
const py = player.pos.y + 0.9; // centre of mass, not feet
|
|
const dsq = (p.x - px) ** 2 + (p.y - py) ** 2 + (p.z - pz) ** 2;
|
|
const hit = p.r + 0.35;
|
|
if (dsq < hit * hit) {
|
|
const impact = Math.hypot(p.vx, p.vy, p.vz) * p.mass;
|
|
// a bin rolling gently past your ankles shouldn't floor you
|
|
if (impact > 25 && o.onHitPlayer) {
|
|
p.hitPlayer = true; // one knockdown per piece
|
|
o.onHitPlayer(p, impact);
|
|
p.vx *= 0.4; p.vz *= 0.4;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- sphere vs sail nodes: impulse ---
|
|
// Duck-typed: lights up the moment Lane B exposes nodes, silent until
|
|
// then. See THREADS — B owns sail.js, so this is the seam we agreed on.
|
|
if (sail && sail.nodes) applyToSail(p, sail);
|
|
|
|
if (p.y < groundAt(p.x, p.z) - 5 || Math.abs(p.x) > bounds.x || Math.abs(p.z) > bounds.z) {
|
|
despawn(p);
|
|
pieces.splice(i, 1);
|
|
}
|
|
}
|
|
},
|
|
|
|
/** Drop everything (phase change, restart). */
|
|
clear() {
|
|
for (const p of pieces) despawn(p);
|
|
pieces.length = 0;
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Shove any cloth node the piece is intersecting, and lose some of the piece's
|
|
* own momentum doing it. Expects sail.nodes: [{x,y,z,px,py,pz}] (verlet, so we
|
|
* move position and let the integrator turn it into velocity).
|
|
*/
|
|
function applyToSail(p, sail) {
|
|
const nodes = sail.nodes;
|
|
const reach = p.r + 0.15;
|
|
const reachSq = reach * reach;
|
|
let hits = 0;
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const n = nodes[i];
|
|
const dx = n.x - p.x, dy = n.y - p.y, dz = n.z - p.z;
|
|
const dsq = dx * dx + dy * dy + dz * dz;
|
|
if (dsq > reachSq || dsq < 1e-9) continue;
|
|
const d = Math.sqrt(dsq);
|
|
// push the node out to the sphere surface along the contact normal
|
|
const push = (reach - d) / d;
|
|
n.x += dx * push; n.y += dy * push; n.z += dz * push;
|
|
hits++;
|
|
}
|
|
if (hits) {
|
|
const drag = Math.min(0.5, (hits * p.mass) / 400);
|
|
p.vx *= 1 - drag; p.vy *= 1 - drag; p.vz *= 1 - drag;
|
|
if (sail.onDebrisHit) sail.onDebrisHit(p, hits);
|
|
}
|
|
}
|
|
|
|
return debris;
|
|
}
|