HardYards/web/world/js/interact.js
m3ultra 0ceff91d5a Lane D: the ladder sub-system (decision 12)
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>
2026-07-17 02:34:49 +10:00

231 lines
9.7 KiB
JavaScript

/**
* interact.js — hold-E actions with radial progress. (Lane D)
*
* contracts.js: interact.register({id, pos, radius, holdSecs, label, canUse()->bool, onDone()})
*
* Zero imports (same reason as player.sim.js): the whole thing is fixed-dt and headless-testable.
* `pos` is duck-typed {x,y,z}, so a THREE.Vector3 or a plain object both work.
*
* The busy handshake: this module both ENTERS and LEAVES the player's `busy` state. Nothing else
* writes it, so a dropped release cannot strand the player — and if the world takes the player away
* mid-hold (a gust puts them down), we notice `player.state` is no longer 'busy' and abort without
* stomping on the state the world just set.
*/
export class Interact {
constructor() {
this.targets = new Map();
this.active = null; // the target currently being held
this.progress = 0; // 0..1 — the radial
this.latched = false; // a completed action re-arms only after E is released (see step)
this.events = []; // {type:'done'|'cancel', id, t} — drained by hud.js
}
/**
* @param {object} spec
* @param {string} spec.id
* @param {object} spec.pos {x,y,z} — read live each step, so it may move (a sail corner does)
* @param {number} [spec.radius] metres
* @param {number} [spec.holdSecs]
* @param {string|function} [spec.label] string, or (player)->string for live text
* @param {function} [spec.canUse] (player) -> bool.
* **Do not test `player.state` in here.** canUse is re-checked every frame to KEEP a hold alive,
* and starting a hold moves the player into `busy` — so `canUse: p => p.state === 'idle'` goes
* false on frame one and the action silently cancels its own hold. It looks exactly like a dead
* prompt. Gate on physical facts instead (carrying, position, height); locked states already
* can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's
* climb and the fascia reach gate.
* @param {function} [spec.onDone] (player, t) -> void
* @param {string} [spec.clip] verb played for the length of the hold ('Crank', 'PickUp', …).
* Must name a clip in player_anims.glb; omitted means the busy state's default Idle.
* @returns {function} unregister
*/
register(spec) {
if (!spec || !spec.id) throw new Error('interact.register: id required');
const target = {
radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, clip: null, ...spec,
};
this.targets.set(target.id, target);
return () => this.unregister(target.id);
}
unregister(id) {
if (this.active && this.active.id === id) this.active = null, this.progress = 0;
return this.targets.delete(id);
}
labelOf(target, player) {
return typeof target.label === 'function' ? target.label(player) : target.label;
}
_usable(target, player) {
return !target.canUse || !!target.canUse(player);
}
/** Nearest registered target in range whose canUse() passes. */
nearest(player) {
let best = null, bestD = Infinity;
for (const target of this.targets.values()) {
const p = typeof target.pos === 'function' ? target.pos() : target.pos;
if (!p) continue;
const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z);
if (d <= target.radius && d < bestD && this._usable(target, player)) { best = target; bestD = d; }
}
return best;
}
cancel(t, player) {
if (!this.active) return;
// only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
if (player.state === 'busy') player.setState('idle', t);
player.busyClip = null;
this.events.push({ type: 'cancel', id: this.active.id, t });
this.active = null;
this.progress = 0;
}
/**
* @param {number} dt @param {number} t
* @param {PlayerSim} player
* @param {boolean} holding is E held this frame
* @returns {{target, progress, label, holding}} for hud.js to draw the prompt + radial
*/
step(dt, t, player, holding) {
// One press, one action: a completed hold latches until E is released. Without this, a held key
// re-arms the instant the action finishes and the same action fires every holdSecs forever
// (leaning on the shed table would deal you a spare a second, indefinitely).
if (!holding) this.latched = false;
const near = this.nearest(player);
if (this.active) {
const stolen = player.state !== 'busy'; // something else claimed the player
if (!holding || near !== this.active || !this._usable(this.active, player) || stolen) {
this.cancel(t, player);
}
}
if (!this.active && holding && !this.latched && near && !player.busy) {
this.active = near;
this.progress = 0;
player.busyClip = near.clip || null; // the verb: Crank at a turnbuckle, PickUp at the table
player.setState('busy', t);
}
if (this.active) {
this.progress += dt / Math.max(1e-6, this.active.holdSecs);
if (this.progress >= 1) {
const done = this.active;
this.active = null;
this.progress = 0;
this.latched = true;
player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy
player.busyClip = null; // ...and after it, so the carry clips win on the next frame
if (done.onDone) done.onDone(player, t);
this.events.push({ type: 'done', id: done.id, t });
}
}
const shown = this.active || near;
return {
target: shown,
progress: this.progress,
label: shown ? this.labelOf(shown, player) : '',
holding: !!this.active,
};
}
}
/**
* Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D
* never edits Lane B's or Lane A's files — anything not yet landed is simply skipped.
*
* Every closure reads `sailRig.corners[i]` LIVE rather than capturing the corner object. Lane A's
* THREADS note: `attach()` REPLACES the corners array, so a captured corner is a stale object the
* sim no longer steps — the prompt would gate forever on a `broken` flag that can never change
* again. Reading by index means a re-rig can't strand these targets whether or not we get re-wired.
*
* @param {Interact} interact
* @param {object} deps {sailRig, world}
* sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
* sailRig.repair(i) -> void (decision 4)
* sailRig.trim(i,d) -> void (decision 4)
* sailRig.cornerPos(i) -> Vector3 (decision 4 — live world position; a flogging corner moves)
* world.shedTable -> {pos} (Lane A — until it lands, the pickup self-skips)
*/
export function wireYardActions(interact, deps = {}) {
const { sailRig, world } = deps;
// createLadder publishes itself onto the Interact instance, so main.js doesn't have to thread a
// ladder through to get the fascia reach gate. An explicit dep still wins (tests pass one).
const ladder = deps.ladder || interact.ladder || null;
const wired = [];
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
const anchorOf = (i) => {
const c = cornerAt(i);
if (!c) return null;
return c.anchor || (world && world.anchors && world.anchors.find((a) => a.id === c.anchorId)) || null;
};
// a flogging corner is MOVING — resolve position every frame, never once at wire time
const posAt = (i) => () => {
const c = cornerAt(i);
if (!c) return null;
// A fascia corner is worked AT THE BRACKET, not at the cloth: the corner has detached and the
// sail is hanging down somewhere, but re-attaching it means getting a shackle onto a fitting
// 2.48 m up a wall. So the prompt lives at the anchor and you need the ladder to hold it.
if (ladder && ladder.needsLadder(anchorOf(i))) {
const a = anchorOf(i);
return { x: a.pos.x, y: a.pos.y, z: a.pos.z + 0.9 };
}
return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
};
/** Fascia work needs you up the ladder that's planted under THAT bracket. */
const canReach = (i, p) => {
const a = anchorOf(i);
if (!ladder || !ladder.needsLadder(a)) return true; // everything else is ground work
return ladder.isWorking(a.id) && p.reachY >= a.pos.y;
};
if (sailRig && Array.isArray(sailRig.corners)) {
sailRig.corners.forEach((_corner, i) => {
// re-rig a broken corner — costs the spare you're carrying
wired.push(interact.register({
id: `rerig_${i}`,
pos: posAt(i),
radius: 1.8,
holdSecs: 2.5,
label: (p) => (canReach(i, p) ? 're-rig corner' : 'out of reach — needs the ladder'),
clip: 'Crank',
canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
&& p.carrying === 'spare' && !!sailRig.repair && canReach(i, p),
onDone: (p) => { p.carrying = null; sailRig.repair(i); },
}));
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
wired.push(interact.register({
id: `trim_${i}`,
pos: posAt(i),
radius: 1.8,
holdSecs: 1.2,
label: (p) => (canReach(i, p) ? 'tighten turnbuckle' : 'out of reach — needs the ladder'),
clip: 'Crank',
canUse: (p) => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim && canReach(i, p),
onDone: () => sailRig.trim(i, +0.1),
}));
});
}
if (world && world.shedTable) {
wired.push(interact.register({
id: 'spare_table',
pos: world.shedTable.pos,
radius: 1.5,
holdSecs: 0.6,
label: (p) => (p.carrying ? 'hands full' : 'take a spare'),
clip: 'PickUp',
canUse: (p) => !p.carrying, // hands-full rule
onDone: (p, t) => p.pickUp('spare', t),
}));
}
return () => wired.forEach((un) => un());
}