New ladder.js. The whole mechanic is 200 mm: the fascia bracket sits at 2.48 m, a 1.72 m person's hands reach 2.20, and E's ladder tops out at 2.90. The asset and the yard were already built for each other; this is the verb between them. Carry-ladder is a second carry type, so the ladder and the spare compete for the same pair of hands and a fascia repair costs two trips while a post repair costs one — DESIGN.md's "limited hands" rule doing real work, and the reason the house is the expensive anchor to depend on (which E's ratingHint 0.35 / collateral "gutter" was already saying in the data). Climb height is code-driven with ClimbLadder playing on top — the knockdown precedent, since _rotOnly strips the root and a clip can no more lift the body than Falling could lay it down. You can't brace up there (both hands on the rungs), the wind's bar drops to 0.6x, and being blown off is a fall that feeds straight into the existing get-up chain. needsLadder is scoped to the fascia on purpose: a height test would have roped in the 3.95 m posts and 5.05 m limbs, made every repair a two-trip job, and silently invalidated the recorded §7 run — and it isn't true to rigging either. Landed with no change to main.js: createLadder self-wires from createPlayer, which Lane A already hands the scene, world and interact. Two bugs found by building on my own API, both now asserted: a canUse that reads player.state cancels its own hold (starting a hold sets busy) — it ate the climb AND the reach gate before I keyed both on physical height instead; and onLadder had to become height-based for the same reason. Documented on register(). Selftest 194/0/0 (was 184). Full loop driven by hand in the real game. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
352 lines
15 KiB
JavaScript
352 lines
15 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';
|
||
|
||
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<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);
|
||
|
||
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);
|
||
}
|
||
}
|