The broom (SPRINT5 §Lane D-1, DESIGN.md's funniest correct mechanic): a third
carry type that queues behind the same hands, walk under the sail belly, hold-E
poke (Crank per E's anim_hint) → B's drainPondAt() → the water lands ON YOU,
sized to the joke: splash under 15 kg, stagger over 60, flat on your back over
120. Everything defers to E's baked metadata (carry_type, poke_tip.use on the
bristle end, anim_hint) — read, not invented. Duck-typed against the ponds[] /
drainPondAt seam I posted to B early, so it carries/walks/refuses-thin-air today
and lights up fully the moment B lands ponding.
Self-wires from createPlayer like the ladder; needs the live sail rig, which it
reads off interact.sailRig (published by wireYardActions, which main.js re-calls
through rigSail whenever attach() swaps the rig).
Greyed prompts (§Lane D-2, my offer, A's HUD hook): interact.visible() shows the
nearest UNAVAILABLE action with its reason when nothing's usable, instead of the
prompt vanishing — the confusion I logged last sprint. nearest() (what hold-E
acts on) is unchanged, so display and action stay separate. step() now reports
`usable` so the HUD can grey the radial. Fixed the ladder-place label to read as
a reason too ("the fascia needs the ladder — it's by the shed").
broom.js keeps no top-level THREE import (the vendored addons need index.html's
importmap) so d.test.js stays headless; the view loads via dynamic import that
only fires in a browser. 217/0/0 (was 207), 13 new asserts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
274 lines
12 KiB
JavaScript
274 lines
12 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. 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);
|
|
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());
|
|
}
|