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/anims.ts
import * as THREE from 'three';
import {
  Clip,
  REST_HIPS_Y,
  blendPose,
  copyPose,
  ease,
  emptyPose,
  grip,
  makePose,
  rot,
  swing,
  swingAxis,
  v,
  type Ease,
  type Key,
  type Pose,
  type PoseDraft,
} from './pose.js';

// Fighter-local space: +Z forward, +Y up, +X is the fighter's LEFT (so the right hand is at -X).

const d = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z).normalize();
const ELBOW_R_DOWN = d(-0.7, -1, -0.15);
const ELBOW_L_DOWN = d(0.7, -1, -0.15);
const ELBOW_R_OUT = d(-1, -0.2, -0.3);
const ELBOW_L_OUT = d(1, -0.2, -0.3);

/** Chudan-no-kamae: katana held two-handed at the belly, point at the opponent's throat. */
export const STANCE_KATANA: Pose = makePose(emptyPose(), {
  hips: v(0, 0.405, -0.01),
  hipsQ: rot(4, 14, 0),
  spineQ: rot(2, -6, 0),
  chestQ: rot(4, -8, 0),
  headQ: rot(-6, 0, 0),
  footR: v(-0.085, 0.035, 0.09),
  footL: v(0.095, 0.035, -0.09),
  handR: v(-0.02, 0.53, 0.18),
  handRQ: grip(d(0.04, 0.5, 0.86), d(0, -0.86, 0.5)),
  handL: v(0.04, 0.48, 0.09),
  twoHand: 1,
  elbowR: ELBOW_R_DOWN,
  elbowL: ELBOW_L_DOWN,
});

/** One-handed improvised weapon: item forward in the right hand, left fist up as a guard. */
export const STANCE_ONE: Pose = makePose(STANCE_KATANA, {
  handR: v(-0.08, 0.52, 0.17),
  handRQ: grip(d(0.1, 0.65, 0.75), d(0, -0.75, 0.65)),
  handL: v(0.1, 0.62, 0.14),
  handLQ: rot(-90, 0, 0),
  twoHand: 0,
});

/** Bare fists: boxer-ish guard. */
export const STANCE_FISTS: Pose = makePose(STANCE_KATANA, {
  hipsQ: rot(4, 20, 0),
  chestQ: rot(6, -10, 0),
  handR: v(-0.08, 0.62, 0.12),
  handRQ: rot(-80, 0, 0),
  handL: v(0.08, 0.64, 0.2),
  handLQ: rot(-80, 0, 0),
  twoHand: 0,
  elbowR: ELBOW_R_DOWN,
  elbowL: ELBOW_L_DOWN,
});

export type Grip = 'katana' | 'one' | 'fists';

export function stanceFor(g: Grip): Pose {
  return g === 'katana' ? STANCE_KATANA : g === 'one' ? STANCE_ONE : STANCE_FISTS;
}

// ---- Procedural locomotion ----------------------------------------------------

const _tmp = emptyPose();
const _tmp2 = emptyPose();

export interface LocoInput {
  /** Gait cycle phase 0..1, advanced by distance travelled. */
  phase: number;
  /** Ground speed in units/s. */
  speed: number;
  /** 0 = standing, 1 = fully crouched. */
  crouch: number;
  /** Signed sideways lean from turning (-1..1). */
  lean: number;
  /** Local-space movement direction (x = strafe left, z = forward). */
  moveX: number;
  moveZ: number;
  time: number;
  grip: Grip;
  /** 0..1 how much the samurai is sprinting (katana trails behind). */
  sprint: number;
}

export const WALK_CYCLE = 0.62; // distance per full gait cycle when walking
export const RUN_CYCLE = 1.35;

export function cycleLength(speed: number): number {
  const t = Math.min(1, Math.max(0, (speed - 1.2) / 2.8));
  return WALK_CYCLE + (RUN_CYCLE - WALK_CYCLE) * t;
}

function footCycle(out: THREE.Vector3, p: number, stance: number, length: number, lift: number, dirX: number, dirZ: number): number {
  // Returns foot pitch. The planted foot slides back at exactly body speed.
  let along: number;
  let up = 0;
  let pitch = 0;
  if (p < stance) {
    const u = p / stance;
    along = (0.5 - u) * stance * length;
    pitch = 0;
  } else {
    const u = (p - stance) / (1 - stance);
    const s = u * u * (3 - 2 * u);
    along = (-0.5 + s) * stance * length;
    up = Math.sin(Math.PI * u) * lift;
    pitch = Math.sin(Math.PI * u * 1.4) * 0.6 - 0.25 * Math.sin(Math.PI * u);
  }
  out.x += along * dirX;
  out.z += along * dirZ;
  out.y += up;
  return pitch;
}

/** Idle, walk, run and crouch-walk, blended continuously by speed. */
export function locomotion(out: Pose, input: LocoInput): Pose {
  const stance = stanceFor(input.grip);
  copyPose(out, stance);
  const { speed, crouch, time } = input;
  const moving = Math.min(1, speed / 1.2);
  const run = Math.min(1, Math.max(0, (speed - 1.6) / 2.6));
  const length = cycleLength(speed);
  const p = input.phase;

  // Movement direction in local space (feet travel along it).
  let mx = input.moveX;
  let mz = input.moveZ;
  const ml = Math.hypot(mx, mz);
  if (ml > 0.01) {
    mx /= ml;
    mz /= ml;
  } else {
    mx = 0;
    mz = 1;
  }

  // Feet: neutral side-by-side when moving, staggered stance when idle.
  const footBaseL = _v1.set(0.085, 0.035, 0).lerp(stance.footL, 1 - moving);
  const footBaseR = _v2.set(-0.085, 0.035, 0).lerp(stance.footR, 1 - moving);
  const stanceFrac = 0.56 - 0.2 * run;
  const lift = (0.07 + 0.09 * run) * moving * (1 - crouch * 0.4);
  out.footL.copy(footBaseL);
  out.footR.copy(footBaseR);
  if (moving > 0.01) {
    const pl = footCycle(_v3.set(0, 0, 0), p, stanceFrac, length, lift, mx, mz);
    out.footL.addScaledVector(_v3, moving);
    out.footLPitch = pl * moving;
    const pr = footCycle(_v3.set(0, 0, 0), (p + 0.5) % 1, stanceFrac, length, lift, mx, mz);
    out.footR.addScaledVector(_v3, moving);
    out.footRPitch = pr * moving;
  }

  // Hips: bob twice per cycle, sway over the planted foot, twist with the stride.
  const bob = Math.cos(p * Math.PI * 4);
  const walkBob = 0.012 * bob;
  const runBob = -0.03 * bob;
  const breathe = Math.sin(time * 2.4) * 0.004 * (1 - moving);
  const hipY = stance.hips.y + (0.43 - stance.hips.y) * moving * 0.6;
  out.hips.set(
    Math.sin(p * Math.PI * 2) * 0.012 * moving * (1 - run),
    hipY + (walkBob * (1 - run) + runBob * run) * moving - 0.02 * run + breathe,
    0.02 * run,
  );
  const twist = Math.sin(p * Math.PI * 2) * (8 + 6 * run) * moving;
  const forwardLean = (4 + 16 * run) * moving * mz + 4;
  const sideLean = input.lean * (10 + 10 * run);
  const stanceYaw = (1 - moving) * 14;
  out.hipsQ.copy(rot(forwardLean * 0.4, stanceYaw - twist, sideLean * 0.5));
  out.spineQ.copy(rot(forwardLean * 0.4 + Math.sin(time * 2.4) * 1.2 * (1 - moving), twist * 0.5 - stanceYaw * 0.4, 0));
  out.chestQ.copy(rot(forwardLean * 0.3 + Math.sin(time * 2.4 + 0.5) * 1.5 * (1 - moving), twist * 0.6 - stanceYaw * 0.6, -sideLean * 0.3));
  out.headQ.copy(rot(-forwardLean * 0.9 - 4, -twist * 0.2, -sideLean * 0.2));

  // Arms.
  const swingAmt = Math.sin(p * Math.PI * 2) * moving;
  if (input.grip === 'katana') {
    // Walking keeps the guard; sprinting lets the blade trail low behind (anime dash).
    const tr = input.sprint;
    out.handR.y += Math.sin(p * Math.PI * 4) * 0.01 * moving;
    if (tr > 0.01) {
      _tmp.handR.set(-0.19, 0.38, -0.06);
      _tmp.handRQ.copy(grip(d(-0.15, -0.35, -0.92), d(0, -0.92, 0.35)));
      _tmp.handL.set(0.17, 0.46 + swingAmt * 0.04, 0.12 * swingAmt);
      _tmp.handLQ.copy(rot(-60, 0, 0));
      out.handR.lerp(_tmp.handR, tr);
      out.handRQ.slerp(_tmp.handRQ, tr);
      out.handL.lerp(_tmp.handL, tr);
      out.handLQ.slerp(_tmp.handLQ, tr);
      out.twoHand = 1 - tr;
      out.elbowR.lerp(ELBOW_R_OUT, tr).normalize();
    }
  } else {
    const armSwing = 0.07 + 0.09 * run;
    if (input.grip === 'fists') {
      out.handR.z += -swingAmt * armSwing * 0.8;
      out.handL.z += swingAmt * armSwing * 0.8;
      out.handR.y -= 0.06 * run;
      out.handL.y -= 0.06 * run;
    } else {
      out.handR.z += -swingAmt * armSwing * 0.4;
      out.handL.z += swingAmt * armSwing;
      out.handL.y += Math.abs(swingAmt) * 0.03 - 0.05 * run;
    }
  }

  // Crouch: sink, widen, hunch.
  if (crouch > 0.001) {
    copyPose(_tmp2, out);
    _tmp2.hips.y -= 0.17;
    _tmp2.hips.z -= 0.03;
    _tmp2.footL.x += 0.035;
    _tmp2.footR.x -= 0.035;
    _tmp2.hipsQ.premultiply(rot(18, 0, 0));
    _tmp2.chestQ.multiply(rot(10, 0, 0));
    _tmp2.headQ.multiply(rot(-22, 0, 0));
    _tmp2.handR.y -= 0.06;
    _tmp2.handL.y -= 0.06;
    _tmp2.handR.z += 0.04;
    blendPose(out, out, _tmp2, crouch);
  }
  return out;
}

const _v1 = new THREE.Vector3();
const _v2 = new THREE.Vector3();
const _v3 = new THREE.Vector3();

/** Airborne: tuck on the way up, reach down when falling. */
export function airPose(out: Pose, vy: number, grip_: Grip, time: number): Pose {
  copyPose(out, stanceFor(grip_));
  const rise = Math.min(1, Math.max(0, vy / 6));
  const fall = Math.min(1, Math.max(0, -vy / 8));
  out.hips.set(0, 0.43, 0);
  out.footL.set(0.09, 0.2 * rise + 0.08, 0.06 * rise - 0.05 * fall);
  out.footR.set(-0.09, 0.12 * rise + 0.02, 0.1 * rise + 0.02 * fall);
  out.footLPitch = 0.3 * fall - 0.2;
  out.footRPitch = 0.3 * fall;
  out.hipsQ.copy(rot(6 * rise - 4 * fall, 8, 0));
  out.chestQ.copy(rot(8 * rise - 6 * fall, -6, 0));
  out.headQ.copy(rot(-10 * fall + 4, 0, 0));
  if (grip_ !== 'katana') {
    out.handL.set(0.22, 0.6 + 0.12 * fall, 0.05);
    out.handLQ.copy(rot(-30, 0, 30));
    if (grip_ === 'fists') out.handR.set(-0.22, 0.6 + 0.12 * fall, 0.05);
  } else {
    out.handR.y += 0.05 * fall + Math.sin(time * 9) * 0.004;
  }
  return out;
}

/**
 * Breaststroke at the surface: body tipped forward, head up out of the water, arms
 * reaching ahead and sweeping round, legs frog-kicking behind. Faster when moving.
 */
export function swimPose(out: Pose, time: number, speed: number): Pose {
  copyPose(out, emptyPose());
  const t = time * (speed > 0.3 ? 5.2 : 2.6);
  // 0 = arms together out front, 1 = swept wide and back.
  const sweep = (1 - Math.cos(t)) / 2;
  const kick = (1 - Math.cos(t + Math.PI * 0.6)) / 2;
  out.hips.set(0, 0.36, -0.08);
  out.hipsQ.copy(rot(58, 0, 0));
  out.spineQ.copy(rot(6, 0, 0));
  out.chestQ.copy(rot(4, 0, 0));
  out.headQ.copy(rot(-62, 0, 0));
  const reach = 0.42 - 0.22 * sweep;
  const wide = 0.08 + 0.22 * sweep;
  out.handR.set(-wide, 0.5 - 0.06 * sweep, reach);
  out.handL.set(wide, 0.5 - 0.06 * sweep, reach);
  out.handRQ.copy(rot(-90, -40 * sweep, 0));
  out.handLQ.copy(rot(-90, 40 * sweep, 0));
  out.elbowR.copy(d(-1, -0.4, 0));
  out.elbowL.copy(d(1, -0.4, 0));
  out.footR.set(-0.06 - 0.08 * kick, 0.3 + 0.06 * kick, -0.36 + 0.08 * kick);
  out.footL.set(0.06 + 0.08 * kick, 0.3 + 0.06 * kick, -0.36 + 0.08 * kick);
  out.footRPitch = 1.2;
  out.footLPitch = 1.2;
  out.twoHand = 0;
  return out;
}

/** Sitting in a toy car: leaning back a little, feet on the pedals, both hands on the wheel. */
export function drivePose(out: Pose, time: number): Pose {
  copyPose(out, emptyPose());
  const bob = Math.sin(time * 9) * 0.006;
  out.hips.set(0, 0.2 + bob, -0.02);
  out.hipsQ.copy(rot(-14, 0, 0));
  out.spineQ.copy(rot(8, 0, 0));
  out.chestQ.copy(rot(6, 0, 0));
  out.headQ.copy(rot(4, 0, 0));
  out.footL.set(0.09, 0.07, 0.36);
  out.footR.set(-0.09, 0.07, 0.36);
  out.footLPitch = -0.3;
  out.footRPitch = -0.3;
  out.handR.set(-0.11, 0.6, 0.3);
  out.handL.set(0.11, 0.6, 0.3);
  out.handRQ.copy(rot(-50, 0, 0));
  out.handLQ.copy(rot(-50, 0, 0));
  out.elbowR.copy(d(-1, -0.6, 0));
  out.elbowL.copy(d(1, -0.6, 0));
  out.twoHand = 0;
  return out;
}

/** Hanging from a ledge. `ledgeY`/`ledgeZ` are the ledge's height and distance in local space. */
export function hangPose(out: Pose, ledgeY: number, ledgeZ: number, time: number, shimmy: number, shimmyPhase: number): Pose {
  copyPose(out, emptyPose());
  const sway = Math.sin(time * 2.2) * 0.012;
  const reach = Math.sin(shimmyPhase * Math.PI * 2) * 0.05 * Math.abs(shimmy);
  out.hips.set(0, REST_HIPS_Y, -0.02 + sway);
  out.hipsQ.copy(rot(-6, 0, sway * 40));
  out.chestQ.copy(rot(-4, 0, 0));
  out.headQ.copy(rot(-18, 0, 0));
  out.handR.set(-0.11 - reach * Math.sign(shimmy || 1), ledgeY - 0.015, ledgeZ + 0.01);
  out.handL.set(0.11 - reach * Math.sign(shimmy || 1), ledgeY - 0.015, ledgeZ + 0.01);
  out.handRQ.copy(rot(-170, 0, 0));
  out.handLQ.copy(rot(-170, 0, 0));
  out.elbowR.copy(d(-1, 0, -0.6));
  out.elbowL.copy(d(1, 0, -0.6));
  out.footL.set(0.08, 0.06 + Math.max(0, Math.sin(time * 2.2)) * 0.03, 0.12);
  out.footR.set(-0.08, 0.04, 0.16);
  out.footLPitch = 0.6;
  out.footRPitch = 0.5;
  out.twoHand = 0;
  return out;
}

/** Pull-up and mantle. `t` 0..1 through the climb; ledge given in local space (it moves as the root rises). */
export function climbPose(out: Pose, t: number, ledgeY: number, ledgeZ: number): Pose {
  hangPose(out, ledgeY, ledgeZ, 0, 0, 0);
  const u = Math.min(1, t / 0.6);
  // Phase 1: pull chest up to the edge; phase 2: knee onto the ledge and stand.
  out.hips.z += 0.06 * u;
  out.hipsQ.copy(rot(-6 + 40 * u, 0, 0));
  out.chestQ.copy(rot(-4 + 30 * u, 0, 0));
  out.headQ.copy(rot(-18 + 10 * u, 0, 0));
  out.footR.set(-0.08, 0.04 + 0.35 * u, 0.16 + 0.12 * u);
  out.footRPitch = 0.5 - 0.5 * u;
  out.elbowR.copy(d(-0.6, -0.3, -1));
  out.elbowL.copy(d(0.6, -0.3, -1));
  if (t > 0.6) {
    const w = (t - 0.6) / 0.4;
    copyPose(_tmp, STANCE_KATANA);
    _tmp.twoHand = 0;
    _tmp.handR.set(-0.18, 0.4, 0.12);
    _tmp.handL.set(0.18, 0.4, 0.12);
    _tmp.handRQ.copy(rot(-20, 0, 0));
    _tmp.handLQ.copy(rot(-20, 0, 0));
    blendPose(out, out, _tmp, ease.inOut(w));
  }
  return out;
}

/** Sprinting crouch: baseball slide. */
export const SLIDE: Pose = makePose(emptyPose(), {
  hips: v(0, 0.17, -0.05),
  hipsQ: rot(-35, 10, 0),
  spineQ: rot(10, 0, 0),
  chestQ: rot(15, -10, 0),
  headQ: rot(20, 0, 0),
  footR: v(-0.08, 0.04, 0.42),
  footL: v(0.1, 0.05, 0.08),
  footRPitch: -0.6,
  footLPitch: 0.3,
  handR: v(-0.24, 0.32, -0.12),
  handRQ: grip(d(-0.2, -0.1, -1), d(0, -1, 0.1)),
  handL: v(0.22, 0.3, 0.05),
  handLQ: rot(0, 0, 60),
  twoHand: 0,
});

// ---- Keyframed clips -----------------------------------------------------------

type Draft = PoseDraft;
function clip(base: Pose, frames: [number, Draft, Ease?][], loop = false): Clip {
  const keys: Key[] = [];
  let prev = base;
  for (const [t, draft, e] of frames) {
    const pose = makePose(prev, draft);
    keys.push({ t, pose, ease: e });
    prev = pose;
  }
  return new Clip(keys, loop);
}

// Katana kesa-giri: diagonal cut from over the right shoulder down to the left hip.
const K1_UP = d(0.3, 0.6, -0.74);
const K1_MID = d(0.35, 0.1, 0.93);
const K1_AX = swingAxis(K1_UP, K1_MID);
const K1_DOWN = d(0.78, -0.5, 0.36);

export interface AttackClips {
  light: Clip[];
  heavy: Clip;
  dash: Clip;
  low: Clip;
  air: Clip;
  airLand: Clip;
}

function katanaSet(base: Pose): AttackClips {
  const s = base;
  const light1 = clip(s, [
    [0, {}],
    [
      0.13,
      {
        hips: v(0, 0.41, -0.03),
        hipsQ: rot(-2, -12, 0),
        spineQ: rot(-4, -10, 0),
        chestQ: rot(-6, -22, 4),
        headQ: rot(-4, 18, 0),
        handR: v(-0.16, 0.86, 0.0),
        handRQ: swing(K1_UP, K1_AX),
        elbowR: ELBOW_R_OUT,
        elbowL: d(0.6, -1, 0.2),
      },
      ease.out,
    ],
    [
      0.2,
      {
        hips: v(0, 0.38, 0.06),
        hipsQ: rot(8, 8, 0),
        spineQ: rot(6, 6, 0),
        chestQ: rot(10, 6, 0),
        headQ: rot(-8, -4, 0),
        footR: v(-0.08, 0.035, 0.2),
        handR: v(0.0, 0.62, 0.3),
        handRQ: swing(K1_MID, K1_AX),
        elbowR: ELBOW_R_DOWN,
        elbowL: ELBOW_L_DOWN,
      },
      ease.in,
    ],
    [
      0.27,
      {
        hips: v(0, 0.35, 0.09),
        hipsQ: rot(12, 22, 0),
        spineQ: rot(8, 12, 0),
        chestQ: rot(14, 26, -4),
        headQ: rot(-14, -30, 0),
        footR: v(-0.08, 0.035, 0.24),
        handR: v(0.16, 0.38, 0.17),
        handRQ: swing(K1_DOWN, K1_AX),
      },
      ease.out,
    ],
    [0.46, { hips: v(0, 0.36, 0.08) }, ease.out],
    [0.72, copyPose(emptyPose(), s), ease.inOut],
  ]);

  // Gyaku-kesa: rising cut back up from the left hip to the right.
  const K2_LOW = d(0.72, -0.5, 0.48);
  const K2_MID = d(0.05, 0.4, 0.92);
  const K2_HIGH = d(-0.7, 0.66, 0.26);
  const K2_AX = swingAxis(K2_LOW, K2_MID);
  const light2 = clip(s, [
    [0, { hips: v(0, 0.36, 0.06), hipsQ: rot(12, 22, 0), chestQ: rot(14, 28, -4), handR: v(0.16, 0.38, 0.17), handRQ: swing(K1_DOWN, K1_AX), footR: v(-0.08, 0.035, 0.2) }],
    [0.08, { hips: v(0, 0.35, 0.06), chestQ: rot(14, 40, -6), handR: v(0.18, 0.34, 0.1), handRQ: swing(K2_LOW, K2_AX), headQ: rot(-10, -30, 0) }, ease.out],
    [0.15, { hips: v(0, 0.39, 0.1), hipsQ: rot(4, 0, 0), chestQ: rot(4, 0, 0), headQ: rot(-6, 0, 0), handR: v(0.0, 0.6, 0.31), handRQ: swing(K2_MID, K2_AX), footL: v(0.095, 0.035, -0.02) }, ease.in],
    [0.23, { hips: v(0, 0.42, 0.1), hipsQ: rot(0, -18, 0), spineQ: rot(-4, -12, 0), chestQ: rot(-8, -30, 6), headQ: rot(-2, 26, 0), handR: v(-0.2, 0.86, 0.16), handRQ: swing(K2_HIGH, K2_AX), elbowR: ELBOW_R_OUT }, ease.out],
    [0.42, { hips: v(0, 0.41, 0.08) }, ease.out],
    [0.68, copyPose(emptyPose(), s), ease.inOut],
  ]);

  // Tsuki: pull back, then a full-body lunging thrust.
  const THRUST = grip(d(0, 0.06, 1), d(0, -1, 0.06));
  const light3 = clip(s, [
    [0, {}],
    [0.14, { hips: v(0, 0.37, -0.06), hipsQ: rot(6, 30, 0), spineQ: rot(2, -10, 0), chestQ: rot(4, -24, 0), headQ: rot(-8, -10, 0), footR: v(-0.08, 0.035, 0.06), footL: v(0.1, 0.035, -0.16), handR: v(-0.12, 0.56, -0.04), handRQ: grip(d(0.08, 0.12, 1), d(0, -1, 0.12)), elbowR: d(-0.8, -0.6, -0.6) }, ease.out],
    [0.21, { hips: v(0, 0.33, 0.14), hipsQ: rot(16, 6, 0), spineQ: rot(8, 0, 0), chestQ: rot(14, 4, 0), headQ: rot(-26, -2, 0), footR: v(-0.08, 0.035, 0.34), footL: v(0.1, 0.035, -0.24), footLPitch: 0.35, handR: v(-0.01, 0.58, 0.48), handRQ: THRUST, elbowR: ELBOW_R_DOWN }, ease.strike],
    [0.4, { hips: v(0, 0.34, 0.13) }, ease.out],
    [0.78, copyPose(emptyPose(), s), ease.inOut],
  ]);

  // Kabuto-wari: huge overhead wind-up, a held breath, then a helmet-splitting cleave.
  const H_UP = d(0.02, 0.38, -0.92);
  const H_MID = d(0, 0.78, 0.62);
  const H_AX = swingAxis(H_UP, H_MID);
  const H_DOWN = d(0, -0.5, 0.86);
  const heavy = clip(s, [
    [0, {}],
    [0.28, { hips: v(0, 0.45, -0.04), hipsQ: rot(-8, 6, 0), spineQ: rot(-8, 0, 0), chestQ: rot(-14, -4, 0), headQ: rot(4, 0, 0), footR: v(-0.085, 0.035, 0.1), footL: v(0.095, 0.05, -0.1), footLPitch: 0.4, handR: v(-0.04, 1.0, -0.04), handRQ: swing(H_UP, H_AX), elbowR: d(-1, 0.2, 0.2), elbowL: d(1, 0.2, 0.2) }, ease.out],
    [0.4, { hips: v(0, 0.46, -0.05), chestQ: rot(-16, -4, 0), handR: v(-0.04, 1.02, -0.06) }, ease.inOut],
    [0.45, { hips: v(0, 0.42, 0.08), hipsQ: rot(6, 4, 0), chestQ: rot(4, 0, 0), handR: v(-0.02, 0.8, 0.3), handRQ: swing(H_MID, H_AX), footR: v(-0.085, 0.035, 0.22), elbowR: ELBOW_R_DOWN, elbowL: ELBOW_L_DOWN }, ease.in],
    [0.5, { hips: v(0, 0.3, 0.13), hipsQ: rot(22, 4, 0), spineQ: rot(10, 0, 0), chestQ: rot(24, 0, 0), headQ: rot(-30, 0, 0), footR: v(-0.085, 0.035, 0.28), footL: v(0.1, 0.035, -0.16), footLPitch: 0.4, handR: v(-0.02, 0.3, 0.34), handRQ: swing(H_DOWN, H_AX) }, ease.out],
    [0.86, { hips: v(0, 0.31, 0.12) }, ease.out],
    [1.15, copyPose(emptyPose(), s), ease.inOut],
  ]);

  // Nukitsuke dash: running horizontal cut from the left hip across to the right.
  const D_START = d(0.55, 0.05, -0.83);
  const D_MID = d(0, 0.08, 1);
  const D_AX = swingAxis(D_START, D_MID);
  const D_END = d(-0.96, 0.12, 0.2);
  const dash = clip(s, [
    [0, {}],
    [0.08, { hips: v(0, 0.36, 0), hipsQ: rot(14, 40, 0), chestQ: rot(10, 30, 0), headQ: rot(-14, -50, 0), handR: v(0.2, 0.44, 0.0), handRQ: swing(D_START, D_AX), footR: v(-0.08, 0.035, 0.12), elbowR: ELBOW_R_OUT }, ease.out],
    [0.14, { hips: v(0, 0.35, 0.1), hipsQ: rot(14, 4, 0), chestQ: rot(10, 0, 0), headQ: rot(-14, 0, 0), handR: v(0.0, 0.5, 0.36), handRQ: swing(D_MID, D_AX) }, ease.in],
    [0.22, { hips: v(0, 0.33, 0.14), hipsQ: rot(16, -34, 0), chestQ: rot(12, -46, 0), headQ: rot(-14, 50, 0), handR: v(-0.3, 0.5, 0.14), handRQ: swing(D_END, D_AX), footR: v(-0.08, 0.035, 0.3), footL: v(0.1, 0.035, -0.2), elbowR: ELBOW_R_OUT }, ease.out],
    [0.48, { hips: v(0, 0.35, 0.1) }, ease.out],
    [0.75, copyPose(emptyPose(), s), ease.inOut],
  ]);

  // Low sweep: crouched cut at ankle height.
  const L_START = d(-0.9, -0.3, -0.3);
  const L_MID = d(0, -0.25, 1);
  const L_AX = swingAxis(L_START, L_MID);
  const L_END = d(0.95, -0.2, 0.2);
  const low = clip(s, [
    [0, { hips: v(0, 0.25, -0.02) }],
    [0.1, { hips: v(0, 0.22, -0.02), hipsQ: rot(24, -26, 0), chestQ: rot(20, -30, 0), headQ: rot(-30, 20, 0), handR: v(-0.26, 0.3, 0.02), handRQ: swing(L_START, L_AX), footL: v(0.14, 0.035, -0.06), footR: v(-0.12, 0.035, 0.1), elbowR: ELBOW_R_OUT }, ease.out],
    [0.17, { hips: v(0, 0.2, 0.04), hipsQ: rot(30, 2, 0), chestQ: rot(22, 0, 0), handR: v(0.0, 0.2, 0.34), handRQ: swing(L_MID, L_AX) }, ease.in],
    [0.25, { hips: v(0, 0.2, 0.06), hipsQ: rot(28, 34, 0), chestQ: rot(22, 40, 0), headQ: rot(-30, -26, 0), handR: v(0.26, 0.22, 0.14), handRQ: swing(L_END, L_AX) }, ease.out],
    [0.48, { hips: v(0, 0.22, 0.03) }, ease.out],
    [0.7, makePose(s, { hips: v(0, 0.25, -0.02) }), ease.inOut],
  ]);

  // Plunge: in the air, blade raised point-down, then driven into the floor on landing.
  const P_DOWN = grip(d(0, -1, 0.25), d(0, 0.25, 1));
  const air = clip(s, [
    [0, {}],
    [0.12, { hips: v(0, 0.45, 0), hipsQ: rot(-10, 0, 0), chestQ: rot(-8, 0, 0), headQ: rot(14, 0, 0), handR: v(-0.02, 0.9, 0.14), handRQ: P_DOWN, footL: v(0.09, 0.25, 0.05), footR: v(-0.09, 0.18, 0.08), elbowR: d(-1, 0.3, 0), elbowL: d(1, 0.3, 0) }, ease.out],
    [0.3, { handR: v(-0.02, 0.94, 0.12) }, ease.inOut],
  ]);
  const airLand = clip(s, [
    [0, { hips: v(0, 0.45, 0), hipsQ: rot(-10, 0, 0), handR: v(-0.02, 0.9, 0.14), handRQ: P_DOWN, footL: v(0.09, 0.2, 0.05), footR: v(-0.09, 0.15, 0.08) }],
    [0.06, { hips: v(0, 0.2, 0.06), hipsQ: rot(30, 0, 0), chestQ: rot(30, 0, 0), headQ: rot(-30, 0, 0), handR: v(-0.02, 0.18, 0.3), handRQ: grip(d(0, -1, 0.05), d(0, 0.05, 1)), footL: v(0.14, 0.035, -0.1), footR: v(-0.12, 0.035, 0.12), elbowR: ELBOW_R_OUT, elbowL: ELBOW_L_OUT }, ease.in],
    [0.42, { hips: v(0, 0.22, 0.06) }, ease.out],
    [0.7, copyPose(emptyPose(), s), ease.inOut],
  ]);

  return { light: [light1, light2, light3], heavy, dash, low, air, airLand };
}

/** Unarmed: jab, cross, roundhouse kick; heavy is a front push-kick. */
function fistSet(s: Pose): AttackClips {
  const jab = clip(s, [
    [0, {}],
    [0.06, { hipsQ: rot(4, 26, 0), chestQ: rot(6, 10, 0), handL: v(0.08, 0.6, 0.12) }, ease.out],
    [0.12, { hips: v(0, 0.4, 0.05), chestQ: rot(8, 24, 0), handL: v(0.0, 0.64, 0.42), handLQ: rot(-90, 0, 0), elbowL: d(1, -0.2, 0) }, ease.strike],
    [0.3, { hips: v(0, 0.405, 0) }, ease.out],
    [0.42, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const cross = clip(s, [
    [0, {}],
    [0.07, { hipsQ: rot(4, 34, 0), chestQ: rot(6, 24, 0), handR: v(-0.1, 0.6, 0.06) }, ease.out],
    [0.14, { hips: v(0, 0.39, 0.08), hipsQ: rot(8, -14, 0), chestQ: rot(10, -30, 0), footR: v(-0.085, 0.035, 0.04), footRPitch: 0.4, handR: v(-0.02, 0.63, 0.44), handRQ: rot(-90, 0, 0), handL: v(0.12, 0.62, 0.1), elbowR: d(-1, -0.2, 0) }, ease.strike],
    [0.34, { hips: v(0, 0.4, 0.06) }, ease.out],
    [0.5, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const kick = clip(s, [
    [0, {}],
    [0.12, { hips: v(0.03, 0.43, 0), hipsQ: rot(-6, 50, 18), chestQ: rot(-6, -20, -8), footR: v(-0.14, 0.24, -0.02), footRPitch: 0.6, handL: v(0.2, 0.6, 0.12), handR: v(-0.16, 0.66, 0.08) }, ease.out],
    [0.2, { hips: v(0.05, 0.44, 0.02), hipsQ: rot(-10, 90, 34), chestQ: rot(-10, -40, -18), headQ: rot(0, -50, 0), footR: v(0.0, 0.52, 0.38), footRPitch: -0.2 }, ease.strike],
    [0.4, { hips: v(0.02, 0.42, 0.02), hipsQ: rot(-4, 40, 10), footR: v(-0.1, 0.12, 0.12) }, ease.out],
    [0.6, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const heavy = clip(s, [
    [0, {}],
    [0.2, { hips: v(0, 0.42, -0.06), hipsQ: rot(-12, 10, 0), chestQ: rot(-8, 0, 0), footR: v(-0.08, 0.32, 0.08), footRPitch: 0.4, handR: v(-0.2, 0.62, 0.0), handL: v(0.2, 0.66, 0.06) }, ease.out],
    [0.27, { hips: v(0, 0.43, -0.08), hipsQ: rot(-18, 6, 0), chestQ: rot(-6, 0, 0), headQ: rot(8, 0, 0), footR: v(-0.06, 0.38, 0.5), footRPitch: -0.9 }, ease.strike],
    [0.5, { hips: v(0, 0.42, -0.04), footR: v(-0.08, 0.16, 0.2) }, ease.out],
    [0.75, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const dash = clip(s, [
    [0, {}],
    [0.08, { hips: v(0, 0.38, 0), hipsQ: rot(12, 20, 0), handR: v(-0.14, 0.6, -0.04) }, ease.out],
    [0.16, { hips: v(0, 0.34, 0.12), hipsQ: rot(20, -20, 0), chestQ: rot(14, -30, 0), handR: v(-0.02, 0.6, 0.46), handRQ: rot(-90, 0, 0), footR: v(-0.08, 0.035, 0.24), elbowR: d(-1, -0.2, 0) }, ease.strike],
    [0.4, { hips: v(0, 0.36, 0.08) }, ease.out],
    [0.62, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const low = clip(s, [
    [0, { hips: v(0, 0.25, -0.02) }],
    [0.1, { hips: v(0, 0.2, -0.02), hipsQ: rot(30, 20, 0), handL: v(0.15, 0.18, 0.15), handR: v(-0.15, 0.2, 0.12), footR: v(-0.14, 0.04, -0.04) }, ease.out],
    [0.18, { hips: v(0, 0.18, 0.0), hipsQ: rot(30, -50, 0), footR: v(0.04, 0.05, 0.42), footRPitch: -0.3 }, ease.strike],
    [0.38, { hips: v(0, 0.2, 0.0) }, ease.out],
    [0.6, makePose(s, { hips: v(0, 0.25, -0.02) }), ease.inOut],
  ]);
  const air = clip(s, [
    [0, {}],
    [0.1, { hipsQ: rot(-20, 0, 0), footR: v(-0.06, 0.12, 0.42), footRPitch: -0.6, footL: v(0.09, 0.28, -0.02), handR: v(-0.2, 0.7, -0.02), handL: v(0.2, 0.7, -0.02) }, ease.strike],
    [0.3, {}, ease.inOut],
  ]);
  const airLand = clip(s, [
    [0, { hips: v(0, 0.3, 0.02), hipsQ: rot(20, 0, 0) }],
    [0.25, copyPose(emptyPose(), s), ease.inOut],
  ]);
  return { light: [jab, cross, kick], heavy, dash, low, air, airLand };
}

/** Improvised one-handed weapon: katana cuts with the off hand free, then adapted per style. */
function oneHandSet(two: AttackClips): AttackClips {
  const free = (c: Clip): Clip =>
    new Clip(
      c.keys.map((k) => {
        const p = copyPose(emptyPose(), k.pose);
        p.twoHand = 0;
        // The off hand counter-swings for balance.
        p.handL.set(0.17 - (p.handR.x + 0.02) * 0.4, 0.56 + (p.handR.y - 0.53) * -0.2, 0.06 - (p.handR.z - 0.18) * 0.5);
        p.handLQ.copy(rot(-60, 0, 20));
        p.elbowL.copy(ELBOW_L_OUT);
        return { t: k.t, pose: p, ease: k.ease };
      }),
      c.loop,
    );
  return {
    light: two.light.map(free),
    heavy: free(two.heavy),
    dash: free(two.dash),
    low: free(two.low),
    air: free(two.air),
    airLand: free(two.airLand),
  };
}

export const KATANA_CLIPS = katanaSet(STANCE_KATANA);
export const ONE_CLIPS = oneHandSet(katanaSet(STANCE_ONE));
export const FIST_CLIPS = fistSet(STANCE_FISTS);

/** Stab-style items lead with thrusts. */
export const STAB_TWO: AttackClips = {
  ...KATANA_CLIPS,
  light: [KATANA_CLIPS.light[2]!, KATANA_CLIPS.light[0]!, KATANA_CLIPS.light[2]!],
};
export const STAB_ONE: AttackClips = {
  ...ONE_CLIPS,
  light: [ONE_CLIPS.light[2]!, ONE_CLIPS.light[1]!, ONE_CLIPS.light[2]!],
};

// ---- Defence, reactions and flourishes -----------------------------------------

const blockCache = new Map<Grip, Pose>();

/** Guard pose per grip (built once; callers copy from it). */
export function blockPose(g: Grip): Pose {
  let pose = blockCache.get(g);
  if (!pose) blockCache.set(g, (pose = buildBlockPose(g)));
  return pose;
}

function buildBlockPose(g: Grip): Pose {
  if (g === 'fists') {
    return makePose(STANCE_FISTS, {
      hips: v(0, 0.38, -0.03),
      chestQ: rot(14, 0, 0),
      headQ: rot(-4, 0, 0),
      handR: v(-0.06, 0.78, 0.13),
      handL: v(0.06, 0.78, 0.13),
      handRQ: rot(-160, 0, 0),
      handLQ: rot(-160, 0, 0),
      elbowR: d(-0.3, -1, 0.2),
      elbowL: d(0.3, -1, 0.2),
    });
  }
  // Blade held across the body, edge out, catching the strike near the guard.
  return makePose(stanceFor(g), {
    hips: v(0, 0.37, -0.03),
    hipsQ: rot(6, 20, 0),
    chestQ: rot(4, -6, 0),
    headQ: rot(-6, -10, 0),
    handR: v(-0.1, 0.66, 0.2),
    handRQ: grip(d(0.88, 0.42, 0.22), d(0.1, 0, 1)),
    handL: v(0.16, 0.7, 0.2),
    twoHand: g === 'katana' ? 1 : 0,
    elbowR: d(-1, -0.6, 0),
    elbowL: d(1, -0.6, 0),
  });
}

export const FLINCH = (base: Pose, side: number) =>
  clip(base, [
    [0, {}],
    [0.07, { hips: v(0, 0.39, -0.06), hipsQ: rot(-14, side * 18, side * -8), spineQ: rot(-10, 0, 0), chestQ: rot(-16, side * 16, side * 6), headQ: rot(-24, side * 22, side * 10), handR: base.handR.clone().add(v(0, 0.06, -0.08)), handL: base.handL.clone().add(v(0, 0.06, -0.08)) }, ease.out],
    [0.38, copyPose(emptyPose(), base), ease.inOut],
  ]);

/** Knocked flat on the back, then rolling up to a knee and standing. */
export const LYING: Pose = makePose(emptyPose(), {
  hips: v(0, 0.1, 0),
  hipsQ: rot(-84, 0, 0),
  spineQ: rot(-6, 0, 0),
  chestQ: rot(-6, 0, 0),
  headQ: rot(18, 10, 0),
  footL: v(0.12, 0.06, 0.38),
  footR: v(-0.1, 0.12, 0.3),
  footLPitch: -1.2,
  footRPitch: -1.0,
  handR: v(-0.32, 0.1, -0.18),
  handL: v(0.32, 0.1, -0.12),
  handRQ: rot(0, 0, -80),
  handLQ: rot(0, 0, 80),
  elbowR: d(-0.4, 1, 0),
  elbowL: d(0.4, 1, 0),
  twoHand: 0,
});

export const KNOCKDOWN = clip(STANCE_KATANA, [
  [0, {}],
  [0.12, { hips: v(0, 0.36, -0.1), hipsQ: rot(-40, 0, 0), chestQ: rot(-20, 0, 0), headQ: rot(-20, 0, 0), handR: v(-0.3, 0.75, -0.05), handL: v(0.3, 0.75, -0.05), twoHand: 0, footR: v(-0.1, 0.18, 0.2), footL: v(0.1, 0.1, 0.15), elbowR: d(-1, 0.2, 0), elbowL: d(1, 0.2, 0) }, ease.out],
  [0.38, copyPose(emptyPose(), LYING), ease.in],
  [0.46, { hips: v(0, 0.13, 0), headQ: rot(4, 10, 0) }, ease.out],
  [0.56, { hips: v(0, 0.1, 0), headQ: rot(18, 10, 0) }, ease.inOut],
]);

export const GETUP = (end: Pose) =>
  clip(LYING, [
    [0, {}],
    [0.18, { hips: v(0, 0.18, -0.02), hipsQ: rot(-30, 0, 0), chestQ: rot(20, 0, 0), headQ: rot(-10, 0, 0), handR: v(-0.2, 0.05, -0.05), handL: v(0.2, 0.05, -0.05), footR: v(-0.09, 0.035, 0.12), footL: v(0.1, 0.035, 0.18) }, ease.out],
    [0.36, { hips: v(0, 0.22, 0.0), hipsQ: rot(20, 0, 0), chestQ: rot(20, 0, 0), headQ: rot(-20, 0, 0), handR: v(-0.14, 0.3, 0.12), handL: v(0.14, 0.12, 0.2), footR: v(-0.09, 0.035, 0.18), footL: v(0.09, 0.07, -0.18), footLPitch: 0.8 }, ease.inOut],
    [0.6, copyPose(emptyPose(), end), ease.inOut],
  ]);

export const PICKUP = (base: Pose) =>
  clip(base, [
    [0, {}],
    [0.16, { hips: v(0, 0.24, -0.04), hipsQ: rot(28, 0, 0), spineQ: rot(16, 0, 0), chestQ: rot(18, 0, 0), headQ: rot(-14, 0, 0), handR: v(-0.06, 0.07, 0.3), handRQ: rot(-90, 0, 0), handL: v(0.18, 0.3, 0.12), footR: v(-0.09, 0.035, 0.14), twoHand: 0, elbowR: d(-0.4, -1, 0) }, ease.out],
    [0.24, { hips: v(0, 0.25, -0.04) }, ease.inOut],
    [0.44, copyPose(emptyPose(), base), ease.inOut],
  ]);

/** Weapon knocked away: arms flung back, stagger. */
export const DISARMED = clip(STANCE_FISTS, [
  [0, {}],
  [0.1, { hips: v(0, 0.42, -0.08), hipsQ: rot(-14, -20, 0), chestQ: rot(-20, -24, 0), headQ: rot(-20, 20, 0), handR: v(-0.3, 0.8, -0.1), handL: v(0.24, 0.64, -0.06), handRQ: rot(-180, 0, 0), elbowR: d(-1, 0, 0) }, ease.out],
  [0.6, copyPose(emptyPose(), STANCE_FISTS), ease.inOut],
]);

/** Guard broken or parried: sword knocked wide, wide open. */
export const STAGGER = (base: Pose) =>
  clip(base, [
    [0, {}],
    [0.1, { hips: v(0, 0.42, -0.08), hipsQ: rot(-14, 0, 0), chestQ: rot(-20, -16, 0), headQ: rot(-14, 10, 0), handR: v(-0.24, 0.86, -0.04), handRQ: grip(d(-0.5, 0.7, -0.5), d(0, 0.6, 0.8)), handL: v(0.24, 0.6, -0.04), twoHand: 0, elbowR: d(-1, 0, 0) }, ease.out],
    [0.62, copyPose(emptyPose(), base), ease.inOut],
  ]);

/** Victory: blade raised to the sky, then a calm bow. */
export const VICTORY = clip(STANCE_KATANA, [
  [0, {}],
  [0.35, { hips: v(0, 0.44, 0), hipsQ: rot(-4, 0, 0), chestQ: rot(-10, 0, 0), headQ: rot(14, 0, 0), handR: v(-0.12, 1.02, 0.06), handRQ: grip(d(-0.05, 1, 0.1), d(0, 0, 1)), handL: v(0.24, 0.42, 0.0), handLQ: rot(-10, 0, 0), twoHand: 0, footR: v(-0.08, 0.035, 0.02), footL: v(0.08, 0.035, -0.02), elbowR: d(-1, 0, 0) }, ease.outBack],
  [1.2, { handR: v(-0.12, 1.04, 0.07) }, ease.inOut],
  [1.6, { hips: v(0, 0.42, -0.02), hipsQ: rot(30, 0, 0), chestQ: rot(14, 0, 0), headQ: rot(10, 0, 0), handR: v(-0.14, 0.44, 0.02), handRQ: grip(d(-0.1, -0.3, -0.95), d(0, -0.95, 0.3)), handL: v(0.16, 0.44, 0.03), handLQ: rot(-20, 0, 0) }, ease.inOut],
  [2.2, { hipsQ: rot(30, 0, 0) }, ease.inOut],
]);

// ---- Carrying and throwing -------------------------------------------------------

/**
 * Lifting something overhead: the rig clamps the hands to the object, so this only
 * sets the body: elbows flared out, chest arched slightly back under the weight.
 */
export function carryOverlay(out: Pose, weight: number): Pose {
  out.twoHand = 0;
  out.elbowR.copy(d(-1, 0.1, -0.25));
  out.elbowL.copy(d(1, 0.1, -0.25));
  out.chestQ.multiply(rot(-5 - 4 * weight, 0, 0));
  out.headQ.multiply(rot(-4, 0, 0));
  out.hips.y -= 0.015 * weight;
  return out;
}

/** Two-handed overhead throw: arch back, then whip forward and let go at ~0.24 s. */
export const THROW_RELEASE = 0.24;
export const THROW = clip(STANCE_FISTS, [
  [0, { handR: v(-0.15, 0.98, 0.04), handL: v(0.15, 0.98, 0.04), elbowR: d(-1, 0.1, -0.25), elbowL: d(1, 0.1, -0.25) }],
  [
    0.18,
    {
      hips: v(0, 0.42, -0.05),
      hipsQ: rot(-8, 16, 0),
      spineQ: rot(-6, 0, 0),
      chestQ: rot(-22, 8, 0),
      headQ: rot(12, -6, 0),
      footR: v(-0.085, 0.035, 0.12),
      footL: v(0.095, 0.035, -0.14),
      footLPitch: 0.3,
      handR: v(-0.15, 1.0, -0.12),
      handL: v(0.15, 1.0, -0.12),
    },
    ease.out,
  ],
  [
    0.27,
    {
      hips: v(0, 0.38, 0.09),
      hipsQ: rot(14, -6, 0),
      spineQ: rot(8, 0, 0),
      chestQ: rot(26, -8, 0),
      headQ: rot(-22, 4, 0),
      footR: v(-0.085, 0.035, 0.26),
      footL: v(0.095, 0.035, -0.2),
      footLPitch: 0.5,
      handR: v(-0.1, 0.62, 0.38),
      handL: v(0.1, 0.62, 0.38),
      elbowR: ELBOW_R_DOWN,
      elbowL: ELBOW_L_DOWN,
    },
    ease.strike,
  ],
  [0.42, { hips: v(0, 0.39, 0.08) }, ease.out],
  [0.62, copyPose(emptyPose(), STANCE_FISTS), ease.inOut],
]);

// ---- Nunchaku -----------------------------------------------------------------------
//
// Whipping swings with the wrist leading: wide flat sweeps across the body, an
// overhead chop, and a twirl over the head before the heavy blow. The loose stick
// is swung by its own physics (see Fighter), so it trails and snaps round.

function flailSet(s: Pose): AttackClips {
  const one = (p: PoseDraft): PoseDraft => ({ twoHand: 0, handLQ: rot(-60, 0, 20), elbowL: ELBOW_L_OUT, ...p });
  const H = rot(-70, 0, 0);
  const light1 = clip(s, [
    [0, {}],
    [0.08, one({ chestQ: rot(0, -34, 0), hipsQ: rot(2, -14, 0), handR: v(-0.34, 0.74, -0.02), handRQ: rot(-30, -60, 0), handL: v(0.2, 0.6, 0.16) }), ease.out],
    [0.17, one({ chestQ: rot(4, 30, 0), hipsQ: rot(4, 14, 0), handR: v(0.12, 0.62, 0.4), handRQ: H, handL: v(0.24, 0.55, -0.02) }), ease.strike],
    [0.3, one({ chestQ: rot(2, 36, 0), handR: v(0.3, 0.56, 0.08), handRQ: rot(-40, 70, 0) }), ease.out],
    [0.46, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const light2 = clip(s, [
    [0, {}],
    [0.06, one({ chestQ: rot(0, 28, 0), handR: v(0.22, 0.72, 0.08), handRQ: rot(-30, 60, 0), handL: v(0.24, 0.55, -0.04) }), ease.out],
    [0.15, one({ chestQ: rot(4, -30, 0), hipsQ: rot(4, -12, 0), handR: v(-0.28, 0.64, 0.36), handRQ: H, handL: v(0.18, 0.6, 0.16) }), ease.strike],
    [0.28, one({ chestQ: rot(2, -36, 0), handR: v(-0.36, 0.56, 0.02), handRQ: rot(-40, -70, 0) }), ease.out],
    [0.42, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const light3 = clip(s, [
    [0, {}],
    [0.12, one({ hips: v(0, 0.42, -0.04), chestQ: rot(-14, -10, 0), handR: v(-0.14, 0.98, -0.08), handRQ: rot(-160, 0, 0), handL: v(0.2, 0.62, 0.18) }), ease.out],
    [0.22, one({ hips: v(0, 0.37, 0.06), chestQ: rot(20, 0, 0), handR: v(-0.04, 0.42, 0.42), handRQ: rot(-40, 0, 0), handL: v(0.22, 0.5, 0.02) }), ease.strike],
    [0.36, one({ hips: v(0, 0.38, 0.05) }), ease.out],
    [0.52, copyPose(emptyPose(), s), ease.inOut],
  ]);
  // Twirl it round overhead, then bring it crashing down.
  const ring: [number, PoseDraft, Ease][] = [];
  for (let i = 0; i <= 6; i++) {
    const a = (i / 6) * Math.PI * 2 + Math.PI;
    ring.push([0.06 + i * 0.06, one({ chestQ: rot(-6, Math.sin(a) * 12, 0), handR: v(-0.06 + Math.sin(a) * 0.16, 0.98, 0.06 + Math.cos(a) * 0.16), handRQ: rot(-150, (a * 180) / Math.PI, 0), handL: v(0.22, 0.58, 0.12) }), ease.inOut]);
  }
  const heavy = clip(s, [
    [0, {}],
    ...ring,
    [0.5, one({ hips: v(0, 0.36, 0.08), chestQ: rot(24, 0, 0), handR: v(-0.04, 0.36, 0.46), handRQ: rot(-30, 0, 0) }), ease.strike],
    [0.7, one({ hips: v(0, 0.37, 0.06) }), ease.out],
    [0.9, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const dash = clip(s, [
    [0, {}],
    [0.05, one({ chestQ: rot(0, -20, 0), handR: v(-0.22, 0.64, -0.12), handRQ: rot(-30, -40, 0) }), ease.out],
    [0.14, one({ hips: v(0, 0.39, 0.08), chestQ: rot(8, 16, 0), handR: v(-0.02, 0.62, 0.48), handRQ: H }), ease.strike],
    [0.48, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const low = clip(s, [
    [0, {}],
    [0.08, one({ hips: v(0, 0.3, -0.02), chestQ: rot(18, -30, 0), handR: v(-0.36, 0.34, 0.06), handRQ: rot(-20, -60, 0) }), ease.out],
    [0.18, one({ hips: v(0, 0.29, 0.04), chestQ: rot(22, 30, 0), handR: v(0.2, 0.2, 0.36), handRQ: rot(-10, 40, 0) }), ease.strike],
    [0.48, copyPose(emptyPose(), s), ease.inOut],
  ]);
  const air = clip(s, [
    [0, {}],
    [0.06, one({ chestQ: rot(-16, 0, 0), handR: v(-0.1, 0.98, -0.06), handRQ: rot(-160, 0, 0) }), ease.out],
    [0.16, one({ chestQ: rot(24, 0, 0), handR: v(-0.04, 0.3, 0.36), handRQ: rot(-20, 0, 0) }), ease.strike],
  ]);
  return { light: [light1, light2, light3], heavy, dash, low, air, airLand: ONE_CLIPS.airLand };
}

export const FLAIL_CLIPS = flailSet(STANCE_ONE);

// ---- Rubber band ------------------------------------------------------------------------
//
// Hooked over the left thumb with the arm held out, drawn back to the cheek like a
// bow, then let go.

export const FLICK_RELEASE = 0.34;
export const FLICK_TIME = 0.58;
export const FLICK = clip(STANCE_FISTS, [
  [0, {}],
  [0.12, { twoHand: 0, chestQ: rot(0, 24, 0), hipsQ: rot(2, 14, 0), headQ: rot(-4, -18, 0), handL: v(0.04, 0.66, 0.44), handLQ: rot(-90, 0, 0), elbowL: d(1, -0.3, 0), handR: v(0.0, 0.66, 0.38), handRQ: rot(-90, 0, 0) }, ease.out],
  [0.3, { chestQ: rot(0, 34, 0), headQ: rot(-6, -26, 0), handL: v(0.04, 0.68, 0.46), handR: v(-0.12, 0.72, 0.04), elbowR: d(-0.9, 0.1, -1) }, ease.inOut],
  [0.36, { handR: v(-0.17, 0.74, -0.04), handRQ: rot(-60, 30, 0) }, ease.out],
  [FLICK_TIME, copyPose(emptyPose(), STANCE_FISTS), ease.inOut],
]);

/** Guarding while walking: the block's arms and chest on top of the walking legs. */
export function blockOverlay(out: Pose, g: Grip): Pose {
  const b = blockPose(g);
  out.handR.copy(b.handR);
  out.handRQ.copy(b.handRQ);
  out.handL.copy(b.handL);
  out.handLQ.copy(b.handLQ);
  out.twoHand = b.twoHand;
  out.elbowR.copy(b.elbowR);
  out.elbowL.copy(b.elbowL);
  out.chestQ.slerp(b.chestQ, 0.7);
  out.headQ.slerp(b.headQ, 0.7);
  // Guarding crouches a little lower than a normal walk.
  out.hips.y -= 0.035;
  return out;
}

// ---- Dodge roll -------------------------------------------------------------------
//
// Modelled on the martial-arts forward breakfall (zenpō kaiten / mae ukemi), the
// roll people use in armour: step in low, curve the lead arm into a wheel, tuck the
// chin and roll diagonally from the back of the lead shoulder to the opposite hip,
// never over the head or spine, knees pulled in, then come up in a low guard.
// Here the free LEFT arm makes the wheel; the right hand keeps the weapon lying along
// the body's spin axis so it turns in place. The rig root yaws by ROLL_TWIST so the
// shoulder-to-hip line runs along the direction of travel, and pitches through a
// full turn; the fighter keeps the lowest part of the body resting on the floor.

/** Height of the spin axis before contact correction. */
export const ROLL_AXIS = 0.34;
/** Body turned this far (radians) from the travel direction: left shoulder leads. */
export const ROLL_TWIST = -0.3;
/**
 * Sideways tilt (radians, about the body's forward axis) during the tumble: the head
 * tips to the right so the rolling circle runs from the back of the LEFT shoulder
 * to the RIGHT hip, keeping the helmet and spine off the floor.
 */
export const ROLL_BANK = 1.15;

/** Dropping in: lead (left) foot forward, sink low, left arm reaching down as the wheel, chin tucked. */
export const ROLL_ENTRY: Pose = makePose(emptyPose(), {
  hips: v(0, 0.3, 0.02),
  hipsQ: rot(38, 10, 0),
  spineQ: rot(14, 0, -6),
  chestQ: rot(20, 8, -10),
  headQ: rot(34, -38, 18),
  footL: v(0.1, 0.035, 0.2),
  footR: v(-0.09, 0.05, -0.14),
  footRPitch: 0.6,
  handL: v(0.1, 0.12, 0.34),
  handLQ: rot(-30, 0, 40),
  elbowL: d(1, 0.2, -0.2),
  twoHand: 0,
});

/** Tucked ball (relative to the spin axis); `side` -1 holds the weapon out on the right. */
export function rollPose(out: Pose, side: number, g: Grip, axis: THREE.Vector3): Pose {
  copyPose(out, emptyPose());
  const hipsY = 0.3;
  // Hand targets get the hips' drop added later, so pre-compensate to land on the axis.
  const shift = hipsY - REST_HIPS_Y;
  out.hips.set(0, hipsY, 0.02);
  out.hipsQ.copy(rot(20, 0, 0));
  out.spineQ.copy(rot(18, 0, 16));
  out.chestQ.copy(rot(12, 8, 26));
  // Chin tucked and the face turned toward the opposite hip: the helmet stays off the floor.
  out.headQ.copy(rot(34, -80, 55));
  out.footL.set(0.08, 0.16, 0.14);
  out.footR.set(-0.08, 0.2, 0.08);
  out.footLPitch = -0.6;
  out.footRPitch = -0.4;
  // Left forearm hugs the shins.
  out.handL.set(0.06, 0.36 - shift, 0.24);
  out.handLQ.copy(rot(-40, 0, 30));
  out.elbowL.copy(d(1, -0.1, -0.3));
  out.twoHand = 0;
  if (g === 'fists') {
    out.handR.set(-0.08, 0.38 - shift, 0.22);
    out.handRQ.copy(rot(-40, 0, -30));
  } else {
    // `axis` is the spin axis in body space. Grip it where it passes closest to the right
    // shoulder (within reach), with the blade lying along it so it turns in place.
    const toShoulder = new THREE.Vector3(-0.15, 0.62 - ROLL_AXIS, 0.16);
    const s = Math.max(-0.4, Math.min(0.4, toShoulder.dot(axis)));
    out.handR.set(axis.x * s, ROLL_AXIS - shift + axis.y * s, axis.z * s);
    const blade = axis.clone().multiplyScalar(side);
    const edge = Math.abs(blade.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0);
    out.handRQ.copy(grip(blade, edge));
  }
  out.elbowR.copy(d(-1, -0.3, -0.3));
  return out;
}

/** Coming up: one knee nearly down, low and square, weapon back in a guard. */
export function rollExitPose(g: Grip): Pose {
  return makePose(stanceFor(g), {
    hips: v(0, 0.27, -0.02),
    hipsQ: rot(22, 4, 0),
    spineQ: rot(8, 0, 0),
    chestQ: rot(10, -4, 0),
    headQ: rot(-14, 0, 0),
    footL: v(0.1, 0.035, 0.12),
    footR: v(-0.09, 0.06, -0.2),
    footRPitch: 0.9,
  });
}