The QA pass's biggest player finding: 'stands bolt upright and unbothered' at 65 km/h. E rendered the two Mixamo lean candidates and rejected both on screen, and proved the deeper reason a clip can't do this: _rotOnly drops Hips.quaternion from every clip at load, so a baked lean is discarded, and a clip's lean axis is fixed while the wind's bearing swings. So the lean is the ROOT, the same machine as the knockdown pitch and climbY lift. sim (player.sim.js): sim.lean 0..1 + sim.leanDir, eased over leanSecs. Magnitude off windBase (the ~4s EMA), NOT raw windSpeed — measured: raw strobes the posture 8-18x/min across the storms, windBase settles it to 0-2 changes per 90s. Sustained wind = posture; the gust is already the stumble/knock event. Suppressed on your back (pitch owns the body), braced (TakeCover is its own hunch), and up a ladder. Bands land on the storm rating ladder: night 1 upright, 2-3 lean, 4-5 in the gale. view (player.js): a foot-pivot tip toward leanDir, max leanMaxRad (~17deg, a third of a knockdown's pi/2), composed under the yaw. It's the else branch of the pitch tip by construction — the sim guarantees lean=0 whenever pitch>0, so they never fight. Three asserts: magnitude scales with sustained speed, a lone gust does NOT snap you over (windBase inertia), and it's downwind + suppressed when floored/braced/aloft. Mutation-checked: forcing lean to 0 reddens two. Verified live at 71 km/h — leanDir dot wind = 1.0, the figure visibly braces. Selftest 340/0/0. This is the root lean E asked me to wire first; whether their WindPosture additive follows is a look-at-it-together call.
432 lines
23 KiB
JavaScript
432 lines
23 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' },
|
||
|
||
// --- the ladder (decision 12). climbY is driven in code and the clip plays on top, exactly the
|
||
// knockdown precedent: _rotOnly strips the root, so ClimbLadder can no more lift the body than
|
||
// Falling could lay it down. ladder.js calls climbTo(); the sim owns the height. ---
|
||
climb: { clip: 'ClimbLadder', locked: true, loop: true, releasedBy: 'ladder' },
|
||
// atTop is deliberately NOT locked: `busy` gates interact.js, and the whole point of being up
|
||
// there is that hold-E works. Movement is stopped by `onLadder` instead, not by `locked`.
|
||
atTop: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true, releasedBy: 'ladder' },
|
||
};
|
||
|
||
/**
|
||
* True while the player is on a ladder — movement is off, and the wind is meaner.
|
||
*
|
||
* Keyed on HEIGHT, not on state, and that distinction is load-bearing: hold-E puts you into `busy`,
|
||
* so a state-based test would say you'd stepped off the ladder the instant you started the repair
|
||
* you climbed up to do. (It did exactly that — the gate cancelled its own hold.) Height is the
|
||
* physical truth and survives every state the ladder passes through.
|
||
*/
|
||
export const onLadder = (sim) => sim.climbY > 0.02 || sim.state === 'climb';
|
||
|
||
/**
|
||
* 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)
|
||
|
||
// Wind lean (SPRINT13 gate 2.4). The QA pass: "the player stands bolt upright and unbothered" at
|
||
// 65 km/h. E rendered the two Mixamo lean candidates and rejected both on screen (a couch-shove
|
||
// and a bus-stop slouch), and proved the deeper reason a clip can't do this at all: _rotOnly drops
|
||
// Hips.quaternion from every clip at load (player.js:44), so a baked lean is discarded — and a
|
||
// clip's lean axis is fixed while the wind's bearing swings. So the lean is the ROOT, same machine
|
||
// as the knockdown pitch: the sim owns an angle and a direction, the view tips the body by it.
|
||
// Keyed on windBase (the ~4 s EMA), NOT raw windSpeed — measured: raw strobes the posture 8-18×/min
|
||
// across the storms, windBase settles it to 0-2 changes per 90 s. Sustained wind = posture; gust is
|
||
// already the stumble/knock event. Bands land on the storm rating ladder (D's THREADS table): night
|
||
// 1 upright all night, 2-3 lean, 4-5 in the gale.
|
||
leanWindMin: 8.3, // m/s windBase (30 km/h — the speed C's leaves start at) before you lean at all
|
||
leanWindFull: 22, // m/s windBase for a full lean — past the wildnight median, shy of its 25 peak
|
||
leanMaxRad: 0.30, // rad (~17°) of torso tip at full lean. A brace-into-it, not a fall (pitch is 0.5π)
|
||
leanSecs: 0.6, // s to reach/leave the target lean — slow, so a lull visibly lets you straighten
|
||
|
||
// 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
|
||
|
||
// Ladder (decision 12). DESIGN.md: "ladder work at height in wind is genuinely tense" — this is
|
||
// where that gets teeth. You cannot brace up there (both hands are on the rungs), so the only
|
||
// defence is choosing your moment: climb in a lull, not through a gust.
|
||
climbRate: 1.1, // m/s up or down a rung — slow enough that the storm gets a vote
|
||
reach: 2.2, // m — how high a 1.72 m person's hands get, standing. Fascia sits at 2.48.
|
||
ladderKnockMult: 0.6, // knockWind × this while on the ladder — far easier to be blown off
|
||
};
|
||
|
||
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.lean = 0; // 0 upright … 1 full wind-lean (view reads this + leanDir)
|
||
this.leanDir = { x: 0, z: 1 }; // unit downwind — the way the wind is trying to push you
|
||
|
||
this.climbY = 0; // m above the ground; >0 means you're up a ladder
|
||
this.climbTarget = 0; // where ladder.js asked you to be
|
||
this.fellFrom = 0; // m — height of the last fall, for the HUD/aftermath to shame you with
|
||
|
||
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); }
|
||
|
||
/** Radians of torso tip at a full wind-lean. Surfaced so PlayerView reads it, not TUNE. */
|
||
get leanMaxRad() { return this.tune.leanMaxRad; }
|
||
|
||
/** How high this person's hands get right now. The gate on reaching a fascia bracket. */
|
||
get reachY() { return this.pos.y + this.climbY + this.tune.reach; }
|
||
|
||
/**
|
||
* Go up or down a ladder. ladder.js owns WHERE (it knows the rungs); the sim owns the motion, so
|
||
* a climb is deterministic and fast-forwards in selftest like everything else.
|
||
* @param {number} y target height above ground; 0 climbs back down
|
||
*/
|
||
climbTo(y, t = 0) {
|
||
this.climbTarget = Math.max(0, y);
|
||
if (Math.abs(this.climbTarget - this.climbY) > 0.02) {
|
||
this.vel.x = this.vel.z = 0;
|
||
this.setState('climb', t);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
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 };
|
||
// Blown off a ladder: you don't stagger, you fall. Everything else about a knockdown is the
|
||
// same, so the ladder gets the get-up chain for free — you just arrive on the ground first.
|
||
this.fellFrom = this.climbY;
|
||
this.climbY = 0;
|
||
this.climbTarget = 0;
|
||
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 }, fellFrom: this.fellFrom });
|
||
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, and refused on
|
||
// a ladder: both hands are on the rungs. Up there your only defence is having picked a lull. ---
|
||
const up = onLadder(this);
|
||
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; a ladder LOWERS it. Same exposure clock either way, so the storm
|
||
// speaks one language whether you're on your feet or up a rung. ---
|
||
const knockAt = T.knockWind
|
||
* (braced ? T.shelterKnockMult : 1)
|
||
* (up ? T.ladderKnockMult : 1);
|
||
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);
|
||
|
||
// --- the climb itself: code-driven height, ClimbLadder plays on top (the knockdown precedent) ---
|
||
if (this.state === 'climb') {
|
||
const dy = this.climbTarget - this.climbY;
|
||
const rung = T.climbRate * dt;
|
||
if (Math.abs(dy) <= rung) {
|
||
this.climbY = this.climbTarget;
|
||
this.setState(this.climbY > 0.02 ? 'atTop' : 'idle', t);
|
||
} else {
|
||
this.climbY += Math.sign(dy) * rung;
|
||
}
|
||
}
|
||
// Invariant: you cannot be standing on the grass while you are 2 m up a ladder. interact.js
|
||
// releases a finished hold to 'idle' without knowing where you are; this puts you back in the
|
||
// work stance instead of leaving you idling in mid-air.
|
||
if (this.climbY > 0.02
|
||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||
this.setState('atTop', t);
|
||
}
|
||
|
||
// --- 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];
|
||
const aloft = onLadder(this); // re-read: the climb block above may have just landed you
|
||
|
||
// --- movement ---
|
||
const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you
|
||
let wantX = 0, wantZ = 0;
|
||
if (!st.locked && !aloft) {
|
||
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);
|
||
|
||
// --- wind lean: the sim owns it exactly like pitch, so the view stays a pure reader and the lean
|
||
// is deterministic (dt,t). Off windBase, not ws, so a gust punches the stumble/knock event
|
||
// while the posture holds. Suppressed where a lean would fight another root pose: on your back
|
||
// (pitch owns the body), braced (TakeCover is its own hunch), or up the ladder (you're pinned
|
||
// to the rungs, not standing in the wind). It eases to 0 there rather than snapping. ---
|
||
const leanOK = this.pitch < 1e-3 && this.state !== 'shelter' && !up;
|
||
const leanSpan = Math.max(1e-3, T.leanWindFull - T.leanWindMin);
|
||
const wantLean = leanOK
|
||
? clamp((this.windBase - T.leanWindMin) / leanSpan, 0, 1)
|
||
: 0;
|
||
const lstep = dt / T.leanSecs;
|
||
this.lean = clamp(this.lean + clamp(wantLean - this.lean, -lstep, lstep), 0, 1);
|
||
// downwind is where the wind blows TOWARD; track it only while there's a wind to read, so a lull
|
||
// leaves the last bearing rather than snapping leanDir to a default and twisting the body.
|
||
if (ws > 0.1) { const inv = 1 / ws; this.leanDir.x = wx * inv; this.leanDir.z = wz * inv; }
|
||
|
||
// --- locomotion state from actual speed (so shove/slow can't desync the feet).
|
||
// `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without
|
||
// this guard the speed check would immediately re-state you to idle and drop you off. ---
|
||
if (!st.locked && !aloft) {
|
||
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;
|
||
}
|
||
}
|