/** * broom.js — DESIGN.md's funniest correct mechanic. (Lane D, SPRINT5 §Lane D-1) * * A flat sail pools water. The water is heavier than anything else in the game and it will pull the * rig down. The fix is a bloke with a broom walking under the belly and poking it upward — at which * point forty kilos of cold water arrives on his head. That is both the correct engineering answer * and the joke, and they are the same thing, which is the best kind of mechanic. * * Everything here defers to the asset. E baked the intent into broom_01_v1.glb: * grip_anchor extras.carry_type = "broom" * poke_tip extras.use = "push the pond up from under the sail; soft end, won't hole the cloth" * — on the BRISTLE end, deliberately: a broomstick jabbed at a loaded sail holes it. * root extras.anim_hint = "reuse Crank/Dig for the poke — no new Mixamo needed" * so the carry type, the working end and the animation are all read, not invented. * * Seam with Lane B (posted in THREADS before either of us built): sailRig.ponds -> [{node,mass,pos}] * and drainPondAt(node) -> kg dumped. Duck-typed: with no ponding landed the broom still carries, * walks and refuses to poke thin air, and the moment B lands it the whole thing lights up. */ // No top-level THREE/GLTFLoader import, deliberately: the vendored addons import the bare specifier // 'three', which only resolves under index.html's importmap — so importing them here would drag the // whole GL chain into d.test.js and cost this lane its headless suite (node resolves relative paths // only). The pond logic below is the part worth asserting and it is pure; the view is loaded // dynamically, which never happens outside a browser. export const BROOM_URL = './models/broom_01_v1.glb'; export const BROOM_TUNE = { pokeSecs: 1.5, // matches B's "drain over ~1.5 s" reachUp: 3.2, // m — how high overhead a pond can be and still be pokeable from the grass standRadius: 2.0, // m — how near the pond's ground shadow you must be // What lands on you. Calibrate once B's masses are real — these are physical guesses, not measured: // a full bucket is ~10 kg, so a splash is nothing, half a bathtub staggers you, and a bathtub // puts you down. Flagged in THREADS for the tuning pass. splashKg: 15, // below this it's just cold and funny staggerKg: 60, // above this you lose your footing // above staggerKg*2 → flat on your back }; /** * @param {THREE.Object3D} scene * @param {object} world contracts World (dressed) * @param {object} interact Lane D's Interact * @param {object} player PlayerSim * @param {object} getRig () => sailRig — a getter, because rigSail() REPLACES the rig object */ export function createBroom(scene, world, interact, player, getRig) { const state = { carried: false, view: null, tune: { ...BROOM_TUNE } }; // Home: against the shed wall. E ships it standing on its head, which is how it lives there. const home = { x: 8.2, y: 0, z: 7.0 }; if (world.shedTable && world.shedTable.pos) { home.x = world.shedTable.pos.x - 0.8; home.z = world.shedTable.pos.z + 1.0; } home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0; if (scene) { import('../vendor/addons/loaders/GLTFLoader.js').then(({ GLTFLoader }) => { new GLTFLoader().load(BROOM_URL, (g) => { g.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } }); state.view = g.scene; scene.add(g.scene); sync(); }, undefined, () => { /* no asset: the mechanic still runs, you just can't see the broom */ }); }).catch(() => { /* headless (selftest/node): logic only, no view */ }); } function sync() { if (!state.view) return; state.view.visible = !state.carried; state.view.position.set(home.x, home.y, home.z); state.view.rotation.set(0, 0.9, 0.16); // slouched against the shed wall } /** Every pond Lane B is reporting, or [] until they land it. */ const ponds = () => { const rig = getRig && getRig(); return (rig && Array.isArray(rig.ponds)) ? rig.ponds : []; }; /** * The pond this player could actually poke: near enough in plan, low enough overhead. * Picks the HEAVIEST reachable one rather than the nearest — if you're standing under two, the * one about to break the rig is the one you meant. */ function targetPond() { let best = null; for (const p of ponds()) { if (!p || !p.pos || !(p.mass > 0)) continue; const d = Math.hypot(p.pos.x - player.pos.x, p.pos.z - player.pos.z); const up = p.pos.y - (player.pos.y + player.climbY); if (d > state.tune.standRadius || up > state.tune.reachUp || up < 0) continue; if (!best || p.mass > best.mass) best = p; } return best; } /** Where to stand: the pond's shadow on the grass. */ const pokeSpot = () => { const p = ponds().reduce((a, b) => (!a || (b && b.mass > a.mass) ? b : a), null); if (!p || !p.pos || !(p.mass > 0)) return null; return { x: p.pos.x, y: 0, z: p.pos.z }; }; const wired = []; // 1. take the broom off the shed wall — a third carry type, so it queues behind the same hands wired.push(interact.register({ id: 'broom_take', pos: () => (state.carried ? null : home), radius: 1.5, holdSecs: 0.7, clip: 'PickUp', label: (p) => (p.carrying ? 'hands full' : 'take the broom'), canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02, onDone: (p, t) => { state.carried = true; p.pickUp('broom', t); sync(); }, })); // 2. put it back wired.push(interact.register({ id: 'broom_drop', pos: () => (state.carried ? { x: home.x, y: home.y, z: home.z } : null), radius: 1.5, holdSecs: 0.4, clip: 'PickUp', label: 'put the broom back', canUse: (p) => state.carried && p.carrying === 'broom', onDone: (p, t) => { state.carried = false; p.drop(t); sync(); }, })); // 3. THE POKE. Stand under the belly, push up, wear it. wired.push(interact.register({ id: 'broom_poke', pos: pokeSpot, radius: state.tune.standRadius, holdSecs: state.tune.pokeSecs, clip: 'Crank', // E's anim_hint — no new Mixamo needed label: (p) => { if (p.carrying !== 'broom') return 'you need the broom'; const pond = targetPond(); if (!pond) return 'nothing pooling here'; return `push the water off (${Math.round(pond.mass)} kg)`; }, // physical gates only — never player.state (see interact.register's note; it cancels its own hold) canUse: (p) => { const rig = getRig && getRig(); return p.carrying === 'broom' && !!(rig && rig.drainPondAt) && !!targetPond(); }, onDone: (p, t) => { const rig = getRig && getRig(); const pond = targetPond(); if (!rig || !pond) return; // B returns the kg actually dumped; fall back to diffing pondMass() if they'd rather not let kg = rig.drainPondAt(pond.node); if (typeof kg !== 'number') kg = pond.mass; onWater(p, kg, pond, t); }, })); /** * The payoff. All of it lands on the player, because they are standing directly underneath it — * that is not a bug in the plan, it IS the plan. */ function onWater(p, kg, pond, t) { p.events.push({ type: 'doused', kg, t }); const T = state.tune; if (kg >= T.staggerKg * 2) { // downward and behind: a bathtub arriving on your head does not blow you downwind p.knockdown(t, -Math.sin(p.facing), -Math.cos(p.facing)); } else if (kg >= T.staggerKg) { p.staggerHit(t); } // below splashKg: you just get wet, which is its own reward } return { get carried() { return state.carried; }, get home() { return home; }, tune: state.tune, ponds, targetPond, pokeSpot, onWater, update() { sync(); }, dispose() { wired.forEach((un) => un()); if (state.view) scene.remove(state.view); }, }; }