New ladder.js. The whole mechanic is 200 mm: the fascia bracket sits at 2.48 m, a 1.72 m person's hands reach 2.20, and E's ladder tops out at 2.90. The asset and the yard were already built for each other; this is the verb between them. Carry-ladder is a second carry type, so the ladder and the spare compete for the same pair of hands and a fascia repair costs two trips while a post repair costs one — DESIGN.md's "limited hands" rule doing real work, and the reason the house is the expensive anchor to depend on (which E's ratingHint 0.35 / collateral "gutter" was already saying in the data). Climb height is code-driven with ClimbLadder playing on top — the knockdown precedent, since _rotOnly strips the root and a clip can no more lift the body than Falling could lay it down. You can't brace up there (both hands on the rungs), the wind's bar drops to 0.6x, and being blown off is a fall that feeds straight into the existing get-up chain. needsLadder is scoped to the fascia on purpose: a height test would have roped in the 3.95 m posts and 5.05 m limbs, made every repair a two-trip job, and silently invalidated the recorded §7 run — and it isn't true to rigging either. Landed with no change to main.js: createLadder self-wires from createPlayer, which Lane A already hands the scene, world and interact. Two bugs found by building on my own API, both now asserted: a canUse that reads player.state cancels its own hold (starting a hold sets busy) — it ate the climb AND the reach gate before I keyed both on physical height instead; and onLadder had to become height-based for the same reason. Documented on register(). Selftest 194/0/0 (was 184). Full loop driven by hand in the real game. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
396 lines
20 KiB
JavaScript
396 lines
20 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)
|
||
|
||
// 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.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); }
|
||
|
||
/** 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);
|
||
|
||
// --- 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;
|
||
}
|
||
}
|