The broom (SPRINT5 §Lane D-1, DESIGN.md's funniest correct mechanic): a third
carry type that queues behind the same hands, walk under the sail belly, hold-E
poke (Crank per E's anim_hint) → B's drainPondAt() → the water lands ON YOU,
sized to the joke: splash under 15 kg, stagger over 60, flat on your back over
120. Everything defers to E's baked metadata (carry_type, poke_tip.use on the
bristle end, anim_hint) — read, not invented. Duck-typed against the ponds[] /
drainPondAt seam I posted to B early, so it carries/walks/refuses-thin-air today
and lights up fully the moment B lands ponding.
Self-wires from createPlayer like the ladder; needs the live sail rig, which it
reads off interact.sailRig (published by wireYardActions, which main.js re-calls
through rigSail whenever attach() swaps the rig).
Greyed prompts (§Lane D-2, my offer, A's HUD hook): interact.visible() shows the
nearest UNAVAILABLE action with its reason when nothing's usable, instead of the
prompt vanishing — the confusion I logged last sprint. nearest() (what hold-E
acts on) is unchanged, so display and action stay separate. step() now reports
`usable` so the HUD can grey the radial. Fixed the ladder-place label to read as
a reason too ("the fascia needs the ladder — it's by the shed").
broom.js keeps no top-level THREE import (the vendored addons need index.html's
importmap) so d.test.js stays headless; the view loads via dynamic import that
only fires in a browser. 217/0/0 (was 207), 13 new asserts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
7.9 KiB
JavaScript
189 lines
7.9 KiB
JavaScript
/**
|
|
* 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); },
|
|
};
|
|
}
|