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.
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,
});
}
