At gate 1 the ped walked straight through the house. Collision now stops it 0.30 m off the wall face (expected -9.70, measured -9.70), off trunks, posts and the fence, and slides along a wall hit at an angle. Injected as opts.collide the way groundAt already was, so player.sim.js stays zero-import and node-runnable. Two things the real yard taught, neither guessable from the plan: - `fence` is a GROUP of 37 child meshes whose combined box is the entire 30x20 m yard, so one box per solids entry is useless. Flattened to 43 leaf boxes. - the house ROOF spans y 2.99-3.21 and reaches 0.4 m FURTHER into the yard than the wall under it (eaves overhang). A flat footprint test would stop a 1.7 m person dead at an invisible eave, so every box is filtered by vertical overlap with the body and the roof drops out on its own. Boxes are built once (solids are static) and distance-pruned — no per-frame raycast, which is the thing Lane A measured as catastrophic on the terrain. The M3 pack is wired: carrying swaps locomotion to Carry/CarryIdle; an interaction names its own verb through a new `clip` field on the interact spec (Crank at a turnbuckle, PickUp at the shed table); StumbleBack fires on a gust that breaks your stride but can't floor you — below knockWind on purpose, so a storm reads as shoved → stumbling → floored rather than fine-fine-fine-flat. TakeCover (hold C) became a real mechanic rather than a pose: brace and knockWind x2.0, shove x0.25. A 38 m/s gale floors you standing and doesn't while braced; let go in the same gale and you're down in half a second. It raises the bar, it does not remove it — a big enough gust still wins, braced or not. So the storm's answer to "the gusts are too strong to cross the yard" is now wait one out and move in the lull, which is the repair-window language DESIGN.md already uses. wireYardActions now reads sailRig.corners[i] live by index instead of capturing the corner object — per Lane A's warning that attach() replaces the array, a captured corner is one the sim no longer steps and would gate forever on a `broken` flag that can never change again. 35 Lane D asserts, 0 fail (was 20). Carry/shelter/knockdown verified against the real 17-clip pack in the assembled game, not only in the harness. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
7.9 KiB
JavaScript
204 lines
7.9 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
|
|
* @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. */
|
|
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);
|
|
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}} 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.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 });
|
|
}
|
|
}
|
|
|
|
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.
|
|
*
|
|
* 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;
|
|
const wired = [];
|
|
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || 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;
|
|
return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
|
|
};
|
|
|
|
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: 're-rig corner',
|
|
clip: 'Crank',
|
|
canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
|
|
&& p.carrying === 'spare' && !!sailRig.repair,
|
|
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: 'tighten turnbuckle',
|
|
clip: 'Crank',
|
|
canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim,
|
|
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());
|
|
}
|