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>
217 lines
9.7 KiB
JavaScript
217 lines
9.7 KiB
JavaScript
/**
|
|
* ladder.js — the ladder sub-system. (Lane D, SPRINT4 decision 12)
|
|
*
|
|
* Why it exists: the house fascia brackets sit at y=2.48 and a 1.72 m person's hands reach 2.20.
|
|
* Two hundred millimetres is the whole mechanic. Everything else in the yard you can rig from the
|
|
* ground — a sail post is tensioned from a cleat at its base, a tree anchor is a strop you throw —
|
|
* but a bracket bolted 2.5 m up a bare wall is not negotiable, and E's ladder tops out at exactly
|
|
* 2.9 m. The asset and the yard were built for each other; this file is the verb between them.
|
|
*
|
|
* The loop it creates is the "limited hands" rule from DESIGN.md doing real work:
|
|
* ladder and spare are BOTH carry items, and you can only hold one.
|
|
* So a fascia repair costs two trips — fetch the ladder, plant it, go back for the spare —
|
|
* while a post repair costs one. The house is the expensive anchor to depend on, which is
|
|
* exactly what E's ratingHint 0.35 / collateral "gutter" is already telling you in the data.
|
|
*
|
|
* Ownership: Lane D. Self-wires from createPlayer() in player.js, so main.js (Lane A's file) needs
|
|
* no change to get this — it already hands createPlayer the scene, world and interact.
|
|
*/
|
|
import * as THREE from '../vendor/three.module.js';
|
|
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
|
|
|
export const LADDER_URL = './models/ladder_01_v1.glb';
|
|
|
|
/**
|
|
* Which anchors you cannot rig from the ground.
|
|
*
|
|
* Deliberately keyed on the anchor TYPE, not on a height test. A pure "is it above reach?" rule
|
|
* would rope in the posts (3.95 m) and tree limbs (up to 5.05 m) and turn every single repair into
|
|
* a two-trip ladder job — which is both untrue to how sails are actually rigged and would have
|
|
* silently invalidated the recorded §7 run and Lane B's gate asserts. Decision 12 scopes this to
|
|
* the fascia; this is that scope, in one line, where it can be found and argued with.
|
|
*/
|
|
export const needsLadder = (anchor) => !!anchor && anchor.type === 'house';
|
|
|
|
/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */
|
|
const STAND_OFF = 0.9; // m clear of the wall face
|
|
const PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder
|
|
const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height
|
|
|
|
/**
|
|
* @param {THREE.Object3D} scene
|
|
* @param {object} world contracts World (must be dressed — anchors are final only after dress())
|
|
* @param {object} interact Lane D's Interact
|
|
* @param {object} player the PlayerSim
|
|
* @returns {object} the ladder system
|
|
*/
|
|
export function createLadder(scene, world, interact, player) {
|
|
const anchors = (world.anchors || []).filter(needsLadder);
|
|
if (!anchors.length) {
|
|
// no fascia in this yard (a bare harness, say) — the whole sub-system is moot, don't half-wire it
|
|
return { placedAt: null, carried: false, needsLadder, isWorking: () => false,
|
|
workY: () => 0, servedAnchor: () => null, update() {}, dispose() {} };
|
|
}
|
|
|
|
const state = {
|
|
carried: false, // in the player's hands
|
|
placedAt: null, // anchor id, or null while stowed/carried
|
|
base: new THREE.Vector3(),
|
|
topY: 2.9, // overwritten from the GLB's ladder_top node
|
|
view: null,
|
|
};
|
|
|
|
// Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
|
|
// so it follows the shed if Lane A moves it. The offset is deliberately ~3 m: at 1.4 m the two
|
|
// prompts overlapped and standing at the ladder offered you "take a spare", which is the kind of
|
|
// thing that reads as a broken game rather than a crowded shed.
|
|
const home = new THREE.Vector3(10.4, 0, 3.4);
|
|
if (world.shedTable && world.shedTable.pos) {
|
|
home.set(world.shedTable.pos.x + 1.4, 0, world.shedTable.pos.z - 2.6);
|
|
}
|
|
home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0;
|
|
state.base.copy(home);
|
|
|
|
// --- view -----------------------------------------------------------------
|
|
new GLTFLoader().load(LADDER_URL, (g) => {
|
|
const obj = g.scene;
|
|
const top = obj.getObjectByName('ladder_top');
|
|
if (top) state.topY = top.position.y;
|
|
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
|
|
state.view = obj;
|
|
scene.add(obj);
|
|
syncView();
|
|
}, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ });
|
|
|
|
const LEAN = 0.26; // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder
|
|
function syncView() {
|
|
if (!state.view) return;
|
|
state.view.visible = !state.carried;
|
|
state.view.position.copy(state.base);
|
|
const a = state.placedAt && world.anchors.find((x) => x.id === state.placedAt);
|
|
if (a) {
|
|
// planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
|
|
// top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
|
|
state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
|
|
} else {
|
|
state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed
|
|
}
|
|
}
|
|
|
|
/** The fascia anchor this ladder is currently serving, if any. */
|
|
const servedAnchor = () => (state.placedAt ? world.anchors.find((a) => a.id === state.placedAt) : null);
|
|
|
|
/** Standing height at the top of the ladder — feet a rung or two down from the very top. */
|
|
const workY = () => Math.max(0, state.topY - RUNG_CLEAR);
|
|
|
|
/**
|
|
* True if the player is up THIS ladder and can work the given anchor.
|
|
* Height only, deliberately — hold-E moves the player into `busy`, so testing for state 'atTop'
|
|
* here would make a fascia repair un-usable the moment it started and cancel its own hold.
|
|
*/
|
|
function isWorking(anchorId) {
|
|
return state.placedAt === anchorId && player.climbY > workY() - 0.15;
|
|
}
|
|
|
|
// --- interactions ---------------------------------------------------------
|
|
const wired = [];
|
|
|
|
// 1. pick the ladder up (from its home, or from wherever it's planted)
|
|
wired.push(interact.register({
|
|
id: 'ladder_take',
|
|
pos: () => (state.carried ? null : state.base),
|
|
radius: 1.6,
|
|
holdSecs: 0.8,
|
|
clip: 'PickUp',
|
|
label: (p) => (p.carrying ? 'hands full' : state.placedAt ? 'take the ladder back' : 'take the ladder'),
|
|
canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02,
|
|
onDone: (p, t) => {
|
|
state.carried = true;
|
|
state.placedAt = null;
|
|
p.pickUp('ladder', t);
|
|
syncView();
|
|
},
|
|
}));
|
|
|
|
// 2. plant it at a fascia anchor
|
|
for (const a of anchors) {
|
|
wired.push(interact.register({
|
|
id: `ladder_place_${a.id}`,
|
|
// stand off the wall, on the yard side — the ladder leans in toward the bracket
|
|
pos: () => ({ x: a.pos.x, y: 0, z: a.pos.z + STAND_OFF }),
|
|
radius: PLACE_RANGE,
|
|
holdSecs: 1.0,
|
|
clip: 'PickUp',
|
|
// Reads as an offer when you can, and as a reason when you can't — interact.visible() now
|
|
// shows unusable targets greyed, and a label written only as an offer explains nothing there.
|
|
// Standing under a blown fascia bracket holding a spare, "the fascia needs the ladder" is the
|
|
// single most useful sentence in the game.
|
|
label: (p) => (p.carrying === 'ladder'
|
|
? `set the ladder under ${a.id}`
|
|
: 'the fascia needs the ladder — it\'s by the shed'),
|
|
canUse: (p) => p.carrying === 'ladder',
|
|
onDone: (p, t) => {
|
|
state.carried = false;
|
|
state.placedAt = a.id;
|
|
state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
|
|
a.pos.z + STAND_OFF);
|
|
p.carrying = null;
|
|
p.events.push({ type: 'ladderPlaced', anchorId: a.id, t });
|
|
syncView();
|
|
},
|
|
}));
|
|
}
|
|
|
|
// 3. climb it — only when it's planted, and only from the ground
|
|
wired.push(interact.register({
|
|
id: 'ladder_climb',
|
|
pos: () => (state.placedAt && !state.carried ? state.base : null),
|
|
radius: 1.5,
|
|
holdSecs: 0.4,
|
|
clip: 'PickUp',
|
|
label: 'climb',
|
|
// NB: no test on p.state here. Starting a hold moves the player into `busy`, so a canUse that
|
|
// reads state goes false the instant the hold begins and cancels itself. climbY is the honest
|
|
// gate (are you on the ground?), and locked states can't start a hold anyway.
|
|
canUse: (p) => !!state.placedAt && !state.carried && p.climbY < 0.02,
|
|
onDone: (p, t) => {
|
|
p.pos.x = state.base.x; p.pos.z = state.base.z; // step onto the rungs
|
|
const a = servedAnchor();
|
|
if (a) p.facing = Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z);
|
|
p.climbTo(workY(), t);
|
|
},
|
|
}));
|
|
|
|
const api = {
|
|
get placedAt() { return state.placedAt; },
|
|
get carried() { return state.carried; },
|
|
get base() { return state.base; },
|
|
get topY() { return state.topY; },
|
|
workY,
|
|
isWorking,
|
|
servedAnchor,
|
|
needsLadder,
|
|
|
|
/**
|
|
* Drive descent from input. Held S climbs down; nothing else can strand you up there, and a
|
|
* knockdown already drops climbY to 0 on its own.
|
|
* player.js calls this each frame from the same input it reads for movement.
|
|
*/
|
|
update(dt, t, input) {
|
|
if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
|
|
syncView();
|
|
},
|
|
|
|
dispose() {
|
|
wired.forEach((un) => un());
|
|
if (interact.ladder === api) interact.ladder = null;
|
|
if (state.view) scene.remove(state.view);
|
|
},
|
|
};
|
|
|
|
// Publish onto the Interact instance so wireYardActions can find the reach gate without main.js
|
|
// (Lane A's file) having to learn about ladders and thread it through. Interact is Lane D's own
|
|
// class, so this stays inside the lane; an explicit `deps.ladder` still wins if anyone passes one.
|
|
interact.ladder = api;
|
|
return api;
|
|
}
|