HardYards/web/world/js/debris.js
m3ultra 383471d0f5 Add debris, skyfx and the Lane C bench; align to landed contracts
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>
2026-07-16 21:38:21 +10:00

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