HardYards/web/world/js/ladder.js
m3ultra d67126eacf Lane D: the broom, greyed prompts, and the label pass
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>
2026-07-17 03:03:02 +10:00

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