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.
376 lines
16 KiB
JavaScript
376 lines
16 KiB
JavaScript
/**
|
||
* 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.99–3.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);
|
||
}
|
||
}
|