HardYards/web/world/js/player.sim.js
m3ultra 6382e604b8 Lane D: the small person — player, interactions, selftests
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>
2026-07-16 21:55:16 +10:00

247 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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