HardYards/web/world/js/interact.js
m3ultra 6382e604b8 Lane D: the small person — player, interactions, selftests
player.sim.js  Deterministic core, zero imports: camera-relative movement, the
               state-machine table, wind slow/gust-shove/knockdown. step(dt,t)
               is the whole clock — no Date.now, no Math.random, no rAF — so a
               storm fast-forwards identically in selftest and in the game.
player.js      The view: rig load per the 90sDJsim DEVMANUAL rig rules
               (SkeletonUtils.clone, height-normalise off the MEASURED head
               bone, canonicalised bone namespace), clip retarget, and
               createPlayer() satisfying the Player contract.
interact.js    Hold-E with radial progress + wireYardActions (re-rig 2.5 s,
               turnbuckle trim 1.2 s, carry-one-item), duck-typed so it no-ops
               cleanly until Lane B lands sailRig.repair/trim.
d.test.js      20 asserts in Lane A's harness: 38 pass / 3 skip overall.

Ported from the 2D prototype's shape (game.js:250-252), retuned to m/s: the
slow curve, and shove gated to gusts only and scaling with speed² so gusts have
teeth. Knockdown needs 0.5 s of SUSTAINED overload — deliberately the same rule
as a sail corner letting go, so cloth and people speak one language.

Two decisions worth the review:
- The knockdown pitches the root in code rather than playing the Falling clip's
  root. Shared clips must drop Hips.quaternion (a different-orientation source
  lays the target flat), so a clip physically cannot lie the body down. Doing it
  in code also lets the fall go DOWNWIND of the gust that caused it, which a
  canned clip could never do, and keeps it deterministic.
- Gust magnitude is recovered from a slow EMA of local wind rather than widening
  Lane C's contract: wind.sample() gives the total and gustTelegraph() only fires
  BEFORE a gust, so nothing reports gust size during the hold. The EMA
  self-calibrates to whatever storm JSON Lane C authors.

Verified in a real scene, not only in asserts: head bone 1.715 m at fig scale
0.983, all six clips bound, walk/run at the tuned speeds, the body lies down and
gets back up, hold-E fires exactly once per press.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:16 +10:00

182 lines
6.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
* @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());
}