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 { 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);
}
}
