New ladder.js. The whole mechanic is 200 mm: the fascia bracket sits at 2.48 m, a 1.72 m person's hands reach 2.20, and E's ladder tops out at 2.90. The asset and the yard were already built for each other; this is the verb between them. Carry-ladder is a second carry type, so the ladder and the spare compete for the same pair of hands and a fascia repair costs two trips while a post repair costs one — DESIGN.md's "limited hands" rule doing real work, and the reason the house is the expensive anchor to depend on (which E's ratingHint 0.35 / collateral "gutter" was already saying in the data). Climb height is code-driven with ClimbLadder playing on top — the knockdown precedent, since _rotOnly strips the root and a clip can no more lift the body than Falling could lay it down. You can't brace up there (both hands on the rungs), the wind's bar drops to 0.6x, and being blown off is a fall that feeds straight into the existing get-up chain. needsLadder is scoped to the fascia on purpose: a height test would have roped in the 3.95 m posts and 5.05 m limbs, made every repair a two-trip job, and silently invalidated the recorded §7 run — and it isn't true to rigging either. Landed with no change to main.js: createLadder self-wires from createPlayer, which Lane A already hands the scene, world and interact. Two bugs found by building on my own API, both now asserted: a canUse that reads player.state cancels its own hold (starting a hold sets busy) — it ate the climb AND the reach gate before I keyed both on physical height instead; and onLadder had to become height-based for the same reason. Documented on register(). Selftest 194/0/0 (was 184). Full loop driven by hand in the real game. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
211 lines
9.2 KiB
JavaScript
211 lines
9.2 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',
|
|
label: `set the ladder under ${a.id}`,
|
|
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;
|
|
}
|