The pre-merge thresholds were tuned against a mock that injected +18 gusts. In the actual storm_02, gusts-over-baseline peak at 12.1 m/s — so stumbleGust:17 was above every gust in the game and StumbleBack was unreachable dead code, and shoveGustMin:8 (a low bar in the prototype's 12-38 units) had become a high bar in ours and threw away most of the shove. Retuned against the real storm profile (12 gust events, peaks 12.1..6.1, 4 knockdowns, maxExposure 1.55): stumbleGust 17→9, shoveGustMin 8→4. A wild night now reads shoved (26s of 90) → stumbling (4) → floored (2); a calm day stays 0/0. d.test.js now loads the real storm JSON via weather.loadStorm and asserts every threshold is REACHABLE and correctly ordered — the guard the first tuning lacked. This matters because decision 8 (B+C) reweights the downdraft this sprint, which is exactly the kind of change that silently killed StumbleBack the first time. Also pinned the emergent 'unbrace into a gust → stumble' behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
318 lines
16 KiB
JavaScript
318 lines
16 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
|
||
* carryClip — clip to use instead when the player has something in their hands
|
||
* 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 names an external releaser. `busy` and `shelter` are the released ones — interact.js and the
|
||
* shelter key each both ENTER and LEAVE their own state, so a dropped release can't strand you.
|
||
*/
|
||
export const STATES = {
|
||
idle: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true },
|
||
walk: { clip: 'Walk', carryClip: 'Carry', locked: false, loop: true },
|
||
run: { clip: 'Run', carryClip: 'Carry', locked: false, loop: true },
|
||
busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' },
|
||
shelter: { clip: 'TakeCover', locked: true, loop: true, releasedBy: 'input' },
|
||
stumble: { clip: 'StumbleBack', locked: true, loop: false, secs: 0.8, next: 'idle' },
|
||
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' },
|
||
};
|
||
|
||
/**
|
||
* Which clip a state actually plays right now. Carrying swaps the locomotion set (Carry/CarryIdle),
|
||
* and an interaction can name its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) —
|
||
* interact.js writes that into `sim.busyClip`. Everything else is the table's `clip`.
|
||
* Kept here rather than in player.js so the selftest can assert it without a renderer.
|
||
*/
|
||
export function clipFor(sim) {
|
||
const st = STATES[sim.state];
|
||
if (sim.state === 'busy' && sim.busyClip) return sim.busyClip;
|
||
if (sim.carrying && st.carryClip) return st.carryClip;
|
||
return st.clip;
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
// Gate RETUNED against the real storm_02 (see the header note): the prototype's literal 8 was a
|
||
// low bar in ITS units (gusts ran 12–38, so 8 was "almost always"); ported straight across it
|
||
// became a high bar in ours (gusts peak at 12.1) and threw away most of the shove. 4 restores the
|
||
// prototype's intent — you're being pushed for ~26 s of a 90 s storm, and a calm day is still calm.
|
||
shoveGustMin: 4, // 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)
|
||
|
||
// A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a
|
||
// storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back.
|
||
// RETUNED against the real storm_02: 17 was set against a mock that injected +18 gusts, but the
|
||
// real storm's gusts-over-baseline peak at 12.1, so StumbleBack was unreachable — dead code in the
|
||
// shipped game. 9 catches the top third of the 12 gusts in a wild night: 4 stumbles to 2
|
||
// knockdowns, which is the intended rhythm (stumble is the beat, knockdown the punchline).
|
||
// d.test.js pins this to the actual storm JSON so a reweighted downdraft can't quietly kill it again.
|
||
stumbleGust: 9, // m/s over baseline → you lose your footing (but not your feet)
|
||
stumbleCooldown: 3, // s — punctuation, not a stutter: one gust hold must not stumble you twice
|
||
|
||
// Shelter (hold C): brace and the wind stops owning you. This is the storm's real answer to "the
|
||
// gusts are too strong to cross the yard" — wait one out, then move in the lull.
|
||
shelterKnockMult: 2.0, // knockWind × this while braced — a gust that floors you standing won't
|
||
shelterShoveMult: 0.25, // and it barely pushes you
|
||
};
|
||
|
||
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 {function} [opts.collide] (x,z,feetY,headY) -> {x,z} pushed clear of world.solids.
|
||
* Injected, not imported, for the same reason as groundAt: this file must stay renderer-free.
|
||
* player.js#makeSolidCollider builds the real one out of world.solids.
|
||
* @param {number} [opts.height] body height, metres — the collider's vertical span
|
||
* @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.busyClip = null; // interact.js names the verb for the current hold (Crank, PickUp…)
|
||
this.stumbleCool = 0; // s until a gust may stumble you again
|
||
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.collide = opts.collide || null;
|
||
this.bodyHeight = opts.height || 1.72;
|
||
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;
|
||
this.stumbleCool = Math.max(0, this.stumbleCool - 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);
|
||
|
||
// --- shelter: hold to brace. Enters and leaves itself, so releasing the key always frees you
|
||
// even mid-gust. Refused while you're down — you can't brace from your back. ---
|
||
const wantShelter = !!input.shelter;
|
||
const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
|
||
if (wantShelter && canShelter) this.setState('shelter', t);
|
||
else if (!wantShelter && this.state === 'shelter') this.setState('idle', t);
|
||
const braced = this.state === 'shelter';
|
||
|
||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go).
|
||
// Bracing raises the bar rather than removing it: a big enough gust still wins. ---
|
||
const knockAt = braced ? T.knockWind * T.shelterKnockMult : T.knockWind;
|
||
if (ws > knockAt) this.exposure += dt;
|
||
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
|
||
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
|
||
|
||
// --- a gust below the knockdown bar can still break your stride ---
|
||
if (!braced && this.gust > T.stumbleGust && this.stumbleCool <= 0
|
||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||
this.stumbleCool = T.stumbleCooldown;
|
||
this.setState('stumble', t);
|
||
this.vel.x = this.vel.z = 0;
|
||
}
|
||
|
||
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 * (braced ? T.shelterShoveMult : 1);
|
||
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);
|
||
|
||
// Solids: push back out of anything we ended up inside. Pushout is perpendicular to the surface,
|
||
// so walking into a wall at an angle keeps its tangential component and slides along it for free
|
||
// — no separate slide pass. Velocity is deliberately NOT zeroed: the wind should still be able to
|
||
// hold you against a fence, and the pushout wins over it every frame anyway.
|
||
if (this.collide) {
|
||
const r = this.collide(this.pos.x, this.pos.z, this.pos.y, this.pos.y + this.bodyHeight);
|
||
if (r) { this.pos.x = r.x; this.pos.z = r.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;
|
||
}
|
||
}
|