HardYards/web/world/js/player.js
m3ultra d67126eacf Lane D: the broom, greyed prompts, and the label pass
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>
2026-07-17 03:03:02 +10:00

364 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* player.js — the small person: rig, clips, camera-relative control. (Lane D)
*
* The deterministic half lives in player.sim.js; this file is the view. It follows the 90sDJsim
* DEVMANUAL "Rigged animated characters" rules, which are law here:
* · SkeletonUtils.clone() for instances — a plain .clone() breaks skinned meshes
* · height-normalise off the HEAD BONE and plant the feet — Mixamo scale is unreliable, so the
* scale factor is always MEASURED, never a blind setScalar of a guessed constant
* · canonicalise the bone namespace so any clip binds to any character
*
* Assets (see models/MODELS.md): player_01.glb is an untouched ped (metre-scale, head bone 1.75 m);
* player_anims.glb is an anim-only carrier built by tools/character/build_player_anims.py. The clips
* are retargeted onto the ped at load — see _rotOnly for why that is safe at any carrier scale.
*/
import * as THREE from '../vendor/three.module.js';
import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
import { createLadder } from './ladder.js';
import { createBroom } from './broom.js';
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom };
export const CHAR_URL = './models/player_01.glb';
export const ANIM_URL = './models/player_anims.glb';
/**
* Canonicalise the Mixamo skeleton namespace (mixamorig4: vs mixamorig12:) so any clip binds to any
* character. Every Mixamo auto-rig upload gets its own numbered namespace and three.js binds tracks
* BY NODE NAME — a mismatched clip binds to nothing and plays a silent T-pose, with no error.
* (Straight from 90sDJsim index.html. The \d+ is deliberate: a bare "mixamorig:" is already canonical.)
*/
const _canon = (s) => s.replace(/mixamorig\d+/g, 'mixamorig');
const canonRig = (r) => {
if (!r) return r;
if (r.scene) r.scene.traverse((o) => { o.name = _canon(o.name); });
if (r.anims) r.anims.forEach((a) => a.tracks.forEach((t) => { t.name = _canon(t.name); }));
return r;
};
/**
* Shared-clip filter: keep limb/spine rotations only. Drop ALL position tracks (a different-scale
* source inflates or crumples the target) AND Hips.quaternion (a different-orientation source lays
* the target flat). Quaternions are scale-invariant, which is the whole reason a clip carrier at any
* scale retargets cleanly onto the metre-scale ped.
*
* The cost is that a clip can no longer lie the body down — so the knockdown does NOT come from the
* Falling clip's root. player.sim.js pitches the root itself, which is also how the fall gets to go
* DOWNWIND of the gust that caused it. The clip only supplies the flail.
*/
const _rotOnly = (c) => new THREE.AnimationClip(c.name, c.duration,
c.tracks.filter((t) => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
const _loadGLTF = (loader, url) => new Promise((res, rej) =>
loader.load(url, (g) => res({ scene: g.scene, anims: g.animations }), undefined,
() => rej(new Error(`player: failed to load ${url}`))));
const UP = new THREE.Vector3(0, 1, 0);
const _clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Build the player's collision test out of `world.solids` (contracts World).
*
* Shape of the problem, measured in the real yard rather than assumed:
* · `fence` is a GROUP of 37 child meshes whose combined box is the whole 30×20 m yard — so one
* box per entry in solids is useless. We flatten to leaf meshes and box each one.
* · the house ROOF is a solid spanning y 2.993.21, i.e. entirely above a 1.72 m head. A flat
* footprint test would wall off the eaves, so every box is filtered by vertical overlap with
* the body and the roof simply drops out.
* Solids are static, so the boxes are computed once. ~44 leaves, distance-pruned — no raycast per
* frame. (Lane A's note: the ground is deliberately NOT in solids; heightAt covers it.)
*
* @param {object} world contracts World
* @param {object} [opts] {radius} metres, the player's shoulder radius
* @returns {(x:number,z:number,feetY:number,headY:number)=>{x:number,z:number}}
*/
export function makeSolidCollider(world, opts = {}) {
const radius = opts.radius ?? 0.3;
const boxes = [];
const b = new THREE.Box3();
for (const root of (world && world.solids) || []) {
root.updateWorldMatrix(true, true);
root.traverse((o) => {
if (!o.isMesh) return;
b.setFromObject(o);
if (!isFinite(b.min.x)) return;
boxes.push({ x0: b.min.x, x1: b.max.x, z0: b.min.z, z1: b.max.z, y0: b.min.y, y1: b.max.y });
});
}
const out = { x: 0, z: 0 }; // scratch — copied by the caller immediately, never retained
const r2 = radius * radius;
return function collide(x, z, feetY, headY) {
out.x = x; out.z = z;
for (let i = 0; i < boxes.length; i++) {
const bx = boxes[i];
if (bx.y1 <= feetY + 0.05 || bx.y0 >= headY) continue; // under the eaves / over a low wall
// closest point on the box to the body centre, in XZ
const cx = _clamp(out.x, bx.x0, bx.x1), cz = _clamp(out.z, bx.z0, bx.z1);
const dx = out.x - cx, dz = out.z - cz;
const d2 = dx * dx + dz * dz;
if (d2 >= r2) continue; // clear
if (d2 > 1e-10) { // outside: push along the normal
const d = Math.sqrt(d2);
out.x = cx + (dx / d) * radius;
out.z = cz + (dz / d) * radius;
} else {
// centre is inside the box (spawned in a wall, or shoved through): eject through the nearest
// face rather than picking an arbitrary axis, so you pop out the side you came in.
const l = out.x - bx.x0, rr = bx.x1 - out.x, u = out.z - bx.z0, dn = bx.z1 - out.z;
const m = Math.min(l, rr, u, dn);
if (m === l) out.x = bx.x0 - radius;
else if (m === rr) out.x = bx.x1 + radius;
else if (m === u) out.z = bx.z0 - radius;
else out.z = bx.z1 + radius;
}
}
return out;
};
}
export class PlayerView {
/**
* @param {object} rig {scene, anims} — the character
* @param {Array} clips retargeted AnimationClips
* @param {number} height metres to the top of the head
*/
constructor(rig, clips, height = 1.72) {
// root: world position + facing + knockdown pitch. fig: the rig, lifted so its feet sit at y=0.
this.root = new THREE.Group();
this.root.name = 'player';
const fig = skeletonClone(rig.scene);
this.fig = fig;
fig.traverse((o) => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = true; } });
this.root.add(fig);
this.root.updateWorldMatrix(true, true);
// measure, then scale — never a blind setScalar (DEVMANUAL). /head/i matches both Head and
// HeadTop_End; max() takes the crown, which is what "height" means.
const wp = new THREE.Vector3();
let headY = 0;
fig.traverse((o) => {
if (!o.isBone) return;
o.getWorldPosition(wp);
if (/head/i.test(o.name)) headY = Math.max(headY, wp.y);
});
if (headY > 1e-4) { fig.scale.setScalar(height / headY); fig.updateWorldMatrix(true, true); }
this.height = height;
// plant the feet: lift the rig so its lowest bone sits on the root's origin
let minY = Infinity;
fig.traverse((o) => { if (o.isBone) { o.getWorldPosition(wp); minY = Math.min(minY, wp.y); } });
if (minY < Infinity) fig.position.y = -minY;
this.mixer = new THREE.AnimationMixer(fig);
this.actions = {};
// some characters lack bones a shared clip animates (thumb joints, say) — bind only what exists,
// else three.js spams "No target node found" for every missing bone
const nodes = new Set();
fig.traverse((o) => { if (o.name) nodes.add(o.name); });
for (const clip of clips) {
const bindable = clip.tracks.filter((t) => nodes.has(t.name.split('.')[0]));
if (!bindable.length) continue;
const use = bindable.length === clip.tracks.length
? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
this.actions[clip.name] = this.mixer.clipAction(use);
}
this.current = null;
this._axis = new THREE.Vector3();
this._qYaw = new THREE.Quaternion();
this._qPitch = new THREE.Quaternion();
}
/** @returns {string[]} clip names that bound to at least one bone */
get clipNames() { return Object.keys(this.actions); }
play(name, loop = true, fade = 0.18) {
const next = this.actions[name];
if (!next || next === this.current) return;
next.reset();
next.setEffectiveWeight(1);
next.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
next.clampWhenFinished = !loop;
if (this.current) next.crossFadeFrom(this.current, fade, false);
next.play();
this.current = next;
}
/** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */
sync(sim, dt) {
const st = STATES[sim.state];
// clipFor, not st.clip: carrying swaps in Carry/CarryIdle, and an interaction names its own verb
this.play(clipFor(sim), st.loop !== false);
// climbY lifts the whole rig up the rungs. Same trick as the knockdown pitch: _rotOnly strips
// the root from every clip, so ClimbLadder can't raise the body — the sim does, and the clip
// just supplies the arms and legs.
this.root.position.set(sim.pos.x, sim.pos.y + (sim.climbY || 0), sim.pos.z);
// yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the
// feet. At pitch 0 this is exactly the yaw, so upright play is untouched.
this._qYaw.setFromAxisAngle(UP, sim.facing);
if (sim.pitch > 1e-4) {
this._axis.set(sim.knockDir.z, 0, -sim.knockDir.x);
if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0);
this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5);
this.root.quaternion.copy(this._qPitch).multiply(this._qYaw);
} else {
this.root.quaternion.copy(this._qYaw);
}
this.mixer.update(dt);
}
dispose() {
this.mixer.stopAllAction();
this.root.removeFromParent();
}
}
/**
* Load the player and attach it to a scene.
* @param {THREE.Scene|THREE.Object3D} scene
* @param {object} [opts] {start, facing, height, groundAt, tune, charUrl, animUrl}
* @returns {Promise<{sim: PlayerSim, view: PlayerView, step: function}>}
*/
export async function loadPlayer(scene, opts = {}) {
const loader = new GLTFLoader();
const [rig, animPack] = await Promise.all([
_loadGLTF(loader, opts.charUrl || CHAR_URL),
_loadGLTF(loader, opts.animUrl || ANIM_URL),
]);
canonRig(rig);
canonRig(animPack);
const clips = animPack.anims.map(_rotOnly).filter((c) => c.tracks.length);
const view = new PlayerView(rig, clips, opts.height || 1.72);
scene.add(view.root);
const sim = new PlayerSim(opts);
const missing = Object.values(STATES).map((s) => s.clip).filter((c, i, a) =>
a.indexOf(c) === i && !view.actions[c]);
if (missing.length) console.warn('player: state machine wants clips that did not bind:', missing);
return {
sim,
view,
/** Convenience: step the sim then push it to the rig. */
step(dt, t, input, wind) {
sim.step(dt, t, input, wind);
view.sync(sim, dt);
return sim.state;
},
};
}
/**
* The `Player` contract factory (contracts.js) — this is the one main.js calls.
* Drop-in for createPlaceholderPlayer(scene, world, cameraRig): same first three args, so boot()
* only changes which function it calls. Everything the player needs per frame it pulls itself, so
* `update(dt, t)` matches the contract's signature exactly.
*
* @param {THREE.Object3D} scene
* @param {object} world contracts World — `heightAt(x,z)` clamps the player to the ground
* @param {object} cameraRig contracts Camera — `yaw` is what WASD is relative to
* @param {object} [opts] {wind, interact, start, facing, height, tune, charUrl, animUrl}
* @returns {Promise<object>} satisfies checkContract('player', …)
*/
export async function createPlayer(scene, world, cameraRig, opts = {}) {
const height = opts.height || 1.72;
const p = await loadPlayer(scene, {
...opts,
height,
groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined,
// built AFTER the world exists so the boxes capture E's real GLBs, not the graybox
collide: opts.collide !== undefined ? opts.collide : makeSolidCollider(world, opts),
start: opts.start || { x: 0, y: 0, z: 6 },
});
const keyboard = new KeyboardInput();
const { sim, view } = p;
// The ladder self-wires from here rather than from main.js: createPlayer is already handed the
// scene, the world and interact, which is everything it needs — so Lane A's file doesn't have to
// change to get a whole sub-system. Opt out with {ladder: false} if a harness doesn't want it.
const ladder = (opts.ladder === false || !opts.interact)
? null : createLadder(scene, world, opts.interact, sim);
// The broom needs the sail rig, which createPlayer isn't handed — but wireYardActions is, and
// main.js re-calls it through rigSail() whenever attach() swaps the rig. So read it live off
// interact rather than capturing a rig that's about to be replaced.
const broom = (opts.broom === false || !opts.interact)
? null : createBroom(scene, world, opts.interact, sim, () => opts.interact.sailRig);
return {
get pos() { return sim.pos; },
get carrying() { return sim.carrying; },
set carrying(v) { sim.carrying = v; },
get busy() { return sim.busy; },
/** @param {number} dt @param {number} t — main.js's fixed-dt loop drives this */
update(dt, t) {
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
sim.step(dt, t, input, opts.wind);
if (ladder) ladder.update(dt, t, input);
if (broom) broom.update(dt, t, input);
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
view.sync(sim, dt);
},
/** The Object3D to follow/frame. Lane A's camera wants this, not the raw rig. */
get object() { return view.root; },
sim,
view,
ladder,
broom,
keyboard,
dispose() {
keyboard.dispose(); view.dispose();
if (ladder) ladder.dispose();
if (broom) broom.dispose();
},
};
}
/**
* Keyboard → the sim's input shape. Camera yaw comes from Lane A's camera each frame.
* Kept out of PlayerSim so the sim stays headless.
*/
export class KeyboardInput {
constructor(target = window) {
this.keys = new Set();
this._down = (e) => {
this.keys.add(e.code);
if (/^(Arrow|Space)/.test(e.code)) e.preventDefault();
};
this._up = (e) => this.keys.delete(e.code);
target.addEventListener('keydown', this._down);
target.addEventListener('keyup', this._up);
this._target = target;
}
get holding() { return this.keys.has('KeyE'); }
/** @param {number} camYaw radians */
read(camYaw = 0) {
const k = this.keys;
const x = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0);
const z = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0);
return {
x, z, camYaw,
run: k.has('ShiftLeft') || k.has('ShiftRight'),
shelter: k.has('KeyC'), // hold to brace — see STATES.shelter
};
}
dispose() {
this._target.removeEventListener('keydown', this._down);
this._target.removeEventListener('keyup', this._up);
}
}