/** * 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 {function} [spec.onHold] (player, dt, t, progress) -> void, every frame the hold runs. * For actions that do WORK over the hold rather than at the end of it — the broom's poke drains * Lane B's pond per-frame (`drainPondAt(node, dt)` returns the kg shed that call), so the total * is only knowable by accumulating it. onDone still fires at the end for the payoff. * @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, onHold: 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. This is what a hold-E acts on. */ 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; } /** * What the HUD should SHOW, which is not the same question as what E acts on. * * A usable action always wins. But when nothing is usable, this returns the nearest action that * is merely unavailable, so the prompt can say WHY instead of vanishing. That distinction came * out of playing it: walking to the shed table with the ladder in your hands made the prompt * disappear, which reads as a broken game rather than a full pair of hands — and every target * already had a perfectly good sentence sitting in its `label`, unreachable, because canUse had * filtered it out before the label was ever asked. * * @returns {{target, usable, label}|null} */ visible(player) { const usable = this.nearest(player); if (usable) return { target: usable, usable: true, label: this.labelOf(usable, 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) { best = target; bestD = d; } } return best ? { target: best, usable: false, label: this.labelOf(best, player) } : null; } 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, usable}} for hud.js to draw the prompt + radial. * `usable:false` means the prompt is a REASON, not an offer — grey it out and don't show a * radial. Lane A: this is the greyed-prompt surface I offered; `label` is already the sentence * ("hands full", "out of reach — needs the ladder", "you need the broom"). */ 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); // work done DURING the hold, before the completion check — so the last frame of a poke still // drains, rather than the action finishing a frame's worth of water short if (this.active.onHold) this.active.onHold(player, dt, t, Math.min(1, this.progress)); 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 }); } } if (this.active) { return { target: this.active, progress: this.progress, label: this.labelOf(this.active, player), holding: true, usable: true, }; } const shown = this.visible(player); return { target: shown ? shown.target : null, progress: 0, label: shown ? shown.label : '', holding: false, usable: !!(shown && shown.usable), }; } } /** * 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; // Publish the CURRENT rig for lane-D systems built before it exists (the broom needs ponds + // drainPondAt). main.js re-calls wireYardActions through rigSail() every time attach() replaces // the rig object, so reading `interact.sailRig` is always the live one — which is the same reason // the corner closures below read by index rather than capturing. interact.sailRig = sailRig || 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()); }