Kowloon Knockout Phase 4 — Skeletal Characters Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the procedural StickFighter with a Mixamo Y-Bot skeletal character that plays motion clips driven by the existing sim state machine (idle/walk/4 punches/block/hit/stun/KO) crossfaded, on the higher tiers — with StickFighter retained as the low-tier path and the universal fallback when assets are missing/loading/failed.
Architecture: A pure, unit-tested core (render/fighter/: clip manifest, sim-state→clip resolver, root-motion strip, rig helpers) feeds a SkeletalFighter R3F component that loads the shared GLBs once (drei useGLTF), clones the rig per seat (SkeletonUtils.clone), runs a per-fighter AnimationMixer, and drives everything imperatively from the RenderFighter snapshot. A Fighter dispatcher picks SkeletalFighter vs StickFighter by tier, with StickFighter as the Suspense + ErrorBoundary fallback. The combat sim/net/input/HUD are untouched.
Tech Stack: React Three Fiber ^9.6.1, three.js ^0.183.2 (three/examples/jsm/utils/SkeletonUtils.js clone; AnimationMixer, AnimationClip), @react-three/drei (useGLTF, Html), Vite, Vitest ^4.1.8 (node env).
Global Constraints¶
three stays at
^0.183.2. Verified available: dreiuseGLTF/useAnimations;SkeletonUtils.clonefromthree/examples/jsm/utils/SkeletonUtils.js; threeAnimationClip/VectorKeyframeTrackinstantiate and expose.tracks[i].valuesin node (so the pure helpers are node-testable).Asset contract (user-supplied, separate FBX — already present):
public/kowloon/fighter/holdsybot.fbx+idle.fbx walk.fbx jab.fbx cross.fbx hook.fbx uppercut.fbx block.fbx hit.fbx stunned.fbx ko.fbx dance.fbx. Loaded viaFBXLoader(not GLTF). If any are missing, every fighter must fall back toStickFighter(no regression). Mixamomixamorig:skeleton; bone names may appear asmixamorig:HipsormixamorigHips— match tolerantly.danceis a render-only local emote (key G), never a sim state.Out of scope — do not edit:
lib/kowloon-knockout/game/**,net/**,game/input.ts, HUD, lobby,PostFx.tsx, and the Phase 2/3 arena files exceptArena3D.tsx(the seat-map mount) andStickFighter.tsx(only to factor its nameplate/shadow intoFighterTrappings).No React state in the per-frame fighter path — drive transforms/mixer imperatively from
framesRef.current, matchingStickFighter.Naming: new arena components use
'use client'+ named-default-export; pure modules live underlib/kowloon-knockout/render/fighter/.
Verification approach (read before starting)¶
(UNIT) — the pure core (
clips.ts,stateMachine.ts,rootMotion.ts,fighterRig.ts): real Vitest TDD in node env. Runnode_modules/.bin/vitest run lib/kowloon-knockout/render.(RUN-OBSERVE) — all rendering. Browser-only, and the skeletal path additionally needs the user to have added the GLBs. Verified by [SMOKE], two-stage: (1) before assets —
StickFighterstill renders everywhere (no regression); (2) after assets — Y-Bot loads/scales/faces/animates. Headless implementers runnode_modules/.bin/eslint <files>and mark visual checks “deferred to user”.Per project workflow: every PR is preceded by a
senior-swe-reviewerpass.
Parallelization guidance¶
Tasks 1–4 (pure core) are independent and can be built in any order before the render layer.
Task 5 (
FighterTrappings, editsStickFighter.tsx) is independent of the pure core.Task 6 (
SkeletalFighter) consumes Tasks 1–5; Task 7 (Fighter+Arena3D) consumes Task 6. Keep 6→7 sequential.
Task 1: clips.ts — clip manifest (UNIT, TDD)¶
Files:
Create:
lib/kowloon-knockout/render/fighter/clips.tsCreate:
lib/kowloon-knockout/render/fighter/__tests__/clips.test.ts
Interfaces:
Consumes: nothing.
Produces:
type ClipKey = 'idle'|'walk'|'jab'|'cross'|'hook'|'uppercut'|'block'|'hit'|'stunned'|'ko'interface ClipDef { file: string; loop: boolean; fade: number; }const CLIP_KEYS: ClipKey[],const CLIPS: Record<ClipKey, ClipDef>const FIGHTER_ASSET_DIR = '/kowloon/fighter',const RIG_FILE = 'ybot.glb'
Step 1: Write the failing test
Create lib/kowloon-knockout/render/fighter/__tests__/clips.test.ts:
import { describe, it, expect } from 'vitest';
import { CLIP_KEYS, CLIPS, type ClipKey } from '../clips';
describe('CLIPS manifest', () => {
it('has a definition for every clip key', () => {
for (const k of CLIP_KEYS) expect(CLIPS[k]).toBeDefined();
expect(CLIP_KEYS).toHaveLength(10);
});
it('gives every clip a unique .glb file', () => {
const files = CLIP_KEYS.map((k) => CLIPS[k].file);
expect(new Set(files).size).toBe(files.length);
for (const f of files) expect(f.endsWith('.glb')).toBe(true);
});
it('loops locomotion/hold clips and one-shots the strikes/reactions', () => {
const loops: ClipKey[] = ['idle', 'walk', 'block', 'stunned'];
const oneShots: ClipKey[] = ['jab', 'cross', 'hook', 'uppercut', 'hit', 'ko'];
for (const k of loops) expect(CLIPS[k].loop).toBe(true);
for (const k of oneShots) expect(CLIPS[k].loop).toBe(false);
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/fighter/__tests__/clips.test.ts
Expected: FAIL — Cannot find module '../clips'.
Step 3: Implement
clips.ts
Create lib/kowloon-knockout/render/fighter/clips.ts:
/** Animation clip identity. Punch keys (jab/cross/hook/uppercut) deliberately
* match the sim's PunchType so the state machine can use rf.punch directly. */
export type ClipKey =
| 'idle' | 'walk'
| 'jab' | 'cross' | 'hook' | 'uppercut'
| 'block' | 'hit' | 'stunned' | 'ko';
export interface ClipDef {
/** GLB filename under FIGHTER_ASSET_DIR. */
file: string;
/** true = looping clip; false = one-shot (LoopOnce + clamp). */
loop: boolean;
/** Crossfade duration into this clip, seconds. */
fade: number;
}
export const CLIP_KEYS: ClipKey[] = [
'idle', 'walk', 'jab', 'cross', 'hook', 'uppercut', 'block', 'hit', 'stunned', 'ko',
];
export const CLIPS: Record<ClipKey, ClipDef> = {
idle: { file: 'idle.glb', loop: true, fade: 0.2 },
walk: { file: 'walk.glb', loop: true, fade: 0.15 },
jab: { file: 'jab.glb', loop: false, fade: 0.08 },
cross: { file: 'cross.glb', loop: false, fade: 0.08 },
hook: { file: 'hook.glb', loop: false, fade: 0.08 },
uppercut: { file: 'uppercut.glb', loop: false, fade: 0.08 },
block: { file: 'block.glb', loop: true, fade: 0.12 },
hit: { file: 'hit.glb', loop: false, fade: 0.1 },
stunned: { file: 'stunned.glb', loop: true, fade: 0.15 },
ko: { file: 'ko.glb', loop: false, fade: 0.15 },
};
export const FIGHTER_ASSET_DIR = '/kowloon/fighter';
export const RIG_FILE = 'ybot.glb';
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/fighter/__tests__/clips.test.ts
Expected: PASS (3 tests).
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/render/fighter/clips.ts lib/kowloon-knockout/render/fighter/__tests__/clips.test.ts
git add lib/kowloon-knockout/render/fighter/clips.ts lib/kowloon-knockout/render/fighter/__tests__/clips.test.ts
git commit -m "feat(kowloon): fighter animation clip manifest"
Task 2: stateMachine.ts — sim state → clip resolver (UNIT, TDD)¶
Files:
Create:
lib/kowloon-knockout/render/fighter/stateMachine.tsCreate:
lib/kowloon-knockout/render/fighter/__tests__/stateMachine.test.ts
Interfaces:
Consumes:
ClipKey,CLIPS(Task 1);RenderFighterfrom@/lib/kowloon-knockout/net/session.Produces:
function resolveClip(rf: Pick<RenderFighter, 'state' | 'punch'>): { clip: ClipKey; loop: boolean }.Step 1: Write the failing test
Create lib/kowloon-knockout/render/fighter/__tests__/stateMachine.test.ts:
import { describe, it, expect } from 'vitest';
import { resolveClip } from '../stateMachine';
const rf = (state: string, punch: 'jab' | 'cross' | 'hook' | 'uppercut' | null = null) =>
({ state, punch }) as Parameters<typeof resolveClip>[0];
describe('resolveClip', () => {
it('maps the looping locomotion/hold states', () => {
expect(resolveClip(rf('idle'))).toEqual({ clip: 'idle', loop: true });
expect(resolveClip(rf('walking'))).toEqual({ clip: 'walk', loop: true });
expect(resolveClip(rf('blocking'))).toEqual({ clip: 'block', loop: true });
expect(resolveClip(rf('stunned'))).toEqual({ clip: 'stunned', loop: true });
});
it('maps the one-shot reactions', () => {
expect(resolveClip(rf('hit'))).toEqual({ clip: 'hit', loop: false });
expect(resolveClip(rf('knockedOut'))).toEqual({ clip: 'ko', loop: false });
});
it('maps each punch to its own clip', () => {
expect(resolveClip(rf('punching', 'jab')).clip).toBe('jab');
expect(resolveClip(rf('punching', 'cross')).clip).toBe('cross');
expect(resolveClip(rf('punching', 'hook')).clip).toBe('hook');
expect(resolveClip(rf('punching', 'uppercut')).clip).toBe('uppercut');
expect(resolveClip(rf('punching', 'jab')).loop).toBe(false);
});
it('defaults a punch with no subtype to jab', () => {
expect(resolveClip(rf('punching', null)).clip).toBe('jab');
});
it('falls back to idle for an unknown state', () => {
expect(resolveClip(rf('teleporting'))).toEqual({ clip: 'idle', loop: true });
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/fighter/__tests__/stateMachine.test.ts
Expected: FAIL — Cannot find module '../stateMachine'.
Step 3: Implement
stateMachine.ts
Create lib/kowloon-knockout/render/fighter/stateMachine.ts:
import type { RenderFighter } from '@/lib/kowloon-knockout/net/session';
import { CLIPS, type ClipKey } from './clips';
/** Map a fighter's sim state to the animation clip it should be playing.
* Pure: same snapshot → same clip. The renderer crossfades to this each frame
* and (re)triggers one-shots on state-entry. */
export function resolveClip(rf: Pick<RenderFighter, 'state' | 'punch'>): { clip: ClipKey; loop: boolean } {
let clip: ClipKey;
switch (rf.state) {
case 'walking': clip = 'walk'; break;
case 'punching': clip = rf.punch ?? 'jab'; break; // PunchType ⊂ ClipKey
case 'blocking': clip = 'block'; break;
case 'hit': clip = 'hit'; break;
case 'stunned': clip = 'stunned'; break;
case 'knockedOut': clip = 'ko'; break;
case 'idle':
default: clip = 'idle'; break;
}
return { clip, loop: CLIPS[clip].loop };
}
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/fighter/__tests__/stateMachine.test.ts
Expected: PASS (5 tests).
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/render/fighter/stateMachine.ts lib/kowloon-knockout/render/fighter/__tests__/stateMachine.test.ts
git add lib/kowloon-knockout/render/fighter/stateMachine.ts lib/kowloon-knockout/render/fighter/__tests__/stateMachine.test.ts
git commit -m "feat(kowloon): sim-state to animation-clip resolver"
Task 3: rootMotion.ts — strip hips XZ translation (UNIT, TDD)¶
Files:
Create:
lib/kowloon-knockout/render/fighter/rootMotion.tsCreate:
lib/kowloon-knockout/render/fighter/__tests__/rootMotion.test.ts
Interfaces:
Consumes:
THREE.AnimationClipfromthree.Produces:
function stripRootMotionXZ(clip: THREE.AnimationClip): void— mutates in place; zeroes X and Z of any hips.positiontrack, keeps Y, leaves all other tracks untouched.Step 1: Write the failing test
Create lib/kowloon-knockout/render/fighter/__tests__/rootMotion.test.ts:
import { describe, it, expect } from 'vitest';
import * as THREE from 'three';
import { stripRootMotionXZ } from '../rootMotion';
describe('stripRootMotionXZ', () => {
it('zeroes X and Z of the hips position track but keeps Y', () => {
const hips = new THREE.VectorKeyframeTrack('mixamorigHips.position', [0, 1], [1, 2, 3, 4, 5, 6]);
const clip = new THREE.AnimationClip('walk', 1, [hips]);
stripRootMotionXZ(clip);
expect(Array.from(clip.tracks[0].values)).toEqual([0, 2, 0, 0, 5, 0]);
});
it('handles the colon bone-name variant', () => {
const hips = new THREE.VectorKeyframeTrack('mixamorig:Hips.position', [0], [9, 8, 7]);
const clip = new THREE.AnimationClip('walk', 1, [hips]);
stripRootMotionXZ(clip);
expect(Array.from(clip.tracks[0].values)).toEqual([0, 8, 0]);
});
it('leaves non-hips and non-position tracks untouched', () => {
const spine = new THREE.VectorKeyframeTrack('mixamorigSpine.position', [0], [1, 2, 3]);
const quat = new THREE.QuaternionKeyframeTrack('mixamorigHips.quaternion', [0], [0, 0, 0, 1]);
const clip = new THREE.AnimationClip('x', 1, [spine, quat]);
stripRootMotionXZ(clip);
expect(Array.from(clip.tracks[0].values)).toEqual([1, 2, 3]);
expect(Array.from(clip.tracks[1].values)).toEqual([0, 0, 0, 1]);
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/fighter/__tests__/rootMotion.test.ts
Expected: FAIL — Cannot find module '../rootMotion'.
Step 3: Implement
rootMotion.ts
Create lib/kowloon-knockout/render/fighter/rootMotion.ts:
import type * as THREE from 'three';
/** Zero the X/Z translation of the hips ("root") position track so the sim
* stays the sole owner of a fighter's ground position; vertical (Y) motion is
* kept so crouches and the KO topple still read. Matches the Mixamo hips bone
* under either `mixamorigHips` or `mixamorig:Hips` naming. Mutates in place. */
export function stripRootMotionXZ(clip: THREE.AnimationClip): void {
for (const track of clip.tracks) {
if (!track.name.endsWith('.position')) continue;
if (!track.name.includes('Hips')) continue;
const v = track.values; // [x,y,z, x,y,z, ...]
for (let i = 0; i < v.length; i += 3) {
v[i] = 0; // x
v[i + 2] = 0; // z
}
}
}
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/fighter/__tests__/rootMotion.test.ts
Expected: PASS (3 tests).
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/render/fighter/rootMotion.ts lib/kowloon-knockout/render/fighter/__tests__/rootMotion.test.ts
git add lib/kowloon-knockout/render/fighter/rootMotion.ts lib/kowloon-knockout/render/fighter/__tests__/rootMotion.test.ts
git commit -m "feat(kowloon): strip hips XZ root motion from fighter clips"
Task 4: fighterRig.ts — rig helpers (UNIT, TDD)¶
Files:
Create:
lib/kowloon-knockout/render/fighter/fighterRig.tsCreate:
lib/kowloon-knockout/render/fighter/__tests__/fighterRig.test.ts
Interfaces:
Consumes:
three.Produces:
function autoScaleToHeight(obj: THREE.Object3D, targetHeight: number): number— scalesobjuniformly so its bounding-box height equalstargetHeight; returns the applied scale. No-op (returns 1) if the box is empty/zero-height.function findBone(root: THREE.Object3D, names: string[]): THREE.Object3D | undefined— first descendant whosenamematches any candidate.
Step 1: Write the failing test
Create lib/kowloon-knockout/render/fighter/__tests__/fighterRig.test.ts:
import { describe, it, expect } from 'vitest';
import * as THREE from 'three';
import { autoScaleToHeight, findBone } from '../fighterRig';
describe('autoScaleToHeight', () => {
it('scales an object so its bbox height matches the target', () => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 2, 1)); // 2 units tall
const s = autoScaleToHeight(mesh, 1.8);
expect(s).toBeCloseTo(0.9, 5);
const h = new THREE.Box3().setFromObject(mesh);
expect(h.max.y - h.min.y).toBeCloseTo(1.8, 4);
});
it('returns 1 and does nothing for a zero-height object', () => {
const empty = new THREE.Group();
expect(autoScaleToHeight(empty, 1.8)).toBe(1);
});
});
describe('findBone', () => {
it('finds a descendant by any candidate name', () => {
const root = new THREE.Object3D();
const head = new THREE.Bone(); head.name = 'mixamorigHead';
root.add(head);
expect(findBone(root, ['mixamorig:Head', 'mixamorigHead'])).toBe(head);
});
it('returns undefined when no candidate matches', () => {
expect(findBone(new THREE.Object3D(), ['nope'])).toBeUndefined();
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/fighter/__tests__/fighterRig.test.ts
Expected: FAIL — Cannot find module '../fighterRig'.
Step 3: Implement
fighterRig.ts
Create lib/kowloon-knockout/render/fighter/fighterRig.ts:
import * as THREE from 'three';
/** Uniformly scale `obj` so its world-space bounding-box height becomes
* `targetHeight`. Robust to whatever units the Mixamo FBX→GLB conversion
* produced. Returns the scale applied (1 if the box has no height). */
export function autoScaleToHeight(obj: THREE.Object3D, targetHeight: number): number {
const box = new THREE.Box3().setFromObject(obj);
const height = box.max.y - box.min.y;
if (!Number.isFinite(height) || height <= 0) return 1;
const s = targetHeight / height;
obj.scale.multiplyScalar(s);
return s;
}
/** First descendant of `root` whose name equals any of `names` (handles the
* `mixamorigHips` vs `mixamorig:Hips` conversion variants). */
export function findBone(root: THREE.Object3D, names: string[]): THREE.Object3D | undefined {
for (const n of names) {
const found = root.getObjectByName(n);
if (found) return found;
}
return undefined;
}
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/fighter/__tests__/fighterRig.test.ts
Expected: PASS (4 tests).
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/render/fighter/fighterRig.ts lib/kowloon-knockout/render/fighter/__tests__/fighterRig.test.ts
git add lib/kowloon-knockout/render/fighter/fighterRig.ts lib/kowloon-knockout/render/fighter/__tests__/fighterRig.test.ts
git commit -m "feat(kowloon): fighter rig helpers (auto-scale, bone lookup)"
Task 6: SkeletalFighter.tsx — Y-Bot skeletal renderer (RUN-OBSERVE, spike-first)¶
Files:
Create:
components/kowloon-knockout/arena/SkeletalFighter.tsx
Interfaces:
Consumes:
CLIPS,CLIP_KEYS,FIGHTER_ASSET_DIR,RIG_FILE,ClipKey(Task 1, now.fbxfiles + adanceclip);resolveClip(Task 2);stripRootMotionXZ(Task 3);autoScaleToHeight,findBone(Task 4);FighterTrappings(Task 5);useLoader(@react-three/fiber) +FBXLoader(three/examples/jsm/loaders/FBXLoader.js);SkeletonUtils.clone. ReadsRenderFighterviaframesRef. (Tier selection is the dispatcher’s job — this component does not read the tier.) The assets already exist atpublic/kowloon/fighter/*.fbx.Produces:
<SkeletalFighter seat framesRef showNameplate? />— same prop shape asStickFighter.Step 1: Implement the skeletal renderer
Create components/kowloon-knockout/arena/SkeletalFighter.tsx. The structure mirrors StickFighter (imperative per-frame from framesRef, damped root, shared FighterTrappings), but the pose comes from an AnimationMixer crossfading clips chosen by resolveClip.
'use client';
import { useMemo, useRef, useEffect, type MutableRefObject } from 'react';
import { useFrame, useLoader } from '@react-three/fiber';
import * as THREE from 'three';
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js';
import type { RenderFighter } from '@/lib/kowloon-knockout/net/session';
import { CLIPS, CLIP_KEYS, FIGHTER_ASSET_DIR, RIG_FILE, type ClipKey } from '@/lib/kowloon-knockout/render/fighter/clips';
import { resolveClip } from '@/lib/kowloon-knockout/render/fighter/stateMachine';
import { stripRootMotionXZ } from '@/lib/kowloon-knockout/render/fighter/rootMotion';
import { autoScaleToHeight, findBone } from '@/lib/kowloon-knockout/render/fighter/fighterRig';
import FighterTrappings from './FighterTrappings';
type FramesRef = MutableRefObject<RenderFighter[]>;
const TARGET_HEIGHT = 1.8;
const MODEL_YAW_OFFSET = 0; // tune in browser if Y-Bot faces away from +Z
const HEAD_BONES = ['mixamorigHead', 'mixamorig:Head'];
const HIPS_BONES = ['mixamorigHips', 'mixamorig:Hips'];
const FLASH = new THREE.Color('#ff2244');
const RIG_URL = `${FIGHTER_ASSET_DIR}/${RIG_FILE}`;
const CLIP_URLS = CLIP_KEYS.map((k) => `${FIGHTER_ASSET_DIR}/${CLIPS[k].file}`);
/** Shortest-path angle damp (shared convention with StickFighter). */
function dampAngle(current: number, target: number, t: number): number {
let d = target - current;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return current + d * t;
}
export default function SkeletalFighter({ seat, framesRef, showNameplate = true }: { seat: number; framesRef: FramesRef; showNameplate?: boolean }) {
// Shared, cached loads via FBXLoader. useLoader suspends; a missing file
// throws → the Fighter dispatcher's ErrorBoundary falls back to StickFighter.
const rig = useLoader(FBXLoader, RIG_URL); // skinned Group
const clipScenes = useLoader(FBXLoader, CLIP_URLS); // Group[], one per CLIP_URLS entry
const initial = framesRef.current.find((f) => f.seat === seat);
const colorHex = initial?.color ?? '#cccccc';
const accentHex = initial?.accent ?? '#ffffff';
const plateColor = initial?.isLocal ? '#ffcc00' : accentHex;
const plateLabel = `P${seat + 1}`;
const root = useRef<THREE.Group>(null);
const shadow = useRef<THREE.Mesh>(null);
// Per-seat clone of the rig + its own mixer + actions, built once.
const { model, mixer, actions, bodyMats } = useMemo(() => {
const model = cloneSkeleton(rig) as THREE.Group;
autoScaleToHeight(model, TARGET_HEIGHT);
// Identity: tint every skinned-mesh material (cloned so seats differ).
const baseColor = new THREE.Color(colorHex);
const bodyMats: THREE.MeshStandardMaterial[] = [];
model.traverse((o) => {
const sm = o as THREE.SkinnedMesh;
if (sm.isSkinnedMesh) {
const mat = (sm.material as THREE.MeshStandardMaterial).clone();
mat.color.copy(baseColor);
sm.material = mat;
bodyMats.push(mat);
}
});
// Procedural accessories parented to bones (skip if bone missing).
const head = findBone(model, HEAD_BONES);
if (head) {
const band = new THREE.Mesh(
new THREE.TorusGeometry(0.12, 0.03, 6, 12),
new THREE.MeshStandardMaterial({ color: accentHex }),
);
band.rotation.x = Math.PI / 2;
head.add(band);
}
const hips = findBone(model, HIPS_BONES);
if (hips) {
const belt = new THREE.Mesh(
new THREE.TorusGeometry(0.16, 0.04, 6, 12),
new THREE.MeshStandardMaterial({ color: accentHex }),
);
belt.rotation.x = Math.PI / 2;
hips.add(belt);
}
// Mixer + one action per clip, with root motion stripped.
const mixer = new THREE.AnimationMixer(model);
const actions = {} as Record<ClipKey, THREE.AnimationAction>;
CLIP_KEYS.forEach((key, i) => {
const clip = clipScenes[i].animations[0];
if (!clip) return;
stripRootMotionXZ(clip);
const action = mixer.clipAction(clip);
if (!CLIPS[key].loop) {
action.setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true;
}
actions[key] = action;
});
return { model, mixer, actions, bodyMats };
}, [rig, clipScenes, colorHex, accentHex]);
const currentClip = useRef<ClipKey | null>(null);
// Local-only dance emote: pressing G toggles dancing. Applied only to the
// local fighter and only while idle (any real action overrides it). Purely
// cosmetic / client-side — it is NOT synced to other players.
const dancing = useRef(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'g') dancing.current = !dancing.current;
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
useFrame((state, deltaRaw) => {
const rf = framesRef.current.find((f) => f.seat === seat);
const r = root.current;
if (!rf || !r) return;
const delta = Math.min(0.05, deltaRaw);
// Position + facing (damped), reusing StickFighter's conventions.
r.position.x += (rf.x - r.position.x) * 0.5;
r.position.z += (rf.z - r.position.z) * 0.5;
const faceY = Math.atan2(Math.cos(rf.yaw), Math.sin(rf.yaw));
r.rotation.y = dampAngle(r.rotation.y, faceY, 0.4);
// Clip selection (+ local dance-emote override) and crossfade.
let clip = resolveClip(rf).clip;
if (rf.isLocal && dancing.current && clip === 'idle') clip = 'dance';
const next = actions[clip];
if (next && currentClip.current !== clip) {
const prev = currentClip.current ? actions[currentClip.current] : undefined;
if (!CLIPS[clip].loop) next.reset();
next.fadeIn(CLIPS[clip].fade).play();
if (prev) prev.fadeOut(CLIPS[clip].fade);
currentClip.current = clip;
}
mixer.update(delta);
// Hit flash → emissive pulse; dim when down.
const flash = rf.hitFlash > 0 ? Math.min(1, rf.hitFlash / 8) : 0;
const dim = rf.alive ? 1 : 0.4;
for (const mat of bodyMats) {
mat.emissive.copy(FLASH).multiplyScalar(flash * 0.9);
mat.color.setStyle(colorHex).multiplyScalar(dim);
}
if (shadow.current) shadow.current.scale.setScalar(rf.state === 'knockedOut' ? 1.4 : 1);
});
return (
<group ref={root}>
<FighterTrappings showNameplate={showNameplate} plateColor={plateColor} plateLabel={plateLabel} shadowRef={shadow} />
<group rotation={[0, MODEL_YAW_OFFSET, 0]}>
<primitive object={model} />
</group>
</group>
);
}
Spike notes (browser-only verifiable): The assets exist (
public/kowloon/fighter/*.fbx), so this CAN be browser-verified, but not headlessly.
useLoader(FBXLoader, CLIP_URLS)with an array path returns an array (one Group per url); each Mixamo FBX exposes its clip at.animations[0]. The rig (ybot.fbx) is itself the skinnedGroup(clone it directly — no.scene).Mixamo FBX loads at ~100× scale;
autoScaleToHeightmeasures the bbox and normalizes toTARGET_HEIGHT, so it self-corrects. TuneMODEL_YAW_OFFSETif the model faces away from+Z.
SkeletonUtils.cloneis the documented way to clone a skinned mesh with an independent skeleton — verify each of the (≤4) fighters animates independently.Mixamo bone names may be
mixamorigHipsormixamorig:Hipsdepending on the FBX;findBone/stripRootMotionXZalready match both.The
danceclip (Gangnam Style) is loaded like any other; it is never returned byresolveClip— only the local G-toggle override plays it. Confirm pressing G makes only YOUR fighter dance, and that moving/punching cancels it.
Step 2: Lint
Run: node_modules/.bin/eslint components/kowloon-knockout/arena/SkeletalFighter.tsx
Expected: clean. (If TS infers clipScenes as a non-array union, narrow with const scenes = Array.isArray(clipScenes) ? clipScenes : [clipScenes]; and map over scenes. FBXLoader may need a // @ts-expect-error only if the examples typing is missing — prefer importing the type cleanly first.)
Step 3: [SMOKE] (RUN-OBSERVE, deferred to user)
The assets are already present (public/kowloon/fighter/*.fbx). pnpm dev → match on a WebGPU desktop. Expected: Y-Bot fighters at the right size/facing, idling, walking, throwing the four punches, blocking, reacting to hits, toppling on KO, each animating independently with neon tint + accessories; pressing G makes your own fighter do the dance (and moving/punching cancels it). Mark deferred to user.
Step 4: Commit
git add components/kowloon-knockout/arena/SkeletalFighter.tsx
git commit -m "feat(kowloon): Y-Bot skeletal fighter renderer (FBX) + local dance emote"
Task 7: Fighter.tsx dispatcher + mount in Arena3D (RUN-OBSERVE)¶
Files:
Create:
components/kowloon-knockout/arena/Fighter.tsxModify:
components/kowloon-knockout/arena/Arena3D.tsx
Interfaces:
Consumes:
useRenderTier(./RenderTierContext);StickFighter;SkeletalFighter(Task 6); ReactSuspense,Component.Produces:
<Fighter seat framesRef />— drop-in for the old<StickFighter>in the seat map.Step 1: Implement the dispatcher with fallback
Create components/kowloon-knockout/arena/Fighter.tsx. Low tier → StickFighter. Otherwise render SkeletalFighter wrapped in a Suspense (fallback StickFighter while GLBs load) and a class ErrorBoundary (fallback StickFighter if the assets are missing or fail to load — the default state until the user adds them).
'use client';
import { Suspense, Component, type ReactNode, type MutableRefObject } from 'react';
import type { RenderFighter } from '@/lib/kowloon-knockout/net/session';
import { useRenderTier } from './RenderTierContext';
import StickFighter from './StickFighter';
import SkeletalFighter from './SkeletalFighter';
type FramesRef = MutableRefObject<RenderFighter[]>;
/** Renders `fallback` if anything under it throws (e.g. a missing/failed
* fighter GLB) — keeps the match alive on the procedural StickFighter. */
class FighterBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { error: boolean }> {
state = { error: false };
static getDerivedStateFromError() { return { error: true }; }
componentDidCatch(error: unknown, info: { componentStack?: string }) {
console.warn('[Fighter] skeletal load failed, using StickFighter:', error, info?.componentStack);
}
render() {
if (this.state.error) return this.props.fallback;
return this.props.children;
}
}
/** Per-seat fighter: skeletal Y-Bot on medium/high/ultra, procedural
* StickFighter on low and as the universal fallback (missing/loading/failed
* assets). */
export default function Fighter({ seat, framesRef }: { seat: number; framesRef: FramesRef }) {
const { tier } = useRenderTier();
const stick = <StickFighter seat={seat} framesRef={framesRef} />;
if (tier === 'low') return stick;
return (
<FighterBoundary fallback={stick}>
<Suspense fallback={stick}>
<SkeletalFighter seat={seat} framesRef={framesRef} />
</Suspense>
</FighterBoundary>
);
}
Step 2: Mount in
Arena3D.tsx
In components/kowloon-knockout/arena/Arena3D.tsx: replace import StickFighter from './StickFighter'; with import Fighter from './Fighter';, and change the seat map (around line 51-53) from <StickFighter key={seat} seat={seat} framesRef={framesRef} /> to <Fighter key={seat} seat={seat} framesRef={framesRef} />.
Step 3: Lint
Run: node_modules/.bin/eslint components/kowloon-knockout/arena/Fighter.tsx components/kowloon-knockout/arena/Arena3D.tsx
Expected: clean.
Step 4: [SMOKE] (RUN-OBSERVE, deferred to user) — the no-asset regression gate
With NO public/kowloon/fighter/ assets present (the current state): pnpm dev → match. Expected: every fighter renders as the StickFighter exactly as before, on all tiers, with no console errors beyond the one-time [Fighter] skeletal load failed warning per seat. This proves the fallback path. (After the user adds assets, medium/high/ultra switch to Y-Bot; low stays StickFighter.)
Step 5: Commit
git add components/kowloon-knockout/arena/Fighter.tsx components/kowloon-knockout/arena/Arena3D.tsx
git commit -m "feat(kowloon): Fighter dispatcher — skeletal with StickFighter fallback"
Final integration¶
After all tasks:
Full unit run:
node_modules/.bin/vitest run lib/kowloon-knockout/render— clips + stateMachine + rootMotion + fighterRig (plus all prior phases) pass.[SMOKE] without assets (ships now): every fighter is StickFighter across tiers, no regression — this is the merge gate.
[SMOKE] with assets (after the user adds the GLBs): Y-Bot loads, scales/faces correctly, all clips play + crossfade per state; low tier + asset-error still fall back to StickFighter.
senior-swe-reviewerpass on the branch diff before opening the PR.
Phase 5 (not in this plan)¶
Tier UI + adaptive FPS governor + mobile/WebGL2 validation. Its own brainstorm → spec → plan.