HardYards/web/world/js/player.js
type-two 7758b65331 Lane D: the player leans into the wind (gate 2.4 — as a root pose, not a clip)
The QA pass's biggest player finding: 'stands bolt upright and unbothered'
at 65 km/h. E rendered the two Mixamo lean candidates and rejected both on
screen, and proved the deeper reason a clip can't do this: _rotOnly drops
Hips.quaternion from every clip at load, so a baked lean is discarded, and
a clip's lean axis is fixed while the wind's bearing swings. So the lean is
the ROOT, the same machine as the knockdown pitch and climbY lift.

sim (player.sim.js): sim.lean 0..1 + sim.leanDir, eased over leanSecs.
Magnitude off windBase (the ~4s EMA), NOT raw windSpeed — measured: raw
strobes the posture 8-18x/min across the storms, windBase settles it to 0-2
changes per 90s. Sustained wind = posture; the gust is already the
stumble/knock event. Suppressed on your back (pitch owns the body), braced
(TakeCover is its own hunch), and up a ladder. Bands land on the storm
rating ladder: night 1 upright, 2-3 lean, 4-5 in the gale.

view (player.js): a foot-pivot tip toward leanDir, max leanMaxRad (~17deg,
a third of a knockdown's pi/2), composed under the yaw. It's the else branch
of the pitch tip by construction — the sim guarantees lean=0 whenever
pitch>0, so they never fight.

Three asserts: magnitude scales with sustained speed, a lone gust does NOT
snap you over (windBase inertia), and it's downwind + suppressed when
floored/braced/aloft. Mutation-checked: forcing lean to 0 reddens two.
Verified live at 71 km/h — leanDir dot wind = 1.0, the figure visibly
braces. Selftest 340/0/0. This is the root lean E asked me to wire first;
whether their WindPosture additive follows is a look-at-it-together call.
2026-07-18 01:33:47 +10:00

376 lines
16 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();
this._leanAxis = new THREE.Vector3();
this._qLean = 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 if (sim.lean > 1e-3) {
// Wind lean (SPRINT13): the same foot-pivot tip as the knockdown, but small and toward the
// wind's downwind bearing (leanDir) rather than the fall direction. The sim suppresses lean
// whenever pitch > 0, so these two never fight — this is the else branch by construction, not
// by luck. leanMaxRad is the whole tip; a knockdown's is π/2, so a full lean is ~a third of a
// fall. Yaw still applies underneath, so you lean while facing wherever you're walking.
this._leanAxis.set(sim.leanDir.z, 0, -sim.leanDir.x);
if (this._leanAxis.lengthSq() < 1e-8) this._leanAxis.set(1, 0, 0);
this._qLean.setFromAxisAngle(this._leanAxis.normalize(), sim.lean * sim.leanMaxRad);
this.root.quaternion.copy(this._qLean).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);
}
}