At gate 1 the ped walked straight through the house. Collision now stops it 0.30 m off the wall face (expected -9.70, measured -9.70), off trunks, posts and the fence, and slides along a wall hit at an angle. Injected as opts.collide the way groundAt already was, so player.sim.js stays zero-import and node-runnable. Two things the real yard taught, neither guessable from the plan: - `fence` is a GROUP of 37 child meshes whose combined box is the entire 30x20 m yard, so one box per solids entry is useless. Flattened to 43 leaf boxes. - the house ROOF spans y 2.99-3.21 and reaches 0.4 m FURTHER into the yard than the wall under it (eaves overhang). A flat footprint test would stop a 1.7 m person dead at an invisible eave, so every box is filtered by vertical overlap with the body and the roof drops out on its own. Boxes are built once (solids are static) and distance-pruned — no per-frame raycast, which is the thing Lane A measured as catastrophic on the terrain. The M3 pack is wired: carrying swaps locomotion to Carry/CarryIdle; an interaction names its own verb through a new `clip` field on the interact spec (Crank at a turnbuckle, PickUp at the shed table); StumbleBack fires on a gust that breaks your stride but can't floor you — below knockWind on purpose, so a storm reads as shoved → stumbling → floored rather than fine-fine-fine-flat. TakeCover (hold C) became a real mechanic rather than a pose: brace and knockWind x2.0, shove x0.25. A 38 m/s gale floors you standing and doesn't while braced; let go in the same gale and you're down in half a second. It raises the bar, it does not remove it — a big enough gust still wins, braced or not. So the storm's answer to "the gusts are too strong to cross the yard" is now wait one out and move in the lull, which is the repair-window language DESIGN.md already uses. wireYardActions now reads sailRig.corners[i] live by index instead of capturing the corner object — per Lane A's warning that attach() replaces the array, a captured corner is one the sim no longer steps and would gate forever on a `broken` flag that can never change again. 35 Lane D asserts, 0 fail (was 20). Carry/shelter/knockdown verified against the real 17-clip pack in the assembled game, not only in the harness. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
340 lines
14 KiB
JavaScript
340 lines
14 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 } from './player.sim.js';
|
||
|
||
export { PlayerSim, STATES, TUNE, clipFor };
|
||
|
||
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);
|
||
|
||
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 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;
|
||
|
||
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, 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);
|
||
}
|
||
}
|