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