/** * 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} 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); } }