Selftest on merged main: 121 pass / 0 skip / 0 fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
268 lines
11 KiB
JavaScript
268 lines
11 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 } from './player.sim.js';
|
|
|
|
export { PlayerSim, STATES, TUNE };
|
|
|
|
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);
|
|
|
|
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];
|
|
this.play(st.clip, st.loop !== false);
|
|
|
|
this.root.position.set(sim.pos.x, sim.pos.y, 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 p = await loadPlayer(scene, {
|
|
...opts,
|
|
groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined,
|
|
start: opts.start || { x: 0, y: 0, z: 6 },
|
|
});
|
|
const keyboard = new KeyboardInput();
|
|
const { sim, view } = p;
|
|
|
|
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 (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,
|
|
keyboard,
|
|
dispose() { keyboard.dispose(); view.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, run: k.has('ShiftLeft') || k.has('ShiftRight'), camYaw };
|
|
}
|
|
|
|
dispose() {
|
|
this._target.removeEventListener('keydown', this._down);
|
|
this._target.removeEventListener('keyup', this._up);
|
|
}
|
|
}
|