Leaves: a pooled handful (cap 7) streaming with the wind from 8.3 m/s — the same number D keys the player's lean on, so the yard and the body start telling one story at one speed. Count rides a 2.5 s speed EMA so it doesn't strobe; own seeded rng (seed ^ 0x1eaf) so the pool never shifts the crate sequence; bit-identical under identical (dt, t) streams (asserted). debris.clear() rewinds the layer. dev_skyfx flies the debris layer now and warms it to the scrub point, so ?t=50 shows the leaves t=50 would have. D's aftermath sliver, exactly as diagnosed on lane/d: the hail InstancedMesh at count:0 with frustum culling and depthWrite off still draws instance 0's identity matrix — a 5 cm always-on-top speck at the origin. mesh.visible now gates on n > 0, sky.hail joins the public fx object, and a whole-storm assert demands hidden-at-zero / shown-when-falling in both directions (gated on floor(1300 x intensity) >= 1: a burst's first frames honestly draw nothing). THREADS: gate-2 wrap entry — D's judging unblocked, A's M key live. Selftest 340/0/0 (+2 tests). Mutation-checked: dropped stepLeaves, a 3 m/s threshold, and an always-visible hail pool all go red. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
325 lines
13 KiB
JavaScript
325 lines
13 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;
|
|
// Deceleration while resting on the ground, m/s². Drag in a 19 m/s wind gives a
|
|
// 9 kg crate ~7 m/s², so it still skitters downwind — which is the whole point.
|
|
const GROUND_FRICTION = 3.5;
|
|
|
|
// 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();
|
|
|
|
// ---------------------------------------------------------------- leaves
|
|
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3"
|
|
// — the event-driven crates are the drama, but nothing SELLS the gale
|
|
// between events. A handful of leaves streaming with the wind does, from
|
|
// 30 km/h up. Numbers that matter: LEAF_START is 8.3 m/s on purpose — the
|
|
// same threshold D keys the player's lean on (their table, lane/d), so the
|
|
// yard and the body start telling the same story at the same speed. Count
|
|
// stays single digits (cap 7); these are a tell, not a particle system.
|
|
//
|
|
// Deterministic: own seeded rng (so the leaf pool never shifts the piece
|
|
// rng sequence), distance accumulated over the fixed-dt stream, flutter
|
|
// pure in t. Same (dt, t) stream in, same leaves out.
|
|
const LEAF_START = 8.3; // m/s = 30 km/h; matches D's lean threshold
|
|
const LEAF_MAX = 7; // "a handful" — single digits, capped
|
|
const LEAF_SPAN = 26; // m of downwind run before a leaf recycles
|
|
const lrand = rng(((wind && wind.seed) || 1) ^ 0x1eaf);
|
|
const leafGeo = new THREE.PlaneGeometry(0.16, 0.09);
|
|
const leafMat = new THREE.MeshLambertMaterial({ color: 0x8a7a3f, side: THREE.DoubleSide });
|
|
const leafMeshes = [];
|
|
const leafSeed = [];
|
|
for (let i = 0; i < LEAF_MAX; i++) {
|
|
const m = new THREE.Mesh(leafGeo, leafMat);
|
|
m.visible = false;
|
|
if (scene) scene.add(m);
|
|
leafMeshes.push(m);
|
|
leafSeed.push({
|
|
lat: lrand() * 16 - 8, // lane across the wind, m
|
|
base: 0.35 + lrand() * 1.5, // ride height, m
|
|
off: lrand() * LEAF_SPAN, // where on the loop it starts
|
|
fl: 1.6 + lrand() * 1.6, // flutter frequency
|
|
ph: lrand() * 6.283,
|
|
});
|
|
}
|
|
let leafDist = 0; // m travelled downwind, accumulated over the dt stream
|
|
let leafEma = 0; // ~2.5 s smoothed speed, so the count doesn't strobe
|
|
|
|
function stepLeaves(dt, t) {
|
|
probe.set(0, 1.6, 0);
|
|
wind.sample(probe, t, w);
|
|
const sp = Math.hypot(w.x, w.z);
|
|
leafEma += (sp - leafEma) * Math.min(1, dt / 2.5);
|
|
const n = leafEma < LEAF_START ? 0
|
|
: Math.min(LEAF_MAX, 1 + Math.floor((leafEma - LEAF_START) / 1.5));
|
|
leafDist += sp * 0.85 * dt; // leaves ride a little under the wind
|
|
const inv = sp > 1e-4 ? 1 / sp : 0;
|
|
const dx = w.x * inv, dz = w.z * inv;
|
|
for (let i = 0; i < LEAF_MAX; i++) {
|
|
const m = leafMeshes[i], s = leafSeed[i];
|
|
const vis = i < n && inv > 0;
|
|
m.visible = vis;
|
|
if (!vis) continue;
|
|
const along = ((leafDist + s.off + i * (LEAF_SPAN / LEAF_MAX)) % LEAF_SPAN) - LEAF_SPAN / 2;
|
|
const lat = s.lat + Math.sin(t * 0.9 + s.ph) * 1.1;
|
|
const x = dx * along - dz * lat;
|
|
const z = dz * along + dx * lat;
|
|
m.position.set(x, groundAt(x, z) + s.base + Math.sin(t * s.fl + s.ph) * 0.3, z);
|
|
m.rotation.set(t * s.fl, s.ph + t * 2.1, t * 1.3 + s.ph);
|
|
}
|
|
}
|
|
|
|
/** 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,
|
|
phase: rand() * 6.283, // so two crates don't hop in lockstep
|
|
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; },
|
|
|
|
/** How many ambient leaves are flying right now (gate 2.3). 0 in a calm. */
|
|
get leafCount() { let n = 0; for (const m of leafMeshes) if (m.visible) n++; return n; },
|
|
/** Positions of the flying leaves — for asserts and D's judging. */
|
|
get leaves() { return leafMeshes.filter((m) => m.visible).map((m) => m.position); },
|
|
|
|
/** 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);
|
|
}
|
|
stepLeaves(dt, t); // gate 2.3 — the ambient tell
|
|
}
|
|
|
|
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;
|
|
|
|
// A tumbling bluff body doesn't just get shoved, it gets picked up: lift
|
|
// flips sign as it rolls, which is why a bin HOPS across a yard instead
|
|
// of sliding. Wind is horizontal (weather.js keeps y=0), so without this
|
|
// there is no vertical force at all once it's down and it just skates.
|
|
p.vy += (0.5 * rel * rel * Math.sin(p.spin * 1.7 + p.phase) / p.mass) * dt;
|
|
|
|
p.x += p.vx * dt;
|
|
p.y += p.vy * dt;
|
|
p.z += p.vz * dt;
|
|
|
|
// ground
|
|
const floor = groundAt(p.x, p.z) + p.r;
|
|
if (p.y <= floor) {
|
|
p.y = floor;
|
|
if (p.vy < -0.5) {
|
|
// a real impact: bounce, and lose some tangential speed to the hit
|
|
p.vy = -p.vy * 0.32; // dead-ish, it's a plastic tub
|
|
p.vx *= 0.72; p.vz *= 0.72;
|
|
} else {
|
|
if (p.vy < 0) p.vy = 0;
|
|
// Resting: rolling friction as a dt-scaled DECELERATION, not a
|
|
// per-frame multiplier. `v *= 0.86` every frame is 0.86^60 per
|
|
// second — that isn't scrape, it's glue, and it pinned a 9 kg crate
|
|
// at 0.7 m/s in a 19 m/s wind.
|
|
const sp = Math.hypot(p.vx, p.vz);
|
|
if (sp > 1e-4) {
|
|
const drop = Math.min(sp, GROUND_FRICTION * dt);
|
|
p.vx -= (p.vx / sp) * drop;
|
|
p.vz -= (p.vz / sp) * drop;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
// leaves are a pooled ambient layer, not spawned pieces: hide and rewind
|
|
// so the next night's stream starts from the same state every time
|
|
for (const m of leafMeshes) m.visible = false;
|
|
leafDist = 0; leafEma = 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;
|
|
}
|