/** * 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 * @param {function} [spec.onDone] (player, t) -> void * @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, ...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); 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.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 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. * * @param {Interact} interact * @param {object} deps {sailRig, world, spares} * sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B) * sailRig.repair(i) -> void [PROPOSED — see THREADS.md] * sailRig.trim(i,d) -> void [PROPOSED — per-corner turnbuckle, see THREADS.md] * world.shedTable -> {pos} (Lane A/E) */ export function wireYardActions(interact, deps = {}) { const { sailRig, world } = deps; const wired = []; 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: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)), radius: 1.8, holdSecs: 2.5, label: 're-rig corner', canUse: (p) => corner.broken && p.carrying === 'spare', onDone: (p) => { p.carrying = null; if (sailRig.repair) sailRig.repair(i); }, })); // per-corner turnbuckle trim — new vs the prototype; makes corners individual wired.push(interact.register({ id: `trim_${i}`, pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)), radius: 1.8, holdSecs: 1.2, label: 'tighten turnbuckle', canUse: () => !corner.broken && !!sailRig.trim, onDone: () => sailRig.trim && 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'), canUse: (p) => !p.carrying, // hands-full rule onDone: (p, t) => p.pickUp('spare', t), })); } return () => wired.forEach((un) => un()); }