/** * 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'; export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder }; 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(); } /** @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} 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); 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 (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, keyboard, dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.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); } }