Back to Mini Samurai
SOURCE / PINNED RELEASE

Made of little things.

Mini Samurai

Release
a7beb798adbc…
Author-recorded commit
4f97be1515b8…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b

Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/fighter.ts
import * as THREE from 'three';
import * as A from './anims.js';
import { Rig, type Livery } from './rig.js';
import { REST_HIPS_Y, blendPose, copyPose, emptyPose, ease, grip, type Clip, type Pose } from './pose.js';
import { movesFor, type AttackDef, type MoveSet } from './moves.js';
import { buildButton, buildThimble } from './throwables.js';
import { CROUCH_HEIGHT, GRAB_MAX, HANG_DROP, HANG_OUT, HEIGHT, RADIUS, World, type Body, type Ledge } from './world.js';
import type { Solid, ThrowDef, WeaponDef } from './types.js';
import * as sfx from './sound.js';

/** One frame of intent for a fighter, from a keyboard, pad, touch or the CPU. */
export interface Controls {
  /** Desired world-space movement (length ≤ 1). */
  moveX: number;
  moveZ: number;
  run: boolean;
  crouch: boolean;
  jump: boolean;
  climb: boolean;
  drop: boolean;
  light: boolean;
  heavy: boolean;
  block: boolean;
  grab: boolean;
  roll: boolean;
}

export function noControls(): Controls {
  return { moveX: 0, moveZ: 0, run: false, crouch: false, jump: false, climb: false, drop: false, light: false, heavy: false, block: false, grab: false, roll: false };
}

export type State =
  | 'move'
  | 'air'
  | 'hang'
  | 'climb'
  | 'attack'
  | 'block'
  | 'hit'
  | 'stagger'
  | 'disarmed'
  | 'down'
  | 'getup'
  | 'pickup'
  | 'slide'
  | 'land'
  | 'throw'
  | 'roll'
  | 'swim'
  | 'drive'
  | 'ko'
  | 'victory';

export interface FighterHost {
  world: World;
  /** How fast the thing under a samurai's feet is moving (the toy train), or zero. */
  surfaceVelocity(support: Solid | null, at: THREE.Vector3, out: THREE.Vector3): THREE.Vector3;
  nearestEnemy(f: Fighter, range: number): Fighter | null;
  pickupNear(f: Fighter): { def: WeaponDef | ThrowDef } | null;
  takePickup(f: Fighter): void;
  /** Put the carried object down (it becomes a pickup again). */
  dropCarry(f: Fighter, away?: THREE.Vector3): void;
  /** Launch the carried object from `from` (world space) at whoever the thrower is facing. */
  throwItem(f: Fighter, def: ThrowDef, from: THREE.Vector3): void;
  /** Who a throw would be aimed at, if anyone. */
  throwTarget(f: Fighter): Fighter | null;
  wind: THREE.Vector3;
  /** Audio volume factor for a world position (0 = inaudible). */
  hearing(p: THREE.Vector3): number;
  dust(p: THREE.Vector3, amount: number): void;
  /** 0..1: how slippery the floor is here (puddles). */
  slipAt(p: THREE.Vector3): number;
  /** Height of the water surface over this spot, if it's in water (the filling bath). */
  waterAt(p: THREE.Vector3): number | null;
  /** A splash, a sparkle, or a "blocked" flash at a point. */
  effect(kind: 'splash' | 'sparkle' | 'guard', p: THREE.Vector3): void;
  popup(f: Fighter, text: string, color: string): void;
  /** Climb into a toy car within reach, if there's a free one. */
  tryDrive(f: Fighter): boolean;
}

const WALK = 2.1;
const SWIM = 1.7;
/** Water deeper than this over the floor and your feet can't touch: you swim. */
const SWIM_DEPTH = 0.9;
/** A sugar rush lasts this long and makes you this much faster. */
const SUGAR_TIME = 9;
const SUGAR_SPEED = 1.35;
const RUN = 4.9;
const CROUCH_SPEED = 1.0;
const GRAVITY = 28;
const JUMP_V = 8.7;
const HANG_SPEED = 0.9;
const BLOCK_WALK = 1.3;
/** Seconds to heave a picked-up object from chest height to over the head. */
const LIFT_TIME = 0.28;
const THROW_TIME = 0.62;
/** Roll timeline: drop in, tumble over the shoulder, come up in a low guard. */
const ROLL_TIME = 0.74;
const ROLL_ENTRY_END = 0.12;
const ROLL_SPIN_END = 0.5;
/** Hits pass through a rolling samurai from the dive until they come up. */
const ROLL_DODGE = 0.46;
/** Seconds to stand back up after a knockdown (the get-up clip is played this fast). */
const GETUP_TIME = 0.46;
/** Armed samurai sheathe (or tuck the item in the belt) before diving... */
const ROLL_SHEATHE = 0.2;
/** ...and take this much longer coming up while they draw again. */
const ROLL_DRAW_EXTRA = 0.16;
const CLIMB_TIME = 0.62;

const _v = new THREE.Vector3();
const _shieldPos = new THREE.Vector3();
const _shieldE = new THREE.Euler();
const _shieldQa = new THREE.Quaternion();
const _shieldQb = new THREE.Quaternion();
const _shieldQc = new THREE.Quaternion();
const _shieldQd = new THREE.Quaternion();
const _bandA = new THREE.Vector3();
const _bandB = new THREE.Vector3();
const _flailS = new THREE.Vector3();
const _flailD = new THREE.Vector3();
const _flailQ = new THREE.Quaternion();
const _flailUp = new THREE.Vector3(0, 1, 0);
const _surfaceVel = new THREE.Vector3();
/** Landing faster than this (falling from higher than a normal jump) turns into a safety roll. */
const SAFETY_FALL = 11;
const _v2 = new THREE.Vector3();

export class Fighter {
  readonly rig: Rig;
  readonly body: Body;
  facing = 0;
  health = 100;
  state: State = 'move';
  stateTime = 0;
  weapon: { def: WeaponDef; durability: number } | null = null;
  moves: MoveSet = movesFor(null);
  attack: AttackDef | null = null;
  combo = 0;
  /** Victims already struck during the current attack. */
  readonly struck = new Set<Fighter>();
  /** Hitstop: the fighter freezes in place for this long. */
  freeze = 0;
  shake = 0;
  invulnerable = 0;
  blockStart = 0;
  /** Seconds since the last time the fighter was hurt (AI and HUD flashes). */
  hurtAgo = 99;
  readonly display: Pose = emptyPose();
  private readonly target: Pose = emptyPose();
  private readonly snapshot: Pose = emptyPose();
  private blendT = 1;
  private blendDur = 0.1;
  private phase = 0;
  private lean = 0;
  private landDip = 0;
  private landVel = 0;
  /**
   * Set each frame by the game while the samurai is in the air after leaving the moving
   * train: the train's velocity, which they keep (they jump with it, not off it).
   */
  readonly carryVel = new THREE.Vector3();
  /** Seconds of sugar rush left. */
  sugar = 0;
  /** Wearing a thimble helmet (stops one hit). */
  private thimble: THREE.Object3D | null = null;
  /** A button shield on the left arm, and the hits from the front it can still take. */
  private button: THREE.Object3D | null = null;
  buttonHits = 0;
  /** Depth of water over the floor where the samurai stands (0 when dry). */
  private waterDepth = 0;
  private waterLevel = 0;
  private crouchAmt = 0;
  private sprintAmt = 0;
  private coyote = 0;
  private grabCooldown = 0;
  private buffered: 'light' | 'heavy' | null = null;
  private bufferTime = 0;
  private ledge: Ledge | null = null;
  private ledgeAlong = 0;
  private shimmy = 0;
  private shimmyPhase = 0;
  private climbFrom = new THREE.Vector3();
  private climbTo = new THREE.Vector3();
  private clipOverride: Clip | null = null;
  private lungeDone = false;
  private lastSwish = false;
  private wasCrouch = false;
  private downTime = 0;
  private time = 0;
  readonly bladeBase = new THREE.Vector3();
  readonly bladeTip = new THREE.Vector3();
  readonly prevBase = new THREE.Vector3();
  readonly prevTip = new THREE.Vector3();
  /** Household object lifted over the head, ready to throw. */
  carrying: ThrowDef | null = null;
  private carryLift = 0;
  private thrown = false;
  /** Last place the fighter stood safely, used to rescue them if they ever leave the room. */
  readonly lastSafe = new THREE.Vector3();
  /** Ground speed this frame, for AI and audio. */
  speed = 0;
  private readonly flinchCache = new Map<string, Clip>();
  private readonly clipCache = new Map<string, Clip>();

  constructor(
    readonly index: number,
    readonly team: number,
    readonly name: string,
    readonly livery: Livery,
    readonly human: boolean,
    scene: THREE.Object3D,
    private readonly host: FighterHost,
  ) {
    this.rig = new Rig(livery, scene);
    // Yaw first, then pitch about the fighter's own side axis (used to spin through rolls).
    this.rig.root.rotation.order = 'YXZ';
    scene.add(this.rig.root);
    this.body = { pos: new THREE.Vector3(), vel: new THREE.Vector3(), height: HEIGHT, onGround: false, support: null };
  }

  get pos(): THREE.Vector3 {
    return this.body.pos;
  }

  get alive(): boolean {
    return this.state !== 'ko';
  }

  get grip(): A.Grip {
    return this.moves.grip;
  }

  forward(out = new THREE.Vector3()): THREE.Vector3 {
    return out.set(Math.sin(this.facing), 0, Math.cos(this.facing));
  }

  /** Put the fighter back somewhere safe (they somehow left the room). */
  rescue(): void {
    this.body.pos.copy(this.lastSafe);
    this.body.vel.set(0, 0, 0);
    this.body.support = null;
    this.ledge = null;
    if (this.state === 'hang' || this.state === 'climb') this.setState('air', 0.1);
  }

  /** Spawn at the start of a round. */
  reset(x: number, y: number, z: number, facing: number): void {
    this.body.pos.set(x, y, z);
    this.body.vel.set(0, 0, 0);
    this.body.support = null;
    this.body.onGround = true;
    this.facing = facing;
    this.health = 100;
    this.state = 'move';
    this.stateTime = 0;
    this.attack = null;
    this.freeze = 0;
    this.invulnerable = 0;
    this.hurtAgo = 99;
    this.ledge = null;
    this.rig.stow(false);
    this.body.height = HEIGHT;
    this.lastSafe.set(x, y, z);
    this.sugar = 0;
    this.loseThimble();
    this.loseButton();
    this.rig.root.position.copy(this.body.pos);
    this.rig.root.rotation.x = 0;
    this.rig.root.rotation.y = facing;
    this.computePose(0);
    copyPose(this.display, this.target);
    this.blendT = 1;
    this.rig.apply(this.display, 0, this.host.wind);
    this.rig.resetSecondary();
  }

  /** Take a pick-up's effect. */
  applyPower(kind: 'sugar' | 'thimble' | 'button'): void {
    if (kind === 'sugar') {
      this.sugar = SUGAR_TIME;
      this.host.popup(this, 'Sugar rush!', '#fff6d0');
    } else if (kind === 'thimble') {
      this.loseThimble();
      this.thimble = buildThimble();
      // Worn over the helmet like a tall metal cap, its rim resting on the helmet's brim.
      this.thimble.scale.setScalar(1.18);
      this.thimble.position.set(0, 0.125, -0.005);
      this.rig.head.add(this.thimble);
      this.host.popup(this, 'Thimble helmet!', '#e6edf2');
    } else {
      this.loseButton();
      // Held in the left hand like a round shield, as big as it lies on the floor.
      this.button = new THREE.Group();
      const disc = buildButton();
      disc.position.y = -0.05;
      this.button.add(disc);
      this.rig.root.add(this.button);
      this.buttonHits = 4;
      this.host.popup(this, 'Button shield!', '#ffb3b3');
    }
  }

  private loseThimble(): void {
    this.thimble?.removeFromParent();
    this.thimble = null;
  }

  private loseButton(): void {
    this.button?.removeFromParent();
    this.button = null;
    this.buttonHits = 0;
  }

  /** Holding a button shield. */
  get hasShield(): boolean {
    return this.button !== null;
  }

  /** The shield took a blow while blocking; after a few it splits. */
  shieldHit(): void {
    this.buttonHits--;
    this.host.popup(this, this.buttonHits > 0 ? 'Shield block!' : 'Shield broke!', '#ffb3b3');
    if (this.buttonHits <= 0) this.loseButton();
  }

  /** Keep the shield in the left hand: facing forward while blocking, edge-on at the side otherwise. */
  private placeShield(dt: number): void {
    if (!this.button) return;
    const root = this.rig.root;
    root.updateMatrixWorld(true);
    const hand = this.rig.handL.getWorldPosition(_shieldPos);
    root.worldToLocal(hand);
    const up = this.state === 'block' ? 1 : 0;
    this.shieldRaise += (up - this.shieldRaise) * Math.min(1, dt * 16);
    const k = this.shieldRaise;
    this.button.position.set(hand.x + 0.05 * (1 - k), hand.y, hand.z + 0.07 * k);
    // Disc axis: out to the left at rest (+X), straight ahead when raised (+Z).
    _shieldQa.setFromEuler(_shieldE.set(0, 0, -Math.PI / 2));
    _shieldQb.setFromEuler(_shieldE.set(Math.PI / 2, 0, 0));
    this.button.quaternion.copy(_shieldQa).slerp(_shieldQb, k);
  }

  private shieldRaise = 0;

  /** What the HUD shows besides the weapon. */
  get powerLabel(): string {
    const bits: string[] = [];
    if (this.sugar > 0) bits.push('sugar rush');
    if (this.thimble) bits.push('thimble');
    if (this.button) bits.push(`button shield ×${this.buttonHits}`);
    return bits.join(', ');
  }

  equip(def: WeaponDef | null, durability = def?.durability ?? 0): void {
    this.weapon = def ? { def, durability } : null;
    this.moves = movesFor(def);
    this.rig.setWeapon(def ? def.build() : null, def ? (def.style === 'katana' ? -0.12 : Math.min(0.3, def.length * 0.24)) : 0, def?.length);
  }

  private setState(s: State, blend = 0.12): void {
    this.rolledRecently = false;
    // Leaving a roll early (hit, blocked...) puts the weapon straight back in hand.
    if (s !== 'roll') this.rig.sheathe(null);
    this.state = s;
    this.stateTime = 0;
    copyPose(this.snapshot, this.display);
    this.blendT = 0;
    this.blendDur = blend;
    this.clipOverride = null;
    this.rig.stow(s === 'hang' || s === 'climb' || s === 'throw' || this.carrying !== null);
  }

  // ---- Update ----------------------------------------------------------------

  update(dt: number, c: Controls): void {
    this.time += dt;
    this.hurtAgo += dt;
    if (this.freeze > 0) {
      this.freeze -= dt;
      this.shake = Math.max(0, this.shake - dt);
      return;
    }
    this.stateTime += dt;
    this.invulnerable = Math.max(0, this.invulnerable - dt);
    this.grabCooldown = Math.max(0, this.grabCooldown - dt);
    this.rollCooldown = Math.max(0, this.rollCooldown - dt);
    this.bufferTime = Math.max(0, this.bufferTime - dt);
    if (this.bufferTime <= 0) this.buffered = null;
    if (c.light) this.bufferInput('light');
    if (c.heavy) this.bufferInput('heavy');
    if (this.sugar > 0) {
      this.sugar -= dt;
      if (this.speed > 2.5 && Math.random() < dt * 14) this.host.effect('sparkle', this.pos);
    }

    const body = this.body;
    // Water: wade through the shallows, swim once the floor is out of reach.
    const level = this.host.waterAt(body.pos);
    this.waterLevel = level ?? 0;
    this.waterDepth = level === null ? 0 : Math.max(0, level - this.host.world.heightAt(body.pos.x, body.pos.y + 0.3, body.pos.z));
    if (
      this.waterDepth > SWIM_DEPTH &&
      body.pos.y < this.waterLevel - 0.55 &&
      (this.state === 'move' || this.state === 'air' || this.state === 'land' || this.state === 'block' || this.state === 'slide' || this.state === 'roll' || this.state === 'hit')
    ) {
      if (this.carrying) this.host.dropCarry(this);
      this.attack = null;
      if (this.state === 'air' || this.state === 'roll') this.host.effect('splash', body.pos.clone().setY(this.waterLevel));
      this.setState('swim', 0.2);
    }
    switch (this.state) {
      case 'swim':
        this.updateSwim(dt, c);
        break;
      case 'drive':
        // The game steers the car and keeps the driver in the seat.
        break;
      case 'move':
      case 'land':
        this.updateGround(dt, c);
        break;
      case 'air':
        this.updateAir(dt, c);
        break;
      case 'hang':
        this.updateHang(dt, c);
        break;
      case 'climb':
        this.updateClimb();
        break;
      case 'attack':
        this.updateAttack(dt, c);
        break;
      case 'block':
        // Guarding samurai can still step around, just slowly.
        if (c.roll && this.startRoll(c)) break;
        if (!c.block || !body.onGround) this.setState('move', 0.14);
        else this.steerGround(dt, c, BLOCK_WALK, true);
        break;
      case 'roll':
        this.updateRoll();
        break;
      case 'throw':
        this.updateThrow(dt, c);
        break;
      case 'slide': {
        const decay = Math.pow(0.12, dt);
        body.vel.x *= decay;
        body.vel.z *= decay;
        if (this.stateTime > 0.32 && c.light) this.startAttack(this.moves.low);
        else if (this.stateTime > 0.55) this.setState('move', 0.25);
        break;
      }
      case 'hit':
      case 'stagger':
      case 'disarmed':
        this.friction(dt, 6);
        if (this.stateTime > (this.state === 'hit' ? 0.34 : this.state === 'stagger' ? 0.62 : 0.55)) this.setState('move', 0.2);
        break;
      case 'down':
        this.friction(dt, body.onGround ? 5 : 0.5);
        if (body.onGround) this.downTime += dt;
        if (this.downTime > 0.7 && this.stateTime > 0.45) {
          this.setState('getup', 0.05);
          this.invulnerable = 0.5;
        }
        break;
      case 'getup':
        this.friction(dt, 10);
        // An attack pressed while rising comes out as soon as the feet are under them.
        if (this.stateTime > GETUP_TIME * 0.55 && this.buffered) this.setState('move', 0.1);
        else if (this.stateTime > GETUP_TIME) this.setState('move', 0.15);
        break;
      case 'pickup':
        this.friction(dt, 12);
        if (this.stateTime > 0.17 && this.stateTime - dt <= 0.17) this.host.takePickup(this);
        if (this.stateTime > 0.44) this.setState('move', 0.15);
        break;
      case 'ko':
        this.friction(dt, body.onGround ? 4 : 0.3);
        break;
      case 'victory':
        this.friction(dt, 10);
        break;
    }

    if (this.state !== 'hang' && this.state !== 'climb' && this.state !== 'drive') {
      if (this.state !== 'swim') body.vel.y -= GRAVITY * dt;
      const before = body.onGround;
      const r = this.host.world.move(body, dt);
      if (r.landed) this.onLand(r.landSpeed);
      if (before && !body.onGround && body.vel.y <= 0) this.coyote = 0.1;
      if (!body.onGround && (this.state === 'move' || this.state === 'block' || this.state === 'land' || this.state === 'slide')) {
        this.coyote -= dt;
        if (this.coyote <= 0) this.setState('air', 0.18);
      }
      // Fell out of the world (should not happen): put them back on the floor.
      if (body.pos.y < -5) body.pos.y = 2;
    }

    // Nobody can stand on a ball: slide off its nearest side.
    const under = body.onGround ? body.support : null;
    if (under?.slide) {
      const p = body.pos;
      const exits: [number, number, number][] = [
        [p.x - under.minX, -1, 0],
        [under.maxX - p.x, 1, 0],
        [p.z - under.minZ, 0, -1],
        [under.maxZ - p.z, 0, 1],
      ];
      exits.sort((a, b) => a[0] - b[0]);
      const [, ex, ez] = exits[0]!;
      body.vel.x = ex * 3.2;
      body.vel.z = ez * 3.2;
    } else if (body.onGround && !body.support?.moving && (this.state === 'move' || this.state === 'block')) this.lastSafe.copy(body.pos);
    this.speed = Math.hypot(body.vel.x, body.vel.z);
    this.landDip += this.landVel * dt;
    this.landVel += (-this.landDip * 220 - this.landVel * 18) * dt;
  }

  /** Breaststroke: float with the head out of the water, paddle about, hop out at the edge. */
  private updateSwim(dt: number, c: Controls): void {
    const body = this.body;
    this.buffered = null;
    body.height = HEIGHT;
    // Bob at the surface.
    const target = this.waterLevel - 0.74 + Math.sin(this.time * 2.4) * 0.03;
    body.vel.y += ((target - body.pos.y) * 30 - body.vel.y * 7) * dt;
    const len = Math.hypot(c.moveX, c.moveZ);
    const k = 1 - Math.exp(-2.5 * dt);
    body.vel.x += (c.moveX * SWIM - body.vel.x) * k;
    body.vel.z += (c.moveZ * SWIM - body.vel.z) * k;
    if (len > 0.1) this.turnToward(Math.atan2(c.moveX, c.moveZ), 4, dt);
    if (this.waterDepth < SWIM_DEPTH - 0.15 || this.waterLevel === 0) {
      this.setState('move', 0.25);
      return;
    }
    if (c.jump && this.stateTime > 0.2) {
      // Catch the edge if it's in reach, otherwise kick up out of the water.
      const dir = len > 0.1 ? _v.set(c.moveX, 0, c.moveZ).normalize() : this.forward(_v);
      const ledge = this.host.world.findLedge(body.pos, dir);
      if (ledge) this.grabLedge(ledge);
      else {
        body.vel.y = 7.5;
        this.setState('air', 0.12);
        this.host.effect('splash', body.pos.clone().setY(this.waterLevel));
      }
    }
  }

  private bufferInput(kind: 'light' | 'heavy'): void {
    this.buffered = kind;
    this.bufferTime = 0.22;
  }

  private friction(dt: number, k: number): void {
    const f = Math.exp(-k * dt * (1 - 0.8 * this.host.slipAt(this.pos)));
    this.body.vel.x *= f;
    this.body.vel.z *= f;
  }

  /** Turn toward an angle at a limited rate and remember the turn for leaning. */
  private turnToward(angle: number, rate: number, dt: number): void {
    let diff = angle - this.facing;
    diff = Math.atan2(Math.sin(diff), Math.cos(diff));
    const step = Math.max(-rate * dt, Math.min(rate * dt, diff));
    this.facing += step;
    this.lean += ((dt > 0 ? step / dt / 10 : 0) - this.lean) * Math.min(1, dt * 8);
  }

  private steerGround(dt: number, c: Controls, maxSpeed: number, faceEnemy: boolean): void {
    const body = this.body;
    const len = Math.hypot(c.moveX, c.moveZ);
    const tx = c.moveX * maxSpeed;
    const tz = c.moveZ * maxSpeed;
    // On a puddle your feet barely grip: you keep sliding the way you were going.
    const slip = this.host.slipAt(body.pos);
    const accel = (len > 0.05 ? 26 : 30) * (1 - 0.88 * slip);
    const k = 1 - Math.exp(-accel * dt / Math.max(1, maxSpeed * 0.6));
    body.vel.x += (tx - body.vel.x) * k;
    body.vel.z += (tz - body.vel.z) * k;
    const enemy = faceEnemy ? this.host.nearestEnemy(this, 5) : null;
    if (enemy) this.turnToward(Math.atan2(enemy.pos.x - body.pos.x, enemy.pos.z - body.pos.z), 10, dt);
    else if (len > 0.1) this.turnToward(Math.atan2(c.moveX, c.moveZ), c.run ? 9 : 13, dt);
    else this.lean *= 1 - Math.min(1, dt * 6);
  }

  private updateGround(dt: number, c: Controls): void {
    const body = this.body;
    if (this.state === 'land' && this.stateTime > 0.14) this.state = 'move';
    const wantCrouch = c.crouch;
    const canStand = !this.host.world.blocked(body.pos.x, body.pos.y, body.pos.z, HEIGHT, RADIUS * 0.9);
    const crouching = wantCrouch || (body.height < HEIGHT && !canStand);
    body.height = crouching ? CROUCH_HEIGHT : HEIGHT;
    this.crouchAmt += ((crouching ? 1 : 0) - this.crouchAmt) * Math.min(1, dt * 14);

    // Slide out of a sprint.
    if (wantCrouch && !this.wasCrouch && this.speed > 3.6) {
      this.wasCrouch = true;
      this.setState('slide', 0.08);
      const f = this.forward(_v);
      body.vel.x = f.x * 6.2;
      body.vel.z = f.z * 6.2;
      this.host.dust(body.pos, 6);
      sfx.sfxLand(false);
      return;
    }
    this.wasCrouch = wantCrouch;

    const running = c.run && !crouching;
    // Heavy objects overhead slow you down.
    const burden = this.carrying ? 1.1 - this.carrying.weight * 0.25 : 1;
    // Sugar makes you quicker; wading through water slows you down.
    const wade = this.waterDepth > 0.15 ? 0.6 : 1;
    const max = (crouching ? CROUCH_SPEED : running ? RUN : WALK) * burden * (this.sugar > 0 ? SUGAR_SPEED : 1) * wade;
    const nearEnemy = !running && this.grip !== 'fists' ? this.host.nearestEnemy(this, 4.2) : !running ? this.host.nearestEnemy(this, 3) : null;
    this.steerGround(dt, c, max, nearEnemy !== null);
    this.sprintAmt += ((running && this.speed > 3 ? 1 : 0) - this.sprintAmt) * Math.min(1, dt * 6);

    if (c.roll && this.startRoll(c)) return;
    if (c.jump && !crouching) {
      this.jump();
      return;
    }
    if (this.carrying) {
      // Hands are full: the grab key puts the object down, attack throws it.
      if (c.grab) this.host.dropCarry(this);
      else if (this.buffered) {
        this.buffered = null;
        this.startThrow();
      }
      return;
    }
    if (c.block) {
      this.setState('block', 0.08);
      this.blockStart = this.time;
      return;
    }
    if (c.grab && !this.carrying && this.host.tryDrive(this)) return;
    if (c.grab && this.host.pickupNear(this)) {
      this.setState('pickup', 0.1);
      return;
    }
    if (this.buffered) {
      const kind = this.buffered;
      this.buffered = null;
      if (kind === 'heavy') this.startAttack(this.moves.heavy);
      else if (crouching) this.startAttack(this.moves.low);
      else if (this.speed > 3.6) this.startAttack(this.moves.dash);
      else {
        this.combo = 0;
        this.startAttack(this.moves.light[0]!);
      }
    }
  }

  private jump(): void {
    const body = this.body;
    body.vel.y = JUMP_V;
    body.onGround = false;
    this.coyote = 0;
    this.setState('air', 0.1);
    sfx.sfxJump();
    this.host.dust(body.pos, 3);
    this.landVel = -0.6;
  }

  private updateAir(dt: number, c: Controls): void {
    const body = this.body;
    const len = Math.hypot(c.moveX, c.moveZ);
    const k = 1 - Math.exp(-5 * dt);
    const cap = Math.max(WALK, Math.hypot(body.vel.x, body.vel.z));
    body.vel.x += (c.moveX * cap - body.vel.x) * k * Math.min(1, len * 2);
    body.vel.z += (c.moveZ * cap - body.vel.z) * k * Math.min(1, len * 2);
    if (len > 0.1) this.turnToward(Math.atan2(c.moveX, c.moveZ), 6, dt);
    body.height = HEIGHT;

    if (this.coyote > 0 && c.jump) {
      this.jump();
      return;
    }
    if (this.buffered && this.stateTime > 0.05) {
      this.buffered = null;
      if (this.carrying) this.startThrow();
      else this.startAttack(this.moves.air);
      return;
    }
    // Ledge grab: reach toward the stick direction (or straight ahead).
    if (this.grabCooldown <= 0 && body.vel.y < 3.5 && !c.drop) {
      const dir = len > 0.1 ? _v.set(c.moveX, 0, c.moveZ).normalize() : this.forward(_v);
      const ledge = this.host.world.findLedge(body.pos, dir);
      if (ledge) this.grabLedge(ledge);
    }
  }

  private grabLedge(ledge: Ledge): void {
    const body = this.body;
    // Both hands are needed on the ledge.
    if (this.carrying) this.host.dropCarry(this, ledge.normal);
    this.ledge = ledge;
    this.ledgeAlong = ledge.point.dot(ledge.tangent);
    body.vel.set(0, 0, 0);
    this.placeOnLedge();
    this.facing = Math.atan2(-ledge.normal.x, -ledge.normal.z);
    this.setState('hang', 0.08);
    this.landVel = 0.8;
    sfx.sfxGrab();
  }

  private placeOnLedge(): void {
    const l = this.ledge!;
    const p = this.body.pos;
    p.copy(l.point).addScaledVector(l.normal, HANG_OUT);
    p.addScaledVector(l.tangent, this.ledgeAlong - p.dot(l.tangent));
    p.y = l.point.y - HANG_DROP;
  }

  private updateHang(dt: number, c: Controls): void {
    const l = this.ledge!;
    const want = c.moveX * l.tangent.x + c.moveZ * l.tangent.z;
    this.shimmy += ((Math.abs(want) > 0.3 ? Math.sign(want) : 0) - this.shimmy) * Math.min(1, dt * 10);
    if (this.stateTime > 0.12) {
      const next = Math.min(l.max, Math.max(l.min, this.ledgeAlong + this.shimmy * HANG_SPEED * dt));
      this.shimmyPhase += Math.abs(next - this.ledgeAlong) * 2.2;
      this.ledgeAlong = next;
      this.placeOnLedge();
    }
    if (this.stateTime < 0.15) return;
    if (c.climb) {
      this.climbFrom.copy(this.body.pos);
      this.climbTo.copy(l.point).addScaledVector(l.normal, -0.32);
      this.climbTo.addScaledVector(l.tangent, this.ledgeAlong - this.climbTo.dot(l.tangent));
      this.setState('climb', 0.06);
      sfx.sfxClimb();
    } else if (c.drop) {
      this.setState('air', 0.15);
      this.grabCooldown = 0.45;
      this.body.vel.set(l.normal.x * 0.6, 0, l.normal.z * 0.6);
      this.rig.stow(false);
    }
  }

  /** The ledge being held or climbed (if any). */
  get heldLedge(): Ledge | null {
    return this.state === 'hang' || this.state === 'climb' ? this.ledge : null;
  }

  /** Sit down in a toy car: the weapon goes on the back, both hands on the wheel. */
  startDriving(): void {
    this.attack = null;
    this.buffered = null;
    this.ledge = null;
    this.body.vel.set(0, 0, 0);
    this.setState('drive', 0.15);
    this.rig.stow(true);
    sfx.sfxClimb();
  }

  /** Hop out of the car, keeping some of its speed. */
  leaveCar(vx: number, vz: number, side: THREE.Vector3): void {
    this.body.vel.set(vx + side.x * 2, 6, vz + side.z * 2);
    this.body.onGround = false;
    this.body.support = null;
    this.rig.stow(false);
    this.setState('air', 0.15);
    sfx.sfxJump();
  }

  /** Thrown into the air by something (the toaster popping). */
  launch(vx: number, vy: number, vz: number): void {
    if (!this.alive) return;
    if (this.state === 'hang' || this.state === 'climb') this.ledge = null;
    this.body.vel.set(vx, vy, vz);
    this.body.onGround = false;
    this.coyote = 0;
    this.grabCooldown = 0.35;
    if (this.state !== 'attack' && this.state !== 'roll') this.setState('air', 0.08);
    sfx.sfxJump();
  }

  /**
   * The held ledge moved (it's on the train, a fan blade...): carry the samurai, the ledge and the climb
   * along with it. `move` maps a point from where it was to where it is now; `turn` is how
   * far it turned about the vertical.
   */
  followLedge(move: (p: THREE.Vector3) => void, turn: number): void {
    const l = this.ledge;
    if (!l) return;
    const before = l.point.dot(l.tangent);
    move(l.point);
    const cos = Math.cos(turn);
    const sin = Math.sin(turn);
    for (const v of [l.normal, l.tangent]) v.set(v.x * cos + v.z * sin, 0, -v.x * sin + v.z * cos);
    const shift = l.point.dot(l.tangent) - before;
    l.min += shift;
    l.max += shift;
    this.ledgeAlong += shift;
    move(this.body.pos);
    move(this.climbFrom);
    move(this.climbTo);
    this.facing += turn;
  }

  private updateClimb(): void {
    const t = Math.min(1, this.stateTime / CLIMB_TIME);
    const p = this.body.pos;
    const up = ease.inOut(Math.min(1, t / 0.62));
    const over = ease.inOut(Math.max(0, (t - 0.42) / 0.58));
    p.y = this.climbFrom.y + (this.climbTo.y - this.climbFrom.y) * up;
    p.x = this.climbFrom.x + (this.climbTo.x - this.climbFrom.x) * over;
    p.z = this.climbFrom.z + (this.climbTo.z - this.climbFrom.z) * over;
    if (t >= 1) {
      this.body.vel.set(0, 0, 0);
      this.body.onGround = true;
      this.body.support = this.ledge?.solid ?? null;
      this.ledge = null;
      this.setState('move', 0.2);
    }
  }

  // ---- Dodge roll ------------------------------------------------------------------

  private readonly rollDir = new THREE.Vector3(0, 0, 1);
  private rollSide = -1;
  private rollCooldown = 0;
  /** The weapon is put away for this roll (sheathed or tucked in the belt) and drawn at the end. */
  private rollArmed = false;
  /** Something solid got in the way mid-roll: stop rolling forward. */
  private rollBlocked = false;
  /** Still blending out of a roll's low finish: keep the feet out of the floor. */
  private rolledRecently = false;

  /** Somersault toward the stick direction (or straight ahead). Returns false if a roll isn't possible now. */
  private startRoll(c: Controls): boolean {
    if (this.rollCooldown > 0 || !this.body.onGround) return false;
    if (Math.hypot(c.moveX, c.moveZ) > 0.2) this.rollDir.set(c.moveX, 0, c.moveZ).normalize();
    else this.forward(this.rollDir);
    // Keep the speed already going the way of the roll (a running roll comes out running).
    this.rollKeep = Math.max(0, Math.min(RUN + 0.4, this.body.vel.x * this.rollDir.x + this.body.vel.z * this.rollDir.z));
    this.beginRoll(0, false);
    return true;
  }

  /** Speed carried into a safety roll from the landing, bleeding away through the roll. */
  private rollCarry = 0;
  /** Speed the samurai had going into the roll: it never drops below this, so there's no stop. */
  private rollKeep = 0;
  /** Slowest a roll ever gets, so it flows straight back into moving. */
  private static readonly ROLL_FLOW = 2.4;

  /** Roll along `rollDir`, carrying `carry` speed into it (a safety roll on landing). */
  private beginRoll(carry: number, safety: boolean): void {
    // Can't tumble with something held overhead: it gets dropped first.
    if (this.carrying) this.host.dropCarry(this);
    this.rollCarry = carry;
    this.facing = Math.atan2(this.rollDir.x, this.rollDir.z);
    this.rollSide = this.pickRollSide();
    this.rollBlocked = false;
    this.rolledRecently = false;
    this.attack = null;
    this.buffered = null;
    this.setState('roll', 0.08);
    this.rollArmed = !!this.weapon;
    if (safety) this.rollKeep = 0;
    // The roll starts at once: the blade goes away as the samurai dives.
    if (this.weapon) {
      this.rig.sheathe(this.weapon.def.style === 'katana' ? 'saya' : 'belt');
      sfx.sfxClimb();
    }
    this.invulnerable = ROLL_DODGE;
    this.body.height = CROUCH_HEIGHT;
    this.host.dust(this.pos, 4);
    sfx.sfxLand(false);
  }

  /**
   * The weapon is held out sideways along the spin axis. Use the side (right first)
   * where nothing along the roll path would poke through the blade.
   */
  private pickRollSide(): number {
    const reach = this.weapon ? this.weapon.def.length * 0.85 : 0;
    if (!reach) return -1;
    const fx = this.rollDir.x;
    const fz = this.rollDir.z;
    // The spin axis is level and square to the direction of travel.
    const lx = Math.cos(this.facing);
    const lz = -Math.sin(this.facing);
    const clear = (side: number): boolean => {
      for (const k of [0, 0.8, 1.6, 2.4]) {
        for (const r of [reach * 0.5, reach]) {
          const x = this.pos.x + fx * k + lx * side * r;
          const z = this.pos.z + fz * k + lz * side * r;
          if (this.host.world.blocked(x, this.pos.y + A.ROLL_AXIS - 0.08, z, 0.16, 0.06)) return false;
        }
      }
      return true;
    };
    return clear(-1) ? -1 : clear(1) ? 1 : -1;
  }

  private updateRoll(): void {
    const body = this.body;
    const t = this.rollT;
    // Push off into the dive, carry the momentum through the tumble and come up
    // still moving, never slower than the samurai went in.
    let speed: number;
    if (t < ROLL_ENTRY_END) speed = 4 + 1.8 * (t / ROLL_ENTRY_END);
    else if (t < ROLL_SPIN_END) speed = 5.8 - 1.2 * ((t - ROLL_ENTRY_END) / (ROLL_SPIN_END - ROLL_ENTRY_END));
    else {
      // Coming up eases off only as far as a jog (or the speed it went in with).
      const exit = Math.max(Fighter.ROLL_FLOW, this.rollKeep);
      speed = exit + (4.6 - exit) * (1 - ease.out(Math.min(1, (t - ROLL_SPIN_END) / (ROLL_TIME - ROLL_SPIN_END))));
    }
    speed = Math.max(speed, this.rollKeep);
    // A safety roll keeps the landing's momentum and lets it run out over the roll.
    if (this.rollCarry > 0) speed = Math.max(speed, this.rollCarry * Math.exp(-2.4 * this.stateTime));
    // Coming up: the hand reaches the hilt at the left hip and draws.
    if (t >= ROLL_SPIN_END + DRAW_GRAB && this.rig.isSheathed) {
      this.rig.sheathe(null);
      sfx.sfxSwish(0.35);
    }
    if (this.rollBlocked) {
      speed = 0;
      // Hit something during the dive: abort the roll and come back up instead of tumbling in place.
      if (t < ROLL_ENTRY_END + 0.06 && t >= 0) {
        body.vel.x = body.vel.z = 0;
        this.rollCooldown = 0.2;
        this.setState('move', 0.18);
        this.rolledRecently = true;
        return;
      }
    }
    body.vel.x = this.rollDir.x * speed;
    body.vel.z = this.rollDir.z * speed;
    body.height = CROUCH_HEIGHT;
    if (t >= ROLL_TIME + (this.rollArmed ? ROLL_DRAW_EXTRA : 0)) {
      this.rollCooldown = 0.12;
      this.setState('move', 0.16);
      this.rolledRecently = true;
    }
  }

  /** Somersault angle: a dip forward on the dive, a full turn over the shoulder, upright to finish. */
  private get rollAngle(): number {
    if (this.state !== 'roll') return 0;
    const t = this.rollT;
    if (t < 0) return 0;
    if (t < ROLL_ENTRY_END) return 0.45 * ease.in(t / ROLL_ENTRY_END);
    if (t < ROLL_SPIN_END) return 0.45 + (Math.PI * 2 - 0.45) * ease.inOut((t - ROLL_ENTRY_END) / (ROLL_SPIN_END - ROLL_ENTRY_END));
    return 0;
  }

  /**
   * 0..1 how far into the shoulder-roll orientation (turn + sideways tilt) the body is:
   * leaning in through the dive, full over the shoulder and down the back, then
   * straightening after the hip so the body comes up square on its feet.
   */
  private get rollLean(): number {
    if (this.state !== 'roll') return 0;
    const t = this.rollT;
    if (t < 0) return 0;
    if (t < ROLL_ENTRY_END) return ease.out(t / ROLL_ENTRY_END);
    if (t >= ROLL_SPIN_END) return 0;
    const a = this.rollAngle;
    const full = Math.PI * 1.25;
    const done = Math.PI * 1.7;
    if (a <= full) return 1;
    return 1 - ease.inOut(Math.min(1, (a - full) / (done - full)));
  }

  /** Body orientation before the spin: facing, a slight turn, and the sideways tilt. */
  private rollBase(out: THREE.Quaternion): THREE.Quaternion {
    const k = this.rollLean;
    return out.setFromEuler(_rollEuler.set(0, this.facing + A.ROLL_TWIST * k, A.ROLL_BANK * k, 'YXZ'));
  }

  /** The level spin axis (square to travel), expressed in body space. */
  private rollAxisLocal(out: THREE.Vector3): THREE.Vector3 {
    out.set(Math.cos(this.facing), 0, -Math.sin(this.facing));
    return out.applyQuaternion(this.rollBase(_rollQ).invert()).normalize();
  }

  /** Roll pose for this moment: dive → tuck → low guard. */
  private rollPoseNow(out: Pose): void {
    const t = this.rollT;
    const armed = this.rollArmed;
    // Hands are free while the weapon is away (sheathed or in the belt).
    A.rollPose(out, this.rollSide, armed ? 'fists' : this.grip, this.rollAxisLocal(_rollAxis));
    if (t < ROLL_ENTRY_END + 0.04) {
      copyPose(_rollA, A.ROLL_ENTRY);
      if (armed) {
        // Coming out of the sheathe pose with empty hands.
        this.sheathePose(_rollB);
        _rollA.handR.copy(_rollB.handR);
        _rollA.handRQ.copy(_rollB.handRQ);
      } else {
        // The weapon goes straight to the spin axis so it never swings down.
        _rollA.handR.copy(out.handR);
        _rollA.handRQ.copy(out.handRQ);
      }
      blendPose(out, _rollA, out, ease.inOut(Math.min(1, t / (ROLL_ENTRY_END + 0.04))));
    } else if (t > ROLL_SPIN_END) {
      const exit = armed ? ROLL_TIME - ROLL_SPIN_END + ROLL_DRAW_EXTRA : ROLL_TIME - ROLL_SPIN_END;
      const k = ease.inOut(Math.min(1, (t - ROLL_SPIN_END) / (exit - 0.06)));
      blendPose(out, out, rollExitFor(this.grip), k);
      if (armed) this.drawOverlay(out, t - ROLL_SPIN_END);
    }
  }

  /** Where the scabbard is right now (fighter space), for sheathing and drawing. */
  private readonly sayaMouth = new THREE.Vector3();
  private readonly sayaDir = new THREE.Vector3();
  private readonly sayaEdge = new THREE.Vector3();

  /** Hand on the hilt at the scabbard's mouth, blade lined up with the scabbard. */
  private handAtSaya(out: Pose, pull: number): void {
    this.rig.sayaFrame(this.sayaMouth, this.sayaDir, this.sayaEdge);
    // The rig adds the hips' offset to hand targets; take it off so the hand lands exactly.
    const sx = out.hips.x;
    const sy = out.hips.y - REST_HIPS_Y;
    const sz = out.hips.z;
    out.handR.copy(this.sayaMouth).addScaledVector(this.sayaDir, -pull);
    out.handR.x -= sx;
    out.handR.y -= sy;
    out.handR.z -= sz;
    out.handRQ.copy(grip(this.sayaDir, this.sayaEdge));
    out.twoHand = 0;
    out.elbowR.set(-0.6, -1, 0.2).normalize();
    // Left hand holds the scabbard just behind its mouth.
    out.handL.copy(this.sayaMouth).addScaledVector(this.sayaDir, 0.07);
    out.handL.x -= sx;
    out.handL.y -= sy;
    out.handL.z -= sz;
    out.elbowL.set(1, -0.8, -0.2).normalize();
  }

  private sheathePose(out: Pose): void {
    copyPose(out, A.stanceFor(this.grip));
    const k = ease.inOut(Math.min(1, this.stateTime / ROLL_SHEATHE));
    // Sink and lean forward a little, ready to dive.
    out.hips.y -= 0.06 * k;
    out.hipsQ.multiply(_q.setFromEuler(_rollEuler.set(0.2 * k, -0.25 * k, 0)));
    out.headQ.multiply(_q.setFromEuler(_rollEuler.set(-0.2 * k, 0.3 * k, 0)));
    this.handAtSaya(out, 0.04 * (1 - k));
  }

  /** Iai-style draw while rising: reach the hilt, pull the blade out forward, sweep to guard. */
  private drawOverlay(out: Pose, dt: number): void {
    if (dt < DRAW_GRAB) {
      // Reach across to the hilt at the left hip.
      copyPose(_rollB, out);
      this.handAtSaya(_rollB, 0);
      const k = ease.out(dt / DRAW_GRAB);
      out.handR.lerp(_rollB.handR, k);
      out.handRQ.slerp(_rollB.handRQ, k);
      out.handL.lerp(_rollB.handL, k);
      out.twoHand = 0;
      return;
    }
    if (dt < DRAW_OUT) {
      // Pull straight out of the scabbard's mouth, then forward.
      this.handAtSaya(out, 0.34 * ease.out((dt - DRAW_GRAB) / (DRAW_OUT - DRAW_GRAB)));
      return;
    }
    // Swing the freed blade up into the guard.
    copyPose(_rollB, out);
    this.handAtSaya(_rollB, 0.34);
    const stance = A.stanceFor(this.grip);
    const k = ease.inOut(Math.min(1, (dt - DRAW_OUT) / DRAW_GUARD));
    out.handR.lerpVectors(_rollB.handR, stance.handR, k);
    out.handRQ.slerpQuaternions(_rollB.handRQ, stance.handRQ, k);
    out.handL.lerpVectors(_rollB.handL, stance.handL, k);
    out.twoHand = stance.twoHand * k;
  }

  /** Time on the roll's own clock (0 at the start of the dive). */
  private get rollT(): number {
    return this.stateTime;
  }

  /**
   * Keep the tumbling body on the floor and out of furniture: rest its lowest point
   * on the ground mid-roll (never below it), and if any part pushes into a solid,
   * back the whole body out and stop rolling forward.
   */
  private rollContact(): void {
    const root = this.rig.root;
    const floor = this.body.pos.y;
    let minY = Infinity;
    this.rig.forEachCorner((p) => {
      if (p.y < minY) minY = p.y;
    });
    // Airborne through the dive (only kept above the floor), then rolling on the
    // floor from the shoulder down the back to the hip.
    const spin = this.rollAngle;
    const resting = spin > Math.PI * 0.8 || this.rollT >= ROLL_SPIN_END;
    const dy = floor + 0.004 - minY;
    const lift = resting ? dy : Math.max(0, dy);
    if (Math.abs(lift) > 1e-4) {
      root.position.y += lift;
      root.updateMatrixWorld(true);
    }
    this.pushOutOfSolids(floor, true);
  }

  /**
   * If any corner of the body or weapon is inside furniture or a wall, slide the
   * whole samurai out the shortest way. Hitting something ahead stops a roll.
   */
  private pushOutOfSolids(floor: number, rolling: boolean): void {
    const root = this.rig.root;
    const p0 = this.body.pos;
    const near = this.host.world.solids.filter(
      (s) => s.maxX > p0.x - 1.8 && s.minX < p0.x + 1.8 && s.maxZ > p0.z - 1.8 && s.minZ < p0.z + 1.8 && s.maxY > floor + 0.02 && s.minY < floor + 1.3,
    );
    if (!near.length) return;
    let pushX = 0;
    let pushZ = 0;
    this.rig.forEachCorner((p) => {
      for (const s of near) {
        if (p.x <= s.minX || p.x >= s.maxX || p.z <= s.minZ || p.z >= s.maxZ || p.y <= s.minY || p.y >= s.maxY) continue;
        // Back out through the face on the samurai's own side of the box, never through
        // the far side (that would carry the whole body into a wall).
        const out: [number, number][] = [];
        if (p0.x <= s.minX) out.push([s.minX - p.x, 0]);
        if (p0.x >= s.maxX) out.push([s.maxX - p.x, 0]);
        if (p0.z <= s.minZ) out.push([0, s.minZ - p.z]);
        if (p0.z >= s.maxZ) out.push([0, s.maxZ - p.z]);
        if (!out.length) continue;
        let best = out[0]!;
        for (const o of out) if (Math.abs(o[0]) + Math.abs(o[1]) < Math.abs(best[0]) + Math.abs(best[1])) best = o;
        if (Math.abs(best[0]) > Math.abs(pushX)) pushX = best[0];
        if (Math.abs(best[1]) > Math.abs(pushZ)) pushZ = best[1];
      }
    });
    if (!pushX && !pushZ) return;
    const sx = pushX + Math.sign(pushX) * 0.004;
    const sz = pushZ + Math.sign(pushZ) * 0.004;
    root.position.x += sx;
    root.position.z += sz;
    p0.x += sx;
    p0.z += sz;
    root.updateMatrixWorld(true);
    if (rolling && sx * this.rollDir.x + sz * this.rollDir.z < -0.01) this.rollBlocked = true;
  }

  /** Lift-only floor clamp used while blending out of a roll. */
  private keepAboveFloor(): void {
    let minY = Infinity;
    this.rig.forEachCorner((p) => {
      if (p.y < minY) minY = p.y;
    });
    const lift = this.body.pos.y + 0.002 - minY;
    if (lift > 0) {
      this.rig.root.position.y += lift;
      this.rig.root.updateMatrixWorld(true);
    }
  }

  /** Lift a household object over the head. */
  carry(def: ThrowDef): void {
    this.carrying = def;
    this.carryLift = 0;
    this.rig.setCarry(def.build(), Math.min(def.radius, 0.14) + 0.03);
    this.rig.stow(true);
  }

  /** Let go of the carried object; the game decides whether it flies or drops. */
  releaseCarry(): ThrowDef | null {
    const def = this.carrying;
    this.carrying = null;
    this.rig.setCarry(null);
    this.rig.carryHold = 0;
    if (this.state !== 'hang' && this.state !== 'climb' && this.state !== 'throw') this.rig.stow(false);
    return def;
  }

  /** World position of the bottom of the carried object. */
  carryPos(out: THREE.Vector3): THREE.Vector3 {
    this.rig.carryMount.updateMatrixWorld(true);
    return out.setFromMatrixPosition(this.rig.carryMount.matrixWorld);
  }

  private startThrow(): void {
    this.attack = null;
    this.thrown = false;
    this.flicking = !!this.carrying?.flick;
    const target = this.host.throwTarget(this);
    if (target) this.facing = Math.atan2(target.pos.x - this.pos.x, target.pos.z - this.pos.z);
    this.setState('throw', 0.06);
  }

  private updateThrow(dt: number, c: Controls): void {
    this.friction(dt, this.body.onGround ? 8 : 0.5);
    if (Math.hypot(c.moveX, c.moveZ) > 0.2 && !this.thrown) this.turnToward(Math.atan2(c.moveX, c.moveZ), 5, dt);
    if (!this.thrown && this.stateTime >= (this.flicking ? A.FLICK_RELEASE : A.THROW_RELEASE)) {
      this.thrown = true;
      // A rubber band flies off the thumb of the outstretched hand.
      const from = this.flicking ? this.rig.handL.getWorldPosition(new THREE.Vector3()) : this.carryPos(new THREE.Vector3());
      const def = this.releaseCarry();
      if (def) {
        this.host.throwItem(this, def, from);
        sfx.sfxSwish(0.6 + def.weight * 0.3);
      }
    }
    if (this.stateTime >= (this.flicking ? A.FLICK_TIME : THROW_TIME)) {
      this.flicking = false;
      this.rig.stow(false);
      this.setState(this.body.onGround ? 'move' : 'air', 0.2);
    }
  }

  private startAttack(def: AttackDef): void {
    this.attack = def;
    this.struck.clear();
    this.lungeDone = false;
    this.lastSwish = false;
    this.setState('attack', def.kind === 'airLand' ? 0.03 : 0.07);
    // Soft lock: snap toward a nearby foe so 3D swings connect.
    const enemy = this.host.nearestEnemy(this, 3.2);
    if (enemy) {
      const ang = Math.atan2(enemy.pos.x - this.pos.x, enemy.pos.z - this.pos.z);
      let diff = Math.atan2(Math.sin(ang - this.facing), Math.cos(ang - this.facing));
      if (Math.abs(diff) < 2.2) this.facing += diff;
      diff = 0;
    }
  }

  get attackRate(): number {
    return this.weapon ? this.weapon.def.speed : 1.15;
  }

  /** Clip time of the current attack. */
  get attackTime(): number {
    return this.stateTime * this.attackRate;
  }

  get striking(): boolean {
    const a = this.attack;
    if (this.state !== 'attack' || !a) return false;
    const t = this.attackTime;
    return t >= a.active[0] && t <= a.active[1];
  }

  private updateAttack(dt: number, c: Controls): void {
    const a = this.attack!;
    const body = this.body;
    const t = this.attackTime;
    if (a.kind === 'air') {
      // Plunge: hang briefly, then dive.
      if (t > 0.1) body.vel.y = Math.min(body.vel.y, -14);
      else body.vel.y = Math.max(body.vel.y, 1.5);
      this.friction(dt, 3);
      return;
    }
    // Lunge into the strike, but don't bulldoze a foe who is already in reach.
    if (!this.lungeDone && t >= a.active[0] - 0.05) {
      this.lungeDone = true;
      const enemy = this.host.nearestEnemy(this, 1.2);
      const f = this.forward(_v);
      const push = enemy && enemy.pos.distanceTo(this.pos) < 0.75 ? a.lunge * 0.25 : a.lunge;
      body.vel.x = f.x * push + body.vel.x * 0.3;
      body.vel.z = f.z * push + body.vel.z * 0.3;
    }
    if (!this.lastSwish && t >= a.active[0]) {
      this.lastSwish = true;
      sfx.sfxSwish(a.kind === 'heavy' ? 1 : a.kind === 'light' ? 0.5 : 0.75);
    }
    this.friction(dt, t > a.active[1] ? 9 : 4);
    // Steer while swinging: the movement keys turn the samurai so strikes can be aimed.
    if (Math.hypot(c.moveX, c.moveZ) > 0.2) this.turnToward(Math.atan2(c.moveX, c.moveZ), 7, dt);

    const end = a.clip.duration;
    if (t >= a.chain && this.buffered && a.kind !== 'airLand') {
      const kind = this.buffered;
      this.buffered = null;
      if (kind === 'light' && a.kind === 'light' && this.combo < this.moves.light.length - 1) {
        this.combo++;
        this.startAttack(this.moves.light[this.combo]!);
        return;
      }
      if (kind === 'heavy' && a.kind !== 'heavy') {
        this.startAttack(this.moves.heavy);
        return;
      }
      if (kind === 'light' && a.kind !== 'light') {
        this.combo = 0;
        this.startAttack(this.moves.light[0]!);
        return;
      }
    }
    if (t >= a.recover && c.roll && this.startRoll(c)) return;
    if (t >= a.recover) {
      const moving = Math.hypot(c.moveX, c.moveZ) > 0.2;
      if (c.block) {
        this.setState('block', 0.1);
        this.blockStart = this.time;
        return;
      }
      if (c.jump) {
        this.jump();
        return;
      }
      if (moving) {
        this.setState('move', 0.22);
        return;
      }
    }
    if (t >= end) {
      this.attack = null;
      this.setState('move', 0.25);
    }
  }

  private onLand(speed: number): void {
    const plunging = this.state === 'attack' && this.attack?.kind === 'air';
    this.landVel = -Math.min(2.2, speed * 0.14);
    if (plunging) {
      this.startAttack(this.moves.airLand);
      this.host.dust(this.pos, 14);
      sfx.sfxLand(true);
      return;
    }
    if (this.state === 'air') {
      // Too fast to just stand up (a long drop, or coming off the moving train): break the fall
      // with a roll the way the samurai is travelling, measured against what they land on.
      const body = this.body;
      const sv = this.host.surfaceVelocity(body.support, body.pos, _surfaceVel);
      const hx = body.vel.x + this.carryVel.x - sv.x;
      const hz = body.vel.z + this.carryVel.z - sv.z;
      const across = Math.hypot(hx, hz);
      if (speed > SAFETY_FALL || across > RUN + 0.05) {
        if (across > 0.6) this.rollDir.set(hx / across, 0, hz / across);
        else this.forward(this.rollDir);
        // Moving with the train, the roll's own speed is on top of the train's.
        body.vel.x -= this.carryVel.x;
        body.vel.z -= this.carryVel.z;
        this.beginRoll(Math.min(9, across), true);
        this.host.dust(this.pos, 8);
        return;
      }
      this.setState('move', 0.1);
      this.host.dust(this.pos, speed > 9 ? 6 : 3);
      sfx.sfxLand(speed > 11);
    }
    if (this.state === 'down' || this.state === 'ko') {
      if (speed > 3) {
        this.host.dust(this.pos, 8);
        sfx.sfxLand(true);
      }
    }
  }

  // ---- Being hit ----------------------------------------------------------------

  /** Can this fighter be struck right now? */
  get vulnerable(): boolean {
    return this.invulnerable <= 0 && this.state !== 'ko' && this.state !== 'down' && this.state !== 'victory';
  }

  get blocking(): boolean {
    return this.state === 'block';
  }

  /** Did the block start recently enough to count as a parry? */
  parryWindow(): boolean {
    return this.state === 'block' && this.time - this.blockStart < 0.18;
  }

  get hurtHeight(): number {
    if (this.state === 'down' || this.state === 'ko') return 0.3;
    if (this.state === 'slide' || this.state === 'roll') return 0.45;
    return this.body.height;
  }

  /** Clean hit: flinch, or be knocked flat. */
  /** Take a hit. Returns false when a thimble helmet or button shield stopped it. */
  hurt(damage: number, from: THREE.Vector3, knock: number, knockdown: boolean): boolean {
    const toward = _v2.subVectors(from, this.pos).setY(0);
    if (this.thimble) {
      this.loseThimble();
      this.host.popup(this, 'Thimble!', '#e6edf2');
      this.host.effect('guard', this.pos.clone().setY(this.pos.y + 1));
      this.body.vel.x -= toward.x * 0.5;
      this.body.vel.z -= toward.z * 0.5;
      return false;
    }

    // Getting hit makes you drop whatever you were holding up.
    if (this.carrying) this.host.dropCarry(this);
    this.health = Math.max(0, this.health - damage);
    this.hurtAgo = 0;
    const dir = _v2.subVectors(this.pos, from).setY(0);
    if (dir.lengthSq() < 1e-6) dir.copy(this.forward()).negate();
    dir.normalize();
    const side = Math.sign(dir.x * Math.cos(this.facing) - dir.z * Math.sin(this.facing)) || 1;
    this.ledge = null;
    if (this.health <= 0) {
      this.body.vel.set(dir.x * (knock + 2.5), 4.5, dir.z * (knock + 2.5));
      this.facing = Math.atan2(-dir.x, -dir.z);
      this.setState('ko', 0.05);
      this.downTime = 0;
      return true;
    }
    if (knockdown) {
      this.body.vel.set(dir.x * knock, 4, dir.z * knock);
      this.body.onGround = false;
      this.facing = Math.atan2(-dir.x, -dir.z);
      this.setState('down', 0.05);
      this.downTime = 0;
      return true;
    }
    this.body.vel.x = dir.x * knock;
    this.body.vel.z = dir.z * knock;
    if (this.state === 'hang' || this.state === 'climb') this.grabCooldown = 0.5;
    this.setState(this.body.onGround ? 'hit' : 'air', 0.04);
    this.clipOverride = this.flinchClip(side);
    return true;
  }

  /** Blocked a strike: slide back a little. */
  blocked(from: THREE.Vector3, push: number): void {
    const dir = _v2.subVectors(this.pos, from).setY(0).normalize();
    this.body.vel.x += dir.x * push;
    this.body.vel.z += dir.z * push;
    this.landVel = -0.5;
  }

  stagger(): void {
    this.attack = null;
    this.setState('stagger', 0.05);
  }

  disarmed(): void {
    this.attack = null;
    this.setState('disarmed', 0.04);
  }

  win(): void {
    if (this.state === 'ko') return;
    this.attack = null;
    this.setState('victory', 0.3);
  }

  private flinchClip(side: number): Clip {
    const key = `${this.grip}${side}`;
    let clip = this.flinchCache.get(key);
    if (!clip) {
      clip = A.FLINCH(A.stanceFor(this.grip), side);
      this.flinchCache.set(key, clip);
    }
    return clip;
  }

  private cached(key: string, make: () => Clip): Clip {
    const k = `${key}-${this.grip}`;
    let clip = this.clipCache.get(k);
    if (!clip) {
      clip = make();
      this.clipCache.set(k, clip);
    }
    return clip;
  }

  // ---- Pose ---------------------------------------------------------------------

  private computePose(dt: number): void {
    const out = this.target;
    const body = this.body;
    const stance = A.stanceFor(this.grip);
    switch (this.state) {
      case 'move':
      case 'land':
        this.walkPose(out, dt);
        if (this.carrying) A.carryOverlay(out, this.carrying.weight);
        break;
      case 'swim':
        A.swimPose(out, this.time, this.speed);
        break;
      case 'drive':
        A.drivePose(out, this.time);
        break;
      case 'air':
        A.airPose(out, body.vel.y, this.grip, this.time);
        if (this.carrying) A.carryOverlay(out, this.carrying.weight);
        break;
      case 'throw':
        (this.flicking ? A.FLICK : A.THROW).sample(this.stateTime, out);
        break;
      case 'roll':
        this.rollPoseNow(out);
        break;
      case 'hang':
      case 'climb': {
        const l = this.ledge;
        if (!l) break;
        // Ledge in fighter-local space: straight ahead at the edge's height.
        const ledgeY = l.point.y - body.pos.y;
        const dist = (body.pos.x - l.point.x) * l.normal.x + (body.pos.z - l.point.z) * l.normal.z;
        if (this.state === 'hang') A.hangPose(out, ledgeY, dist - 0.02, this.time, this.shimmy, this.shimmyPhase);
        else A.climbPose(out, Math.min(1, this.stateTime / CLIMB_TIME), ledgeY, dist - 0.02);
        break;
      }
      case 'attack':
        this.attack!.clip.sample(this.attackTime, out);
        break;
      case 'block':
        // Real steps under a raised guard (with a shield, the shield does the guarding).
        this.walkPose(out, dt);
        if (!this.button) A.blockOverlay(out, this.grip);
        out.hips.y += Math.sin(this.time * 3) * 0.004;
        break;
      case 'slide':
        copyPose(out, A.SLIDE);
        break;
      case 'hit':
        (this.clipOverride ?? this.flinchClip(1)).sample(this.stateTime, out);
        break;
      case 'stagger':
        this.cached('stagger', () => A.STAGGER(stance)).sample(this.stateTime, out);
        break;
      case 'disarmed':
        A.DISARMED.sample(this.stateTime, out);
        break;
      case 'down':
      case 'ko':
        A.KNOCKDOWN.sample(this.stateTime, out);
        break;
      case 'getup':
        this.cached('getup', () => A.GETUP(stance)).sample(this.stateTime * (0.6 / GETUP_TIME), out);
        break;
      case 'pickup':
        this.cached('pickup', () => A.PICKUP(stance)).sample(this.stateTime, out);
        break;
      case 'victory':
        A.VICTORY.sample(this.stateTime, out);
        break;
    }
  }

  /** Gait-driven walk/run/crouch pose (feet planted, phase advanced by distance). */
  private walkPose(out: Pose, dt: number): void {
    const body = this.body;
    const cosF = Math.cos(this.facing);
    const sinF = Math.sin(this.facing);
    // World velocity into fighter-local axes (+X is the fighter's left).
    const lx = body.vel.x * cosF - body.vel.z * sinF;
    const lz = body.vel.x * sinF + body.vel.z * cosF;
    const len = A.cycleLength(this.speed);
    this.phase = (this.phase + (this.speed * dt) / len) % 1;
    const before = this.phase - (this.speed * dt) / len;
    if (this.speed > 0.4 && (Math.floor(before * 2) !== Math.floor(this.phase * 2) || before < 0)) {
      sfx.sfxStep(Math.min(1, this.speed / RUN) * this.host.hearing(this.pos));
      if (this.speed > 3.5) this.host.dust(this.pos, 1);
    }
    A.locomotion(out, {
      phase: this.phase,
      speed: this.speed,
      crouch: this.crouchAmt,
      lean: Math.max(-1, Math.min(1, this.lean)),
      moveX: lx,
      moveZ: lz,
      time: this.time,
      grip: this.carrying ? 'fists' : this.grip,
      sprint: this.grip === 'katana' && !this.carrying ? this.sprintAmt : 0,
    });
  }

  /** Place the carried object: heaved up from the chest, pulled back during a throw's wind-up. */
  private placeCarry(dt: number): void {
    // While flicking, the band is stretched between the hands instead (see placeBand).
    this.rig.carryMount.visible = !this.flicking;
    if (!this.carrying) return;
    this.carryLift = Math.min(1, this.carryLift + dt / LIFT_TIME);
    const lift = ease.inOut(this.carryLift);
    const back = this.state === 'throw' ? ease.out(Math.min(1, this.stateTime / 0.18)) : 0;
    this.rig.carryMount.position.set(0, 0.08 + 0.32 * lift - 0.05 * back, 0.2 * (1 - lift) - 0.16 * back);
    this.rig.carryHold = 1;
  }

  /** Build this frame's pose and drive the skeleton. Call after update(). */
  animate(dt: number): void {
    const frozen = this.freeze > 0;
    const step = frozen ? 0 : dt;
    if (!frozen) this.computePose(dt);
    if (!frozen) {
      if (this.blendT < 1) {
        this.blendT = Math.min(1, this.blendT + dt / this.blendDur);
        blendPose(this.display, this.snapshot, this.target, ease.out(this.blendT));
      } else copyPose(this.display, this.target);
    }
    // Landing squash on top of everything.
    const shown = _pose;
    copyPose(shown, this.display);
    shown.hips.y += Math.max(-0.12, Math.min(0.05, this.landDip));
    shown.handR.y += Math.max(-0.12, Math.min(0.05, this.landDip)) * 0.4;
    shown.handL.y += Math.max(-0.12, Math.min(0.05, this.landDip)) * 0.4;

    const root = this.rig.root;
    root.position.copy(this.body.pos);
    if (frozen && this.shake > 0) {
      root.position.x += (Math.random() - 0.5) * 0.04;
      root.position.z += (Math.random() - 0.5) * 0.04;
    }
    if (this.state === 'roll') {
      // Shoulder roll: tilt and turn the body, then spin it forward about a level axis
      // square to the direction of travel, pivoting around the middle of the body.
      const base = this.rollBase(_rollQ);
      const axis = _rollAxis.set(Math.cos(this.facing), 0, -Math.sin(this.facing));
      const spinQ = _rollQ2.setFromAxisAngle(axis, this.rollAngle);
      root.quaternion.copy(spinQ).multiply(base);
      // The body's middle lifts through the dive, then comes down onto the shoulder.
      const spinAngle = this.rollAngle;
      const dive = spinAngle < Math.PI ? Math.sin(spinAngle) ** 0.8 * 0.42 : 0;
      const pivot = _rollPivot.set(0, A.ROLL_AXIS, 0).applyQuaternion(root.quaternion);
      root.position.x -= pivot.x;
      root.position.y += A.ROLL_AXIS + dive - pivot.y;
      root.position.z -= pivot.z;
      this.rig.sayaAxis.copy(this.rollAxisLocal(_rollAxis2));
    } else {
      root.rotation.set(0, this.facing, 0);
    }
    this.prevBase.copy(this.bladeBase);
    this.prevTip.copy(this.bladeTip);
    if (!frozen) {
      this.placeCarry(dt);
      // Tuck the scabbard along the spin axis for rolls; it settles back on the hip afterwards.
      // Tucked along the spin axis only while tumbling; back on the hip to sheathe and draw.
      const rt = this.rollT;
      const tuck = this.state === 'roll' && rt > -0.01 && rt < ROLL_SPIN_END - 0.06 ? 1 : 0;
      this.rig.sayaTuck += (tuck - this.rig.sayaTuck) * Math.min(1, dt * (tuck ? 40 : 10));
    }
    if (this.button && this.state !== 'roll' && this.state !== 'hang' && this.state !== 'climb' && this.state !== 'swim' && this.state !== 'throw' && this.state !== 'down' && this.state !== 'ko' && this.state !== 'getup' && !this.carrying) {
      // The left hand holds the shield: out at the side, or up in front while blocking.
      shown.twoHand = 0;
      if (this.state === 'block') {
        shown.handL.set(0.04, 0.6, 0.3);
        shown.chestQ.multiply(_shieldQc.setFromEuler(_shieldE.set(0, 0.25, 0)));
      } else if (this.state !== 'attack') shown.handL.set(0.2, 0.5, 0.12);
      shown.handLQ.copy(_shieldQd.setFromEuler(_shieldE.set(-Math.PI / 2, 0, 0)));
      shown.elbowL.set(1, -0.4, -0.3).normalize();
    }
    this.rig.apply(shown, step, this.host.wind);
    this.placeShield(dt);
    this.placeBand();
    if (!frozen) this.swingFlail(dt);
    if (this.state === 'roll' && !frozen) this.rollContact();
    else if (this.blendT < 1 && this.rolledRecently) {
      this.keepAboveFloor();
      this.pushOutOfSolids(this.body.pos.y, false);
    }
    this.updateStrikePoints();
  }

  private updateStrikePoints(): void {
    const a = this.attack;
    if (this.state === 'attack' && a?.limb) {
      if (a.limb === 'footR') this.rig.footWorld(true, this.bladeTip);
      else this.rig.fist(a.limb === 'fistR', this.bladeTip);
      this.bladeBase.copy(this.bladeTip);
    } else if (this.weapon?.def.flail && this.flailReady) {
      this.bladeBase.copy(this.flailHinge);
      this.bladeTip.copy(this.flailTip);
    } else if (this.weapon) {
      this.rig.bladeSegment(this.weapon.def.length, this.bladeBase, this.bladeTip);
    } else {
      this.rig.fist(true, this.bladeTip);
      this.bladeBase.copy(this.bladeTip);
    }
  }

  // ---- Rubber band and nunchaku --------------------------------------------------------

  private flicking = false;
  private band: THREE.Mesh | null = null;

  /** While drawing a rubber band back, show it stretched between the two hands. */
  private placeBand(): void {
    const show = this.state === 'throw' && this.flicking && !this.thrown && this.stateTime > 0.08;
    if (!show) {
      if (this.band) this.band.visible = false;
      return;
    }
    if (!this.band) {
      this.band = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0xe86f4a, roughness: 0.9 }));
      this.rig.root.parent?.add(this.band);
    }
    const a = this.rig.handL.getWorldPosition(_bandA);
    const b = this.rig.handR.getWorldPosition(_bandB);
    const len = a.distanceTo(b);
    this.band.visible = true;
    this.band.position.addVectors(a, b).multiplyScalar(0.5);
    this.band.lookAt(b);
    this.band.scale.set(0.05, 0.02, Math.max(0.05, len));
  }

  private flailReady = false;
  private readonly flailHinge = new THREE.Vector3();
  private readonly flailTip = new THREE.Vector3();
  private readonly flailPrev = new THREE.Vector3();

  /**
   * The nunchaku's loose stick hangs from its cord and swings with the handle's motion:
   * a little rope physics on its tip, then the stick is turned to point at it.
   */
  private swingFlail(dt: number): void {
    const part = this.weapon?.def.flail ? this.rig.weaponPart('flail') : undefined;
    if (!part || !part.parent || dt <= 0) {
      this.flailReady = false;
      return;
    }
    const parent = part.parent;
    parent.updateMatrixWorld(true);
    const hinge = this.flailHinge.copy(part.position).applyMatrix4(parent.matrixWorld);
    const L = 0.5 * parent.getWorldScale(_flailS).y;
    if (!this.flailReady) {
      this.flailTip.copy(_flailD.set(0, 1, 0).transformDirection(parent.matrixWorld)).multiplyScalar(L).add(hinge);
      this.flailPrev.copy(this.flailTip);
      this.flailReady = true;
    }
    // Keep going the way it was going, a little damped, and fall under gravity.
    const vx = (this.flailTip.x - this.flailPrev.x) * 0.97;
    const vy = (this.flailTip.y - this.flailPrev.y) * 0.97;
    const vz = (this.flailTip.z - this.flailPrev.z) * 0.97;
    this.flailPrev.copy(this.flailTip);
    this.flailTip.x += vx;
    this.flailTip.y += vy - 14 * dt * dt;
    this.flailTip.z += vz;
    // The cord holds it at the stick's length from the hinge.
    const dir = _flailD.subVectors(this.flailTip, hinge);
    if (dir.lengthSq() < 1e-8) dir.set(0, -1, 0);
    dir.normalize();
    this.flailTip.copy(hinge).addScaledVector(dir, L);
    // Point the stick (its +Y) along the cord, in its parent's frame.
    const inv = parent.getWorldQuaternion(_flailQ).invert();
    part.quaternion.setFromUnitVectors(_flailUp, dir.applyQuaternion(inv));
  }

  /** Top of the head in world space (labels, camera). */
  headPos(out: THREE.Vector3): THREE.Vector3 {
    return out.set(0, 0.32, 0).applyMatrix4(this.rig.head.matrixWorld);
  }

  /** Surface the fighter stands on (AI navigation). */
  get support(): Solid | null {
    return this.body.onGround ? this.body.support : null;
  }

  get hanging(): boolean {
    return this.state === 'hang';
  }

  get canGrabHeight(): number {
    return GRAB_MAX;
  }
}

const _pose = emptyPose();
const _rollA = emptyPose();
const _rollB = emptyPose();
const _q = new THREE.Quaternion();
/** Draw timing after the tumble (seconds into the rise). */
const DRAW_GRAB = 0.09;
const DRAW_OUT = 0.2;
const DRAW_GUARD = 0.18;
const _rollEuler = new THREE.Euler();
const _rollQ = new THREE.Quaternion();
const _rollQ2 = new THREE.Quaternion();
const _rollAxis = new THREE.Vector3();
const _rollAxis2 = new THREE.Vector3();
const _rollPivot = new THREE.Vector3();
const rollExits = new Map<A.Grip, Pose>();

/** Low guard at the end of a roll, built once per grip. */
function rollExitFor(g: A.Grip): Pose {
  let pose = rollExits.get(g);
  if (!pose) rollExits.set(g, (pose = A.rollExitPose(g)));
  return pose;
}