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/rig.ts
import * as THREE from 'three';
import { REST_HIPS_Y, type Pose } from './pose.js';

/** Colours for one samurai. Team A wears warm lacquer, team B cool. */
export interface Livery {
  armor: number;
  trim: number;
  cloth: number;
  ribbon: number;
}

const mats = new Map<string, THREE.MeshStandardMaterial>();
function mat(color: number, rough = 0.75, metal = 0): THREE.MeshStandardMaterial {
  const key = `${color}-${rough}-${metal}`;
  let m = mats.get(key);
  if (!m) {
    m = new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal, flatShading: true });
    mats.set(key, m);
  }
  return m;
}

function mesh(geo: THREE.BufferGeometry, material: THREE.Material, x = 0, y = 0, z = 0): THREE.Mesh {
  const m = new THREE.Mesh(geo, material);
  m.position.set(x, y, z);
  m.castShadow = true;
  m.receiveShadow = true;
  return m;
}

const box = (w: number, h: number, d: number) => new THREE.BoxGeometry(w, h, d);
const SKIN = 0xf2c7a0;
const LACQUER = 0x1d1a1f;
const GOLD = 0xe3b23c;

const UPPER_ARM = 0.165;
const FOREARM = 0.155;
const THIGH = 0.2;
const SHIN = 0.195;

/** Spring-driven chain (helmet ribbons) simulated in world space. */
class Ribbon {
  readonly points: THREE.Vector3[] = [];
  private readonly prev: THREE.Vector3[] = [];
  readonly segs: THREE.Mesh[] = [];
  private readonly anchor = new THREE.Vector3();

  constructor(
    private readonly parent: THREE.Object3D,
    private readonly local: THREE.Vector3,
    private readonly segLen: number,
    count: number,
    material: THREE.Material,
    scene: THREE.Object3D,
  ) {
    for (let i = 0; i <= count; i++) {
      this.points.push(new THREE.Vector3());
      this.prev.push(new THREE.Vector3());
    }
    for (let i = 0; i < count; i++) {
      const seg = mesh(box(0.045 - i * 0.008, segLen, 0.012), material);
      seg.geometry.translate(0, -segLen / 2, 0);
      scene.add(seg);
      this.segs.push(seg);
    }
  }

  reset(): void {
    this.anchor.copy(this.local);
    this.parent.localToWorld(this.anchor);
    this.points.forEach((p, i) => {
      p.copy(this.anchor).y -= i * this.segLen;
      this.prev[i]!.copy(p);
    });
  }

  update(dt: number, wind: THREE.Vector3): void {
    this.anchor.copy(this.local);
    this.parent.localToWorld(this.anchor);
    const pts = this.points;
    pts[0]!.copy(this.anchor);
    this.prev[0]!.copy(this.anchor);
    const damp = Math.pow(0.04, dt);
    for (let i = 1; i < pts.length; i++) {
      const p = pts[i]!;
      const pr = this.prev[i]!;
      const vx = (p.x - pr.x) * damp;
      const vy = (p.y - pr.y) * damp;
      const vz = (p.z - pr.z) * damp;
      pr.copy(p);
      p.x += vx + wind.x * dt * dt;
      p.y += vy - 9 * dt * dt;
      p.z += vz + wind.z * dt * dt;
    }
    for (let iter = 0; iter < 3; iter++) {
      for (let i = 1; i < pts.length; i++) {
        const a = pts[i - 1]!;
        const b = pts[i]!;
        const d = b.distanceTo(a) || 1e-5;
        const k = (d - this.segLen) / d;
        b.x -= (b.x - a.x) * k;
        b.y -= (b.y - a.y) * k;
        b.z -= (b.z - a.z) * k;
      }
    }
    for (let i = 0; i < this.segs.length; i++) {
      const seg = this.segs[i]!;
      seg.position.copy(pts[i]!);
      _dir.subVectors(pts[i]!, pts[i + 1]!).normalize();
      seg.quaternion.setFromUnitVectors(UP, _dir);
    }
  }

  setVisible(v: boolean): void {
    for (const s of this.segs) s.visible = v;
  }

  dispose(): void {
    for (const s of this.segs) s.removeFromParent();
  }
}

const UP = new THREE.Vector3(0, 1, 0);
const _dir = new THREE.Vector3();
const _s = new THREE.Vector3();
const _e = new THREE.Vector3();
const _t = new THREE.Vector3();
const _n = new THREE.Vector3();
const _p = new THREE.Vector3();
const _bx = new THREE.Vector3();
const _by = new THREE.Vector3();
const _bz = new THREE.Vector3();
const _m4 = new THREE.Matrix4();
const _q = new THREE.Quaternion();
const _q2 = new THREE.Quaternion();
const _rootQ = new THREE.Quaternion();
const _hipsShift = new THREE.Vector3();
const _corner = new THREE.Vector3();
const _identityQ = new THREE.Quaternion();

/** Orient `bone` so its -Y axis runs from `from` to `to`, twisting its +Z toward `pole`. */
function aimBone(bone: THREE.Object3D, from: THREE.Vector3, to: THREE.Vector3, pole: THREE.Vector3): void {
  _by.subVectors(from, to).normalize();
  _bz.copy(pole).addScaledVector(_by, -pole.dot(_by));
  if (_bz.lengthSq() < 1e-6) _bz.set(0, 0, 1).addScaledVector(_by, -_by.z);
  _bz.normalize();
  _bx.crossVectors(_by, _bz);
  _m4.makeBasis(_bx, _by, _bz);
  _q.setFromRotationMatrix(_m4);
  bone.parent!.getWorldQuaternion(_q2);
  bone.quaternion.copy(_q2.invert().multiply(_q));
  bone.updateMatrixWorld(true);
}

/** Classic two-bone IK in world space. */
function solveLimb(
  upper: THREE.Object3D,
  lower: THREE.Object3D,
  lenA: number,
  lenB: number,
  target: THREE.Vector3,
  pole: THREE.Vector3,
): void {
  upper.getWorldPosition(_s);
  _n.subVectors(target, _s);
  let d = _n.length();
  const scale = upper.matrixWorld.getMaxScaleOnAxis();
  const a = lenA * scale;
  const b = lenB * scale;
  d = Math.min(Math.max(d, Math.abs(a - b) + 1e-4), a + b - 1e-4);
  _n.normalize();
  const cosA = (a * a + d * d - b * b) / (2 * a * d);
  const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
  _p.copy(pole).addScaledVector(_n, -pole.dot(_n));
  if (_p.lengthSq() < 1e-8) _p.set(0, -1, 0).addScaledVector(_n, _n.y);
  _p.normalize();
  _e.copy(_s).addScaledVector(_n, a * cosA).addScaledVector(_p, a * sinA);
  _t.copy(_s).addScaledVector(_n, d);
  aimBone(upper, _s, _e, _p);
  aimBone(lower, _e, _t, _p);
}

export class Rig {
  readonly root = new THREE.Group();
  readonly hips = new THREE.Group();
  readonly spine = new THREE.Group();
  readonly chest = new THREE.Group();
  readonly head = new THREE.Group();
  readonly upperArmR = new THREE.Group();
  readonly forearmR = new THREE.Group();
  readonly handR = new THREE.Group();
  readonly upperArmL = new THREE.Group();
  readonly forearmL = new THREE.Group();
  readonly handL = new THREE.Group();
  readonly thighL = new THREE.Group();
  readonly shinL = new THREE.Group();
  readonly footL = new THREE.Group();
  readonly thighR = new THREE.Group();
  readonly shinR = new THREE.Group();
  readonly footR = new THREE.Group();
  /** Item held in the right hand (origin = grip point). */
  readonly weaponMount = new THREE.Group();
  /** Slung across the back while climbing. */
  private readonly backMount = new THREE.Group();
  private stowed = false;
  /** Katana sheathed in the scabbard / other item tucked in the belt (for rolls). */
  private readonly sheathMount = new THREE.Group();
  private readonly beltMount = new THREE.Group();
  private sheathed: 'saya' | 'belt' | null = null;
  /** Holds an object lifted over the head (object origin = its bottom centre). Positioned by the fighter. */
  readonly carryMount = new THREE.Group();
  private carried: THREE.Object3D | null = null;
  /** Half the distance between the hands gripping the carried object. */
  private carryGrip = 0.14;
  /** 0..1: how firmly the hands are clamped onto the carried object. */
  carryHold = 0;
  /** 0..1: swing the scabbard sideways along the hips (rolls), so it spins in place instead of hitting the floor. */
  sayaTuck = 0;
  /** Body-space direction the tucked scabbard should lie along (the roll's spin axis). */
  readonly sayaAxis = new THREE.Vector3(1, 0, 0);
  private readonly plates: { pivot: THREE.Group; side: number; front: number }[] = [];
  private readonly saya = new THREE.Group();
  private readonly ribbons: Ribbon[] = [];
  private weapon: THREE.Object3D | null = null;
  /** Grip offset of the off hand along the held item (negative = toward the pommel). */
  offGrip = -0.12;
  private sayaSwing = 0;
  private sayaVel = 0;
  private readonly lastHips = new THREE.Vector3();
  readonly bodyMeshes: THREE.Mesh[] = [];

  constructor(livery: Livery, scene: THREE.Object3D) {
    const armor = mat(livery.armor, 0.45, 0.1);
    const trim = mat(livery.trim, 0.35, 0.5);
    const cloth = mat(livery.cloth, 0.95);
    const lacquer = mat(LACQUER, 0.4, 0.1);
    const skin = mat(SKIN, 0.8);
    const wrap = mat(0xe8e0cc, 0.95);
    const gold = mat(GOLD, 0.35, 0.6);
    const ribbonMat = mat(livery.ribbon, 0.9);
    ribbonMat.side = THREE.DoubleSide;

    const { root, hips, spine, chest, head } = this;
    root.add(hips);
    hips.position.set(0, REST_HIPS_Y, 0);
    hips.add(spine);
    spine.position.set(0, 0.06, 0);
    spine.add(chest);
    chest.position.set(0, 0.12, 0);
    chest.add(head);
    head.position.set(0, 0.13, 0.005);

    // Hips: obi sash, knot and the kusazuri skirt plates.
    hips.add(mesh(new THREE.CylinderGeometry(0.125, 0.13, 0.065, 8), cloth, 0, 0.035, 0));
    hips.add(mesh(new THREE.CylinderGeometry(0.128, 0.128, 0.02, 8), ribbonMat, 0, 0.05, 0));
    for (let i = 0; i < 6; i++) {
      const angle = (i / 6) * Math.PI * 2 + Math.PI / 6;
      const pivot = new THREE.Group();
      pivot.position.set(Math.sin(angle) * 0.12, 0.01, Math.cos(angle) * 0.12);
      pivot.rotation.y = angle;
      const plate = mesh(box(0.1, 0.14, 0.018), i % 2 ? armor : lacquer, 0, -0.07, 0.01);
      plate.add(mesh(box(0.1, 0.012, 0.02), trim, 0, -0.065, 0.002));
      pivot.add(plate);
      hips.add(pivot);
      this.plates.push({ pivot, side: Math.sin(angle), front: Math.cos(angle) });
    }

    // Scabbard on the left hip.
    this.saya.position.set(0.13, 0.03, 0.02);
    hips.add(this.saya);
    // Long enough to hold the katana's 1.05 blade (mouth at y = 0.1, tip cap at -1.0).
    const sayaBody = mesh(box(0.034, 1.1, 0.055), lacquer, 0, 0, 0);
    sayaBody.geometry.translate(0, -0.45, 0);
    this.saya.add(sayaBody);
    this.saya.add(mesh(box(0.042, 0.03, 0.065), gold, 0, 0.09, 0));
    this.saya.add(mesh(box(0.04, 0.04, 0.06), gold, 0, -0.99, 0));
    // Sheathed katana: grip just out of the mouth, blade pointing down into the scabbard.
    this.saya.add(this.sheathMount);
    this.sheathMount.position.set(0, 0.115, 0);
    this.sheathMount.rotation.set(Math.PI, 0, 0);
    // Anything else is tucked into the belt beside the scabbard, on the left side.
    this.saya.add(this.beltMount);
    this.beltMount.position.set(0.055, 0.08, 0.015);
    this.beltMount.rotation.set(Math.PI, 0, 0);
    this.saya.rotation.set(1.2, 0, 0.18);

    // Torso: lower body armour, dō breastplate with trim, collar.
    spine.add(mesh(box(0.22, 0.14, 0.16), cloth, 0, 0.05, 0));
    const dou = mesh(new THREE.CylinderGeometry(0.14, 0.12, 0.2, 7), armor, 0, 0.06, 0);
    dou.scale.set(1, 1, 0.78);
    chest.add(dou);
    chest.add(mesh(box(0.2, 0.018, 0.02), trim, 0, 0.1, 0.1));
    chest.add(mesh(box(0.2, 0.018, 0.02), trim, 0, 0.0, 0.105));
    chest.add(mesh(box(0.04, 0.04, 0.02), gold, 0, 0.05, 0.11));
    chest.add(mesh(new THREE.CylinderGeometry(0.06, 0.08, 0.04, 7), cloth, 0, 0.16, 0));

    // Head, face and kabuto.
    const skull = mesh(new THREE.IcosahedronGeometry(0.13, 1), skin, 0, 0.11, 0);
    skull.scale.set(1.02, 0.95, 0.95);
    head.add(skull);
    const eye = mat(0x16121a, 0.5);
    const eyeGeo = box(0.032, 0.05, 0.02);
    head.add(mesh(eyeGeo, eye, 0.047, 0.115, 0.118));
    head.add(mesh(eyeGeo, eye, -0.047, 0.115, 0.118));
    const brow = box(0.06, 0.016, 0.02);
    const browL = mesh(brow, eye, 0.05, 0.155, 0.112);
    browL.rotation.z = -0.35;
    const browR = mesh(brow, eye, -0.05, 0.155, 0.112);
    browR.rotation.z = 0.35;
    head.add(browL, browR);
    const menpo = mesh(box(0.15, 0.06, 0.05), mat(0x7a1f1a, 0.5), 0, 0.05, 0.1);
    menpo.rotation.x = 0.2;
    head.add(menpo);

    const helmet = new THREE.Group();
    helmet.position.set(0, 0.145, 0);
    head.add(helmet);
    helmet.add(mesh(new THREE.SphereGeometry(0.15, 9, 4, 0, Math.PI * 2, 0, Math.PI / 2), lacquer, 0, 0, -0.005));
    helmet.add(mesh(new THREE.CylinderGeometry(0.152, 0.152, 0.025, 9), armor, 0, 0.005, -0.005));
    const shikoro = mesh(
      new THREE.CylinderGeometry(0.155, 0.215, 0.11, 9, 1, true, Math.PI * 0.35, Math.PI * 1.3),
      armor,
      0,
      -0.05,
      -0.01,
    );
    (shikoro.material as THREE.Material).side = THREE.DoubleSide;
    helmet.add(shikoro);
    for (const side of [-1, 1]) {
      const flap = mesh(box(0.06, 0.06, 0.015), armor, side * 0.155, -0.005, 0.07);
      flap.rotation.y = side * 0.9;
      helmet.add(flap);
      const horn = mesh(box(0.022, 0.2, 0.012), gold, side * 0.06, 0.13, 0.11);
      horn.rotation.z = -side * 0.55;
      horn.rotation.x = -0.2;
      helmet.add(horn);
    }
    helmet.add(mesh(new THREE.OctahedronGeometry(0.03, 0), gold, 0, 0.05, 0.135));

    // Arms: sleeve, shoulder plate, forearm guard, fist.
    const buildArm = (side: number, upper: THREE.Group, fore: THREE.Group, hand: THREE.Group) => {
      chest.add(upper);
      upper.position.set(side * 0.145, 0.1, 0);
      upper.add(mesh(box(0.08, UPPER_ARM, 0.08), cloth, 0, -UPPER_ARM / 2, 0));
      const sode = mesh(box(0.025, 0.13, 0.12), armor, side * 0.05, -0.05, 0);
      sode.rotation.z = side * 0.25;
      sode.add(mesh(box(0.028, 0.015, 0.122), trim, 0, -0.05, 0));
      upper.add(sode);
      upper.add(fore);
      fore.position.set(0, -UPPER_ARM, 0);
      fore.add(mesh(box(0.068, FOREARM, 0.068), lacquer, 0, -FOREARM / 2, 0));
      fore.add(mesh(box(0.075, 0.03, 0.075), trim, 0, -FOREARM + 0.02, 0));
      fore.add(hand);
      hand.position.set(0, -FOREARM, 0);
      hand.add(mesh(box(0.07, 0.075, 0.075), skin, 0, -0.015, 0));
    };
    buildArm(-1, this.upperArmR, this.forearmR, this.handR);
    buildArm(1, this.upperArmL, this.forearmL, this.handL);
    this.handR.add(this.weaponMount);
    chest.add(this.backMount);
    chest.add(this.carryMount);
    // Slung blade-down across the back: handle up over the left shoulder, edge facing out.
    this.backMount.rotation.set(0, Math.PI, -0.75 * Math.PI);
    this.placeBackMount(1.05);
    this.weaponMount.position.set(0, -0.015, 0);

    // Legs: flared hakama, wrapped shins, tabi feet with straw sandals.
    const buildLeg = (side: number, thigh: THREE.Group, shin: THREE.Group, foot: THREE.Group) => {
      hips.add(thigh);
      thigh.position.set(side * 0.075, -0.015, 0);
      const pant = mesh(new THREE.CylinderGeometry(0.06, 0.088, THIGH + 0.04, 7), cloth, 0, -THIGH / 2 - 0.01, 0);
      thigh.add(pant);
      thigh.add(shin);
      shin.position.set(0, -THIGH, 0);
      shin.add(mesh(new THREE.CylinderGeometry(0.05, 0.04, SHIN, 6), wrap, 0, -SHIN / 2, 0));
      shin.add(mesh(new THREE.CylinderGeometry(0.052, 0.052, 0.02, 6), cloth, 0, -0.06, 0));
      shin.add(mesh(new THREE.CylinderGeometry(0.046, 0.046, 0.02, 6), cloth, 0, -0.13, 0));
      shin.add(foot);
      foot.position.set(0, -SHIN, 0);
      foot.add(mesh(box(0.075, 0.045, 0.14), wrap, 0, -0.012, 0.03));
      foot.add(mesh(box(0.085, 0.014, 0.15), mat(0xc9a25d, 0.95), 0, -0.04, 0.03));
    };
    buildLeg(1, this.thighL, this.shinL, this.footL);
    buildLeg(-1, this.thighR, this.shinR, this.footR);

    root.traverse((o) => {
      if (o instanceof THREE.Mesh) this.bodyMeshes.push(o);
    });

    // Helmet ribbons live in world space so they trail behind movement.
    this.ribbons.push(new Ribbon(helmet, new THREE.Vector3(0.03, 0.02, -0.15), 0.1, 3, ribbonMat, scene));
    this.ribbons.push(new Ribbon(helmet, new THREE.Vector3(-0.03, 0.02, -0.15), 0.12, 3, ribbonMat, scene));
  }

  setWeapon(obj: THREE.Object3D | null, offGrip: number, length = 1.05): void {
    this.weapon?.removeFromParent();
    this.weapon = obj;
    this.offGrip = offGrip;
    this.placeBackMount(length);
    this.weaponSettling = false;
    this.mountWeapon(false);
  }

  /** Put the held item where it belongs now: sheathed/belt, slung on the back, or in hand. */
  private mountWeapon(smooth = true): void {
    if (!this.weapon) return;
    const parent = this.sheathed === 'saya' ? this.sheathMount : this.sheathed === 'belt' ? this.beltMount : this.stowed ? this.backMount : this.weaponMount;
    if (this.weapon.parent === parent) return;
    if (smooth && this.weapon.parent) {
      // Hand-offs (hand ↔ scabbard, belt or back) keep the item exactly where it is
      // this frame, then it settles into its new place over a few frames: no popping.
      this.root.updateMatrixWorld(true);
      parent.attach(this.weapon);
      this.weaponSettling = true;
    } else {
      parent.add(this.weapon);
      this.weapon.position.set(0, 0, 0);
      this.weapon.quaternion.identity();
    }
  }

  private weaponSettling = false;

  /** Ease a just-handed-off item into its mount. */
  private settleWeapon(dt: number): void {
    if (!this.weaponSettling || !this.weapon) return;
    const k = 1 - Math.exp(-dt * 22);
    this.weapon.position.multiplyScalar(1 - k);
    this.weapon.quaternion.slerp(_identityQ, k);
    if (this.weapon.position.lengthSq() < 1e-8 && this.weapon.quaternion.angleTo(_identityQ) < 1e-3) {
      this.weapon.position.set(0, 0, 0);
      this.weapon.quaternion.identity();
      this.weaponSettling = false;
    }
  }

  /** Sheathe the katana / tuck another item in the belt (or draw it back into the hand). */
  sheathe(where: 'saya' | 'belt' | null): void {
    this.sheathed = where;
    this.mountWeapon();
  }

  get isSheathed(): boolean {
    return this.sheathed !== null;
  }

  /**
   * The scabbard's mouth, its pointing direction (into the scabbard) and the sheathed
   * blade's edge direction, in the fighter's root space (from the last posed frame).
   */
  sayaFrame(mouth: THREE.Vector3, dir: THREE.Vector3, edge: THREE.Vector3): void {
    this.root.updateMatrixWorld(true);
    mouth.set(0, 0.115, 0).applyMatrix4(this.saya.matrixWorld);
    this.root.worldToLocal(mouth);
    const q = this.saya.getWorldQuaternion(new THREE.Quaternion());
    const rootInv = this.root.getWorldQuaternion(new THREE.Quaternion()).invert();
    dir.set(0, -1, 0).applyQuaternion(q).applyQuaternion(rootInv);
    edge.set(0, 0, -1).applyQuaternion(q).applyQuaternion(rootInv);
  }

  /** Long items ride higher on the back so their tips don't drag on the floor. */
  private placeBackMount(length: number): void {
    const lift = Math.max(0, length - 1.05) * 0.7;
    this.backMount.position.set(0.71 * lift, 0.14 + 0.71 * lift, -0.14);
  }

  /** Put an object in (or take it out of) the overhead carry mount. */
  setCarry(obj: THREE.Object3D | null, grip = 0.14): void {
    this.carried?.removeFromParent();
    this.carried = obj;
    this.carryGrip = grip;
    if (obj) this.carryMount.add(obj);
  }

  /** Sling the held item across the back (hands free for climbing) or draw it again. */
  stow(on: boolean): void {
    if (on === this.stowed) return;
    this.stowed = on;
    this.mountWeapon();
  }

  get hasWeapon(): boolean {
    return this.weapon !== null;
  }

  /** Drop the ribbon simulation onto the current pose (after teleports). */
  resetSecondary(): void {
    this.root.updateMatrixWorld(true);
    for (const r of this.ribbons) r.reset();
    this.lastHips.setFromMatrixPosition(this.hips.matrixWorld);
  }

  setVisible(v: boolean): void {
    this.root.visible = v;
    for (const r of this.ribbons) r.setVisible(v);
  }

  dispose(): void {
    this.root.removeFromParent();
    for (const r of this.ribbons) r.dispose();
  }

  /** Pose the skeleton. `dt` drives the secondary motion. */
  apply(pose: Pose, dt: number, wind: THREE.Vector3): void {
    const { root } = this;
    this.hips.position.copy(pose.hips);
    this.hips.quaternion.copy(pose.hipsQ);
    this.spine.quaternion.copy(pose.spineQ);
    this.chest.quaternion.copy(pose.chestQ);
    this.head.quaternion.copy(pose.headQ);
    root.updateMatrixWorld(true);
    root.getWorldQuaternion(_rootQ);

    // Hands ride along with the hips so crouches and bobs carry the arms.
    _hipsShift.set(pose.hips.x, pose.hips.y - REST_HIPS_Y, pose.hips.z);

    // Legs.
    this.legIK(this.thighL, this.shinL, this.footL, pose.footL, pose.footLPitch, 0.15);
    this.legIK(this.thighR, this.shinR, this.footR, pose.footR, pose.footRPitch, -0.15);

    // Right arm.
    const tR = _t.copy(pose.handR).add(_hipsShift);
    root.localToWorld(tR);
    // Carrying: both hands grip the underside of the object held above the head.
    const carrying = this.carried !== null && this.carryHold > 0.001;
    const gripR = new THREE.Vector3();
    const gripL = new THREE.Vector3();
    if (carrying) {
      this.carryMount.updateMatrixWorld(true);
      const base = new THREE.Vector3().setFromMatrixPosition(this.carryMount.matrixWorld);
      const side = new THREE.Vector3(1, 0, 0).applyQuaternion(this.chest.getWorldQuaternion(new THREE.Quaternion()));
      gripR.copy(base).addScaledVector(side, -this.carryGrip).y += 0.03;
      gripL.copy(base).addScaledVector(side, this.carryGrip).y += 0.03;
      tR.lerp(gripR, this.carryHold);
    }
    const poleR = _dir.copy(pose.elbowR).applyQuaternion(_rootQ);
    solveLimb(this.upperArmR, this.forearmR, UPPER_ARM, FOREARM, tR, poleR);
    this.setHandWorld(this.handR, pose.handRQ);

    // Left arm: either its own target or clamped to the held item's handle.
    const tL = new THREE.Vector3().copy(pose.handL).add(_hipsShift);
    root.localToWorld(tL);
    const qL = _q.copy(pose.handLQ);
    if (pose.twoHand > 0.001 && this.weapon && !this.stowed && !this.sheathed) {
      this.handR.updateMatrixWorld(true);
      const grip = new THREE.Vector3(0, this.offGrip - 0.015, 0).applyMatrix4(this.handR.matrixWorld);
      tL.lerp(grip, pose.twoHand);
      qL.slerp(pose.handRQ, pose.twoHand);
    }
    if (carrying) tL.lerp(gripL, this.carryHold);
    const qLHold = qL.clone();
    const poleL = new THREE.Vector3().copy(pose.elbowL).applyQuaternion(_rootQ);
    solveLimb(this.upperArmL, this.forearmL, UPPER_ARM, FOREARM, tL, poleL);
    this.setHandWorld(this.handL, qLHold);

    this.secondary(pose, dt, wind);
    this.settleWeapon(dt);
  }

  private legIK(
    thigh: THREE.Group,
    shin: THREE.Group,
    foot: THREE.Group,
    target: THREE.Vector3,
    pitch: number,
    out: number,
  ): void {
    const t = new THREE.Vector3().copy(target);
    this.root.localToWorld(t);
    // Knees point forward and a touch outward, following the hips' facing.
    const pole = new THREE.Vector3(out, 0, 1).applyQuaternion(this.hips.getWorldQuaternion(_q2));
    solveLimb(thigh, shin, THIGH, SHIN, t, pole);
    // Feet stay flat to the fighter's facing except for their pitch.
    const q = _q.setFromEuler(new THREE.Euler(pitch, 0, 0));
    this.setHandWorld(foot, q);
  }

  /** Give `bone` an orientation expressed in the fighter's root space. */
  private setHandWorld(bone: THREE.Object3D, localQ: THREE.Quaternion): void {
    const world = new THREE.Quaternion().copy(_rootQ).multiply(localQ);
    bone.parent!.getWorldQuaternion(_q2);
    bone.quaternion.copy(_q2.invert().multiply(world));
    bone.updateMatrixWorld(true);
  }

  private secondary(pose: Pose, dt: number, wind: THREE.Vector3): void {
    // Skirt plates lift with the thigh beneath them.
    const tl = this.thighL.quaternion;
    const tr = this.thighR.quaternion;
    const swingL = new THREE.Euler().setFromQuaternion(tl, 'YXZ').x;
    const swingR = new THREE.Euler().setFromQuaternion(tr, 'YXZ').x;
    for (const plate of this.plates) {
      const leg = plate.side > 0.2 ? swingL : plate.side < -0.2 ? swingR : (swingL + swingR) / 2;
      const kick = plate.front > 0.3 ? Math.max(0, -leg) * 0.8 : plate.front < -0.3 ? Math.max(0, leg) * 0.6 : Math.abs(leg) * 0.3;
      plate.pivot.rotation.x = 0.18 + kick;
    }

    // Scabbard pendulum driven by hip acceleration.
    this.hips.updateMatrixWorld(true);
    const hipsNow = new THREE.Vector3().setFromMatrixPosition(this.hips.matrixWorld);
    if (dt > 0) {
      const vel = hipsNow.clone().sub(this.lastHips).divideScalar(dt);
      const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(_rootQ);
      const push = Math.max(-6, Math.min(6, vel.dot(fwd)));
      this.sayaVel += (-(this.sayaSwing - push * 0.05) * 60 - this.sayaVel * 8) * dt;
      this.sayaSwing += this.sayaVel * dt;
    }
    this.lastHips.copy(hipsNow);
    // Swings back freely but never dips far enough for the long scabbard to touch the floor.
    const hang = 1.2 + Math.max(-0.08, Math.min(0.5, this.sayaSwing));
    const k = this.sayaTuck;
    this.saya.rotation.set(hang, 0, 0.18);
    if (k > 0.001) {
      // Lay the scabbard (its -Y) along the spin axis so it turns in place.
      this.hips.updateMatrixWorld(true);
      const want = this.sayaAxis.clone().applyQuaternion(this.root.getWorldQuaternion(new THREE.Quaternion())).normalize();
      const world = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, -1, 0), want);
      const local = this.hips.getWorldQuaternion(new THREE.Quaternion()).invert().multiply(world);
      this.saya.quaternion.slerp(local, k);
    }
    void pose;

    for (const r of this.ribbons) r.update(Math.min(dt, 1 / 30), wind);
  }

  /**
   * Visit the world-space corners of every piece of the samurai (armour, limbs,
   * scabbard and held item, not the ribbons). Used to keep a rolling body out of
   * the floor and furniture. Call after apply().
   */
  forEachCorner(visit: (p: THREE.Vector3) => void): void {
    // Secondary motion (skirt plates, scabbard) moves after the IK pass; refresh first.
    this.root.updateMatrixWorld(true);
    const meshes: THREE.Mesh[] = [...this.bodyMeshes];
    this.weapon?.traverse((o) => {
      if (o instanceof THREE.Mesh) meshes.push(o);
    });
    for (const m of meshes) {
      if (!m.visible) continue;
      const geo = m.geometry;
      if (!geo.boundingBox) geo.computeBoundingBox();
      const b = geo.boundingBox!;
      for (let i = 0; i < 8; i++) {
        _corner.set(i & 1 ? b.max.x : b.min.x, i & 2 ? b.max.y : b.min.y, i & 4 ? b.max.z : b.min.z).applyMatrix4(m.matrixWorld);
        visit(_corner);
      }
    }
  }

  /** World-space base and tip of the held item. */
  bladeSegment(length: number, base: THREE.Vector3, tip: THREE.Vector3): void {
    // Follow the item itself (it may still be settling into the hand).
    const frame = this.weapon ?? this.weaponMount;
    frame.updateMatrixWorld(true);
    base.set(0, length * 0.25, 0).applyMatrix4(frame.matrixWorld);
    tip.set(0, length, 0).applyMatrix4(frame.matrixWorld);
  }

  /** A named part of the held item (the nunchaku's free stick). */
  weaponPart(name: string): THREE.Object3D | undefined {
    return this.weapon?.getObjectByName(name);
  }

  /** World-space fist positions for unarmed strikes. */
  fist(right: boolean, out: THREE.Vector3): THREE.Vector3 {
    const hand = right ? this.handR : this.handL;
    return out.set(0, -0.03, 0).applyMatrix4(hand.matrixWorld);
  }

  footWorld(right: boolean, out: THREE.Vector3): THREE.Vector3 {
    return out.set(0, -0.02, 0.06).applyMatrix4((right ? this.footR : this.footL).matrixWorld);
  }
}