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>
182 lines
6.7 KiB
JavaScript
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());
|
|
}
|