player.sim.js Deterministic core, zero imports: camera-relative movement, the
state-machine table, wind slow/gust-shove/knockdown. step(dt,t)
is the whole clock — no Date.now, no Math.random, no rAF — so a
storm fast-forwards identically in selftest and in the game.
player.js The view: rig load per the 90sDJsim DEVMANUAL rig rules
(SkeletonUtils.clone, height-normalise off the MEASURED head
bone, canonicalised bone namespace), clip retarget, and
createPlayer() satisfying the Player contract.
interact.js Hold-E with radial progress + wireYardActions (re-rig 2.5 s,
turnbuckle trim 1.2 s, carry-one-item), duck-typed so it no-ops
cleanly until Lane B lands sailRig.repair/trim.
d.test.js 20 asserts in Lane A's harness: 38 pass / 3 skip overall.
Ported from the 2D prototype's shape (game.js:250-252), retuned to m/s: the
slow curve, and shove gated to gusts only and scaling with speed² so gusts have
teeth. Knockdown needs 0.5 s of SUSTAINED overload — deliberately the same rule
as a sail corner letting go, so cloth and people speak one language.
Two decisions worth the review:
- The knockdown pitches the root in code rather than playing the Falling clip's
root. Shared clips must drop Hips.quaternion (a different-orientation source
lays the target flat), so a clip physically cannot lie the body down. Doing it
in code also lets the fall go DOWNWIND of the gust that caused it, which a
canned clip could never do, and keeps it deterministic.
- Gust magnitude is recovered from a slow EMA of local wind rather than widening
Lane C's contract: wind.sample() gives the total and gustTelegraph() only fires
BEFORE a gust, so nothing reports gust size during the hold. The EMA
self-calibrates to whatever storm JSON Lane C authors.
Verified in a real scene, not only in asserts: head bone 1.715 m at fig scale
0.983, all six clips bound, walk/run at the tuned speeds, the body lies down and
gets back up, hold-E fires exactly once per press.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
247 lines
11 KiB
JavaScript
247 lines
11 KiB
JavaScript
/**
|
||
* player.sim.js — the small person's deterministic core. (Lane D)
|
||
*
|
||
* Zero imports, on purpose. PLAN3D §0 requires the sim to be fast-forwardable in selftest with a
|
||
* fixed dt and no renderer, so nothing in here touches THREE, the DOM, rAF, Date.now() or
|
||
* Math.random(). `step(dt, t, …)` is the entire clock: same inputs → same trace, every run.
|
||
*
|
||
* player.js owns the view (rig, clips, camera). This file owns the truth: where the person is,
|
||
* what state they're in, and what the wind is doing to them.
|
||
*/
|
||
|
||
/**
|
||
* The state machine, as a table (PLAN3D §5-D.5 asks for a table test).
|
||
* clip — clip name in player_anims.glb
|
||
* locked — movement input is ignored, and `player.busy` is true
|
||
* secs — timed states auto-advance to `next` after this long
|
||
* Invariant the selftest enforces: every locked state either has a `next` (so it drains on its own)
|
||
* or is released by an external actor. `busy` is the only externally-released state — interact.js
|
||
* both enters and leaves it, so a dropped release can't strand the player.
|
||
*/
|
||
export const STATES = {
|
||
idle: { clip: 'Idle', locked: false, loop: true },
|
||
walk: { clip: 'Walk', locked: false, loop: true },
|
||
run: { clip: 'Run', locked: false, loop: true },
|
||
busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' },
|
||
stagger: { clip: 'Reaction', locked: true, loop: false, secs: 0.9, next: 'idle' },
|
||
knocked: { clip: 'Falling', locked: true, loop: false, secs: 1.4, next: 'getup' },
|
||
getup: { clip: 'CrouchToStand', locked: true, loop: false, secs: 1.3, next: 'idle' },
|
||
};
|
||
|
||
/**
|
||
* Tuning. Ported from the 2D prototype's shape (prototype/game.js:250-252), retuned to metres and
|
||
* m/s per PLAN3D §1 ("port the behaviour, retune the constants").
|
||
*/
|
||
export const TUNE = {
|
||
walkSpeed: 1.5, // m/s — a person crossing a 30 m yard, unhurried
|
||
runSpeed: 4.4, // m/s — shift
|
||
accel: 16, // m/s² toward the wanted velocity
|
||
turnRate: 11, // rad/s — facing chases the movement direction
|
||
|
||
// prototype: slow = 1 - min(0.35, wind.speed / 160). Its wind ran ~4 (calm) to ~38 (gust peak),
|
||
// which is already m/s-shaped, so the curve ports across directly.
|
||
slowMax: 0.35,
|
||
slowRef: 160,
|
||
|
||
// prototype: push = ws*ws*0.55 — "wind pressure goes with speed², gusts have teeth"; and shove
|
||
// only applied while wind.gust > 8, never from the base wind.
|
||
shoveK: 0.0035, // shove accel (m/s²) = shoveK · ws² → ~3.2 m/s² in a 30 m/s gust
|
||
shoveGustMin: 8, // m/s of gust (over baseline) before the wind can push you at all
|
||
shoveDamp: 2.5, // 1/s foot-friction bleed → terminal drift ≈ shoveK·ws²/shoveDamp
|
||
|
||
// Baseline tracker: contracts.js exposes wind.sample() (total) and wind.gustTelegraph() (before
|
||
// the gust), but nothing reports gust magnitude DURING the hold. Rather than widen Lane C's
|
||
// contract, we recover it: a slow EMA of local wind speed is the base curve, and everything above
|
||
// it is gust. Self-calibrating to whatever storm JSON Lane C authors.
|
||
baseTrack: 0.25, // 1/s — ~4 s memory; gust holds are ~1.7 s, so they read as gust, not base
|
||
|
||
// Knockdown mirrors the sail's failure rule (PLAN3D §1: break after 0.4 s SUSTAINED overload) —
|
||
// same verb for cloth and for people, so the player reads one language.
|
||
knockWind: 30, // m/s sustained local wind → you go down
|
||
knockSustain: 0.5, // s above knockWind before it happens
|
||
knockBleed: 2, // exposure drains this many × faster than it fills
|
||
pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch)
|
||
};
|
||
|
||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||
|
||
/** Shortest-arc angle step from a toward b, at most `maxStep` radians. */
|
||
function turnToward(a, b, maxStep) {
|
||
let d = (b - a) % (Math.PI * 2);
|
||
if (d > Math.PI) d -= Math.PI * 2;
|
||
if (d < -Math.PI) d += Math.PI * 2;
|
||
return a + clamp(d, -maxStep, maxStep);
|
||
}
|
||
|
||
export class PlayerSim {
|
||
/**
|
||
* @param {object} [opts]
|
||
* @param {object} [opts.start] {x,y,z} spawn, metres
|
||
* @param {function} [opts.groundAt] (x,z) -> y. Lane A's world.js provides the real one.
|
||
* @param {object} [opts.tune] overrides for TUNE
|
||
*/
|
||
constructor(opts = {}) {
|
||
const s = opts.start || { x: 0, y: 0, z: 0 };
|
||
this.pos = { x: s.x, y: s.y || 0, z: s.z };
|
||
this.vel = { x: 0, z: 0 }; // intended (input-driven) velocity
|
||
this.shove = { x: 0, z: 0 }; // wind-driven velocity, decays through foot friction
|
||
this.facing = opts.facing || 0; // yaw; models face +Z at 0 (90sDJsim convention)
|
||
|
||
this.state = 'idle';
|
||
this.stateT = 0;
|
||
this.carrying = null; // contract: player.carrying — one item, hands-full rule
|
||
this.events = []; // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD
|
||
|
||
this.exposure = 0; // s spent above knockWind
|
||
this.windBase = 0; // EMA baseline (see TUNE.baseTrack)
|
||
this.windSpeed = 0; // last sampled local speed, m/s — HUD/audio read this
|
||
this.gust = 0; // windSpeed - windBase, clamped ≥0
|
||
this.pitch = 0; // 0 upright … 1 flat on the ground
|
||
this.knockDir = { x: 0, z: 1 }; // which way the body went down
|
||
|
||
this.groundAt = opts.groundAt || (() => 0);
|
||
this.tune = { ...TUNE, ...(opts.tune || {}) };
|
||
}
|
||
|
||
/** contract: player.busy */
|
||
get busy() { return !!STATES[this.state].locked; }
|
||
get clip() { return STATES[this.state].clip; }
|
||
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
|
||
|
||
setState(s, t = 0) {
|
||
if (this.state === s) return false;
|
||
if (!STATES[s]) throw new Error(`player: unknown state ${s}`);
|
||
this.state = s;
|
||
this.stateT = 0;
|
||
this.events.push({ type: 'state', state: s, t });
|
||
return true;
|
||
}
|
||
|
||
/** Drop whatever's carried (knockdown does this per PLAN3D §5-D.3). */
|
||
drop(t = 0) {
|
||
if (!this.carrying) return null;
|
||
const item = this.carrying;
|
||
this.carrying = null;
|
||
this.events.push({ type: 'drop', item, t });
|
||
return item;
|
||
}
|
||
|
||
/** @returns {boolean} true if the pickup was accepted (hands-full rule). */
|
||
pickUp(item, t = 0) {
|
||
if (this.carrying || this.busy) return false;
|
||
this.carrying = item;
|
||
this.events.push({ type: 'pickup', item, t });
|
||
return true;
|
||
}
|
||
|
||
/** Light hit — a glancing debris clip. Ignored if already down. */
|
||
staggerHit(t = 0) {
|
||
if (this.state === 'knocked' || this.state === 'getup') return false;
|
||
return this.setState('stagger', t);
|
||
}
|
||
|
||
/**
|
||
* Put the player on the ground. debris.js (Lane C) calls this on a solid hit; the sim calls it
|
||
* itself on sustained extreme wind.
|
||
* @param {number} [dirX] @param {number} [dirZ] which way to fall — defaults to downwind/facing.
|
||
*/
|
||
knockdown(t = 0, dirX, dirZ) {
|
||
if (this.state === 'knocked' || this.state === 'getup') return false;
|
||
let x = dirX, z = dirZ;
|
||
if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); }
|
||
const m = Math.hypot(x, z) || 1;
|
||
this.knockDir = { x: x / m, z: z / m };
|
||
this.setState('knocked', t);
|
||
this.exposure = 0;
|
||
this.vel.x = this.vel.z = 0;
|
||
this.drop(t);
|
||
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir } });
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* @param {number} dt fixed step, seconds
|
||
* @param {number} t storm time, seconds
|
||
* @param {object} input {x,z} camera-relative axes in -1..1, {run}, {camYaw} radians
|
||
* @param {object} wind contracts wind ({sample(pos,t)->Vector3}) or a plain {x,z} vector
|
||
*/
|
||
step(dt, t, input = {}, wind = null) {
|
||
const T = this.tune;
|
||
this.stateT += dt;
|
||
|
||
// --- local wind, and how much of it is gust ---
|
||
let wx = 0, wz = 0;
|
||
if (wind) {
|
||
const v = typeof wind.sample === 'function' ? wind.sample(this.pos, t) : wind;
|
||
if (v) { wx = v.x || 0; wz = v.z || 0; }
|
||
}
|
||
const ws = Math.hypot(wx, wz);
|
||
this.windSpeed = ws;
|
||
this.windBase += (ws - this.windBase) * clamp(dt * T.baseTrack, 0, 1);
|
||
this.gust = Math.max(0, ws - this.windBase);
|
||
|
||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go) ---
|
||
if (ws > T.knockWind) this.exposure += dt;
|
||
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
|
||
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
|
||
|
||
const st = STATES[this.state];
|
||
|
||
// --- movement ---
|
||
const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you
|
||
let wantX = 0, wantZ = 0;
|
||
if (!st.locked) {
|
||
const ix = input.x || 0, iz = input.z || 0;
|
||
const mag = Math.hypot(ix, iz);
|
||
if (mag > 1e-3) {
|
||
// camera-relative: at camYaw 0 three.js looks down -Z, so forward = (-sin, -cos).
|
||
const cy = input.camYaw || 0;
|
||
const sin = Math.sin(cy), cos = Math.cos(cy);
|
||
const nx = ix / mag, nz = iz / mag;
|
||
const dx = nx * cos - nz * sin;
|
||
const dz = -nx * sin - nz * cos;
|
||
const target = (input.run ? T.runSpeed : T.walkSpeed) * Math.min(1, mag) * slow;
|
||
wantX = dx * target; wantZ = dz * target;
|
||
this.facing = turnToward(this.facing, Math.atan2(dx, dz), T.turnRate * dt);
|
||
}
|
||
}
|
||
// approach the wanted velocity at a fixed accel (both directions — stopping is the same law)
|
||
const dvx = wantX - this.vel.x, dvz = wantZ - this.vel.z;
|
||
const dvm = Math.hypot(dvx, dvz);
|
||
const step = T.accel * dt;
|
||
if (dvm <= step || dvm < 1e-6) { this.vel.x = wantX; this.vel.z = wantZ; }
|
||
else { this.vel.x += dvx / dvm * step; this.vel.z += dvz / dvm * step; }
|
||
|
||
// --- gust shove: pressure ∝ speed², gust only, never while you're already on the ground ---
|
||
const grounded = this.state === 'knocked' || this.state === 'getup';
|
||
if (!grounded && this.gust > T.shoveGustMin && ws > 1e-3) {
|
||
const a = T.shoveK * ws * ws;
|
||
this.shove.x += (wx / ws) * a * dt;
|
||
this.shove.z += (wz / ws) * a * dt;
|
||
}
|
||
const bleed = Math.exp(-T.shoveDamp * dt);
|
||
this.shove.x *= bleed; this.shove.z *= bleed;
|
||
|
||
this.pos.x += (this.vel.x + this.shove.x) * dt;
|
||
this.pos.z += (this.vel.z + this.shove.z) * dt;
|
||
this.pos.y = this.groundAt(this.pos.x, this.pos.z);
|
||
|
||
// --- body pitch: the sim owns it so a knockdown is deterministic and falls DOWNWIND,
|
||
// which a canned clip can't do. player.js just reads sim.pitch + sim.knockDir. ---
|
||
const wantPitch = this.state === 'knocked' ? 1
|
||
: this.state === 'getup' ? Math.max(0, 1 - this.stateT / (st.secs || 1))
|
||
: 0;
|
||
const pstep = dt / T.pitchSecs;
|
||
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
|
||
|
||
// --- locomotion state from actual speed (so shove/slow can't desync the feet) ---
|
||
if (!st.locked) {
|
||
const sp = this.speed;
|
||
this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t);
|
||
} else if (st.secs && this.stateT >= st.secs && st.next) {
|
||
this.setState(st.next, t);
|
||
}
|
||
return this.state;
|
||
}
|
||
}
|