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