BUILD A RIGGED LOW-POLY
SOLDIER IN THREE.JS

By Shane Brumback · · Free Three.js tutorial

To build a rigged low-poly soldier in Three.js, assemble the body from primitive shapes, bind it to a bone skeleton as a SkinnedMesh, drive its action clips with an AnimationMixer, and move it with WASD keys — blending Walk, Run, Jump, and Fire as the player presses keys. This free tutorial includes a live demo, a one-click rigged GLB export, and the full copy-paste source.

Meet the Vanguard Trooper — a low-poly soldier built entirely in code from boxes, cylinders and spheres, rigged to a 19-bone skeleton and animated with seven action clips. Load nothing, download nothing: it's assembled live in Three.js, controlled with WASD — Shift to run, Space to jump, F to fire — and exportable as a rigged .glb.

SkinnedMesh + Skeleton AnimationMixer WASD + jump + fire One-click rigged GLB
Rigged, animated low-poly 3D soldier (Vanguard Trooper) built in Three.js

LIVE DEMO

The trooper standing behind this page is a live Three.js scene — built from primitives, not loaded from a file. Launch the full-screen demo and walk him around with WASD / arrow keys — hold Shift to run, Space to jump, F to fire. Drag to orbit, scroll to zoom, tap the clip buttons to preview each animation, show the skeleton, or export a rigged GLB.

Questions about rigging or skinning in Three.js? Contact Me and I'll help.

What You'll Build

This tutorial covers the whole pipeline for a code-built character: model it from primitives, rig it to a skeleton, animate it, and control it with the keyboard — no external model file required.

  • Build the body in code from boxes, cylinders and spheres — a chunky, low-poly silhouette with armor plating, a visor and a carbine.
  • Rig it to a 19-bone skeleton — assign each piece to a bone with skinIndex/skinWeight and bind a SkinnedMesh.
  • Author 7 action clips with an AnimationMixer — Idle, Walk, Run, Jump, Fire, Reload, Death — and crossfade between them.
  • WASD / arrow-key movement — camera-relative, with the character turning to face the way it's heading.
  • Shift to run, Space to jump, F to fire — one-shot clips that return to idle when they finish.
  • One-click rigged GLB export — GLTFExporter writes a standard glTF 2.0 file for Three.js, Unity, Blender or Godot.

Controls

MoveW A S D / arrows
RunShift
JumpSpace
FireF
Orbit cameradrag
Zoomscroll wheel

Launch the live demo above and try them. The same controls are implemented in the code below.

FAQ

How do you build a rigged character in Three.js without a GLB?

Build the body from primitive geometries, give each piece a skinIndex/skinWeight pointing at one bone, merge the geometries, and bind them to a THREE.Skeleton with a SkinnedMesh. The Vanguard Trooper is built entirely in code this way, with a 19-bone skeleton and no external file.

How do you play a character's animations in Three.js?

Create a THREE.AnimationMixer bound to the model, then make an action for each clip with mixer.clipAction(clip). Call action.play() to start one and action.crossFadeTo(next, dur) to blend. Advance it each frame with mixer.update(delta).

How do you add WASD movement to a character in Three.js?

Track pressed keys with keydown/keyup. Each frame, build a direction from the camera's forward and right vectors (flattened to the ground), scale by speed × delta, and add it to the model's position. Rotate the model to face travel, and switch to Walk/Run while moving, Idle when stopped.

How do you make the character jump or fire on a key press?

Give the Jump and Fire clips THREE.LoopOnce with clampWhenFinished, then play them on a single keydown (Space / F), ignoring auto-repeat with event.repeat. Listen for the mixer's finished event to return to Idle or resume walking.

Can I export the Vanguard Trooper as a rigged GLB?

Yes. The live demo has a one-click Export GLB button that runs GLTFExporter in your browser and downloads a standard binary glTF 2.0 file with the skeleton, the skinned mesh, and all seven clips. It imports into Blender, Unity, Godot, Babylon.js, or any viewer that reads glTF.

Is this rigged soldier tutorial free?

Yes. It's completely free. The trooper walking behind this page is a live demo, the full Three.js build + controls source is on the page to copy or launch in one click, and you can export a rigged GLB for your own projects. No sign-in or membership required.

How It Works

  1. 1Set up a scene. A renderer, a camera, OrbitControls, a couple of lights, and a ground plane — the usual Three.js starting point.
  2. 2Build + rig the body. Primitive geometries are positioned into a trooper, each tagged to one bone, merged per material, and bound to a THREE.Skeleton as a SkinnedMesh.
  3. 3Author the clips. Each action (Idle, Walk, Run, Jump, Fire, Reload, Death) is a function of time that returns bone rotations, baked into QuaternionKeyframeTracks. Jump/Fire/Death are LoopOnce; the rest loop.
  4. 4Read the keyboard. keydown/keyup listeners track WASD + Shift as held state, and fire Space (jump) and F (fire) once per press.
  5. 5Move the character. Each frame, build a camera-relative direction, step the model by speed × delta, clamp it to the ground, and turn it to face travel. Switch to Walk/Run while moving and Idle when stopped.
  6. 6Export it. GLTFExporter serializes the skinned mesh, skeleton and clips to a binary .glb you can download and reuse.

The Full Code

Paste this into a file named index.html and open it in any modern browser — or hit Launch. Three.js loads from a CDN via the import map and the trooper is built in code, so it runs as-is. It's a complete, minimal character controller: build + rig the model, play its clips, and move it with WASD (Shift run, Space jump, F fire).

<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rigged Low-Poly Soldier (WASD) · Three.js</title>
<style>
  html,body{margin:0;height:100%;overflow:hidden;background:#12100c;font-family:system-ui,Segoe UI,sans-serif;color:#f0e6d8}
  #hud{position:fixed;left:14px;top:14px;padding:12px 14px;background:rgba(20,16,10,.82);border:1px solid #ffb24a55;border-radius:12px;font-size:13px;line-height:1.7}
  #hud b{color:#ffb24a}
  kbd{display:inline-block;min-width:16px;text-align:center;padding:1px 6px;background:#2a2318;border:1px solid #4a3d26;border-bottom-width:2px;border-radius:5px;font:600 12px/1 ui-monospace,Menlo,Consolas,monospace;color:#f0e6d8}
  #loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#12100c;color:#ffb24a;font-size:16px;letter-spacing:1px}
</style>
<script type="importmap">
{ "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
</script></head><body>
<div id="loading">Building trooper...</div>
<div id="hud" style="display:none">
  <div><b>Move</b> <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> &nbsp; <b>Run</b> <kbd>Shift</kbd></div>
  <div><b>Jump</b> <kbd>Space</kbd> &nbsp; <b>Fire</b> <kbd>F</kbd> &nbsp; <b>Orbit</b> drag</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';

// ---------------------------------------------------------------------------
// 1. SCENE / CAMERA / RENDERER
// ---------------------------------------------------------------------------
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x12100c);
scene.fog = new THREE.Fog(0x12100c, 9, 26);

const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.05, 100);
camera.position.set(2.6, 1.8, 4.0);

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.52;
controls.update();

scene.add(new THREE.HemisphereLight(0xffe6bf, 0x2a2518, 0.9));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(3, 6, 4); sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -12, right: 12, top: 12, bottom: -12, near: 0.5, far: 40 });
sun.shadow.bias = -0.0005;
scene.add(sun);
const rim = new THREE.DirectionalLight(0xffb24a, 1.4);
rim.position.set(-3, 2.5, -3); scene.add(rim);

const ground = new THREE.Mesh(new THREE.CircleGeometry(12, 48),
  new THREE.MeshStandardMaterial({ color: 0x2a2820, roughness: 0.95 }));
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
scene.add(new THREE.GridHelper(24, 48, 0xffb24a, 0x3a362a));

// ---------------------------------------------------------------------------
// 2. BUILD + RIG THE TROOPER  (primitives -> SkinnedMesh on a 19-bone skeleton)
// ---------------------------------------------------------------------------
const MAT = {
  armor: new THREE.MeshStandardMaterial({ color: 0x8a7a52, metalness: 0.3, roughness: 0.55 }),
  cloth: new THREE.MeshStandardMaterial({ color: 0x4a4330, metalness: 0.1, roughness: 0.85 }),
  dark:  new THREE.MeshStandardMaterial({ color: 0x24211a, metalness: 0.2, roughness: 0.6 }),
  visor: new THREE.MeshStandardMaterial({ color: 0x3a1f05, emissive: 0xffb24a, emissiveIntensity: 2.2, roughness: 0.3 }),
};
const BONES = {
  Root: [null, [0, 0, 0]], Hips: ['Root', [0, 0.98, 0]], Spine: ['Hips', [0, 1.1, 0]],
  Spine1: ['Spine', [0, 1.28, 0]], Neck: ['Spine1', [0, 1.52, 0]], Head: ['Neck', [0, 1.6, 0]],
};
for (const [S, s] of [['Left', 1], ['Right', -1]]) {
  BONES[S + 'UpperArm'] = ['Spine1', [s * 0.26, 1.45, 0]];
  BONES[S + 'LowerArm'] = [S + 'UpperArm', [s * 0.26, 1.17, 0]];
  BONES[S + 'Hand'] = [S + 'LowerArm', [s * 0.26, 0.93, 0]];
  BONES[S + 'UpLeg'] = ['Hips', [s * 0.1, 0.94, 0]];
  BONES[S + 'Leg'] = [S + 'UpLeg', [s * 0.1, 0.53, 0]];
  BONES[S + 'Foot'] = [S + 'Leg', [s * 0.1, 0.12, 0]];
}
BONES.Weapon = ['RightHand', [-0.26, 0.89, 0]];

function buildTrooper() {
  const parts = [];
  const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _e = new THREE.Euler();
  const add = (g, mat, bone, pos, rot = [0, 0, 0], scl = [1, 1, 1]) => {
    _q.setFromEuler(_e.set(...rot));
    g.applyMatrix4(_m.compose(new THREE.Vector3(...pos), _q, new THREE.Vector3(...scl)));
    parts.push({ g, mat, bone });
  };
  const box = (x, y, z) => new THREE.BoxGeometry(x, y, z);
  const cyl = (r1, r2, h, n = 8) => new THREE.CylinderGeometry(r1, r2, h, n);
  const sph = (r, w = 10, h = 8) => new THREE.SphereGeometry(r, w, h);
  // torso + heavy chest plate
  add(box(0.36, 0.2, 0.24), MAT.cloth, 'Hips', [0, 0.97, 0]);
  add(box(0.4, 0.07, 0.27), MAT.dark, 'Hips', [0, 1.06, 0]);
  add(cyl(0.16, 0.15, 0.2), MAT.cloth, 'Spine', [0, 1.18, 0]);
  add(box(0.46, 0.34, 0.3), MAT.armor, 'Spine1', [0, 1.37, 0]);
  add(box(0.34, 0.22, 0.05), MAT.armor, 'Spine1', [0, 1.37, 0.15], [-0.08, 0, 0]);
  add(box(0.14, 0.14, 0.02), MAT.visor, 'Spine1', [0, 1.4, 0.18]);        // chest emblem
  add(box(0.32, 0.34, 0.14), MAT.dark, 'Spine1', [0, 1.34, -0.2]);         // backpack
  // shoulder pauldrons (bulky, low-poly)
  add(sph(0.13, 8, 6), MAT.armor, 'Spine1', [0.3, 1.5, 0]);
  add(sph(0.13, 8, 6), MAT.armor, 'Spine1', [-0.3, 1.5, 0]);
  // head + helmet
  add(cyl(0.07, 0.075, 0.12, 8), MAT.dark, 'Neck', [0, 1.55, 0]);
  add(box(0.17, 0.12, 0.13), MAT.cloth, 'Head', [0, 1.62, 0.04]);
  add(sph(0.15, 10, 8), MAT.armor, 'Head', [0, 1.68, -0.01], [0, 0, 0], [1, 0.95, 1.1]);
  add(box(0.24, 0.07, 0.07), MAT.visor, 'Head', [0, 1.665, 0.12]);         // visor glow
  add(box(0.07, 0.16, 0.07), MAT.dark, 'Head', [0.12, 1.73, 0]);           // antenna fin
  // limbs
  for (const [S, s] of [['Left', 1], ['Right', -1]]) {
    const x = s * 0.26;
    add(box(0.18, 0.12, 0.23), MAT.armor, S + 'UpperArm', [s * 0.28, 1.48, 0], [0, 0, -s * 0.35]);
    add(cyl(0.062, 0.056, 0.26), MAT.cloth, S + 'UpperArm', [x, 1.31, 0]);
    add(cyl(0.062, 0.052, 0.22), MAT.armor, S + 'LowerArm', [x, 1.05, 0]);
    add(box(0.08, 0.1, 0.09), MAT.dark, S + 'Hand', [x, 0.89, 0]);
    const lx = s * 0.1;
    add(cyl(0.085, 0.07, 0.38), MAT.cloth, S + 'UpLeg', [lx, 0.73, 0]);
    add(box(0.11, 0.14, 0.07), MAT.armor, S + 'UpLeg', [s * 0.17, 0.78, 0.02]); // thigh plate
    add(cyl(0.07, 0.06, 0.36), MAT.cloth, S + 'Leg', [lx, 0.33, 0]);
    add(box(0.1, 0.18, 0.05), MAT.armor, S + 'Leg', [lx, 0.3, 0.07]);           // shin guard
    add(box(0.14, 0.1, 0.28), MAT.dark, S + 'Foot', [lx, 0.06, 0.05]);          // boot
  }
  // carbine (compact)
  const wx = -0.26;
  add(box(0.055, 0.28, 0.09), MAT.dark, 'Weapon', [wx, 0.82, 0.08]);
  add(box(0.057, 0.16, 0.012), MAT.visor, 'Weapon', [wx, 0.82, 0.128]);
  add(box(0.035, 0.07, 0.13), MAT.dark, 'Weapon', [wx, 0.75, -0.02], [0.2, 0, 0]);
  add(cyl(0.016, 0.016, 0.2, 6), MAT.dark, 'Weapon', [wx, 0.56, 0.08]);
  add(cyl(0.024, 0.024, 0.05, 6), MAT.armor, 'Weapon', [wx, 0.46, 0.08]);
  // bones
  const bones = {}, boneList = [];
  for (const [name, [parent, p]] of Object.entries(BONES)) {
    const b = new THREE.Bone(); b.name = name; b.position.set(...p);
    if (parent) { b.position.sub(new THREE.Vector3(...BONES[parent][1])); bones[parent].add(b); }
    bones[name] = b; boneList.push(b);
  }
  const boneIndex = Object.fromEntries(boneList.map((b, i) => [b.name, i]));
  const byMat = new Map();
  for (const { g, mat, bone } of parts) {
    for (const k of Object.keys(g.attributes)) if (!['position', 'normal', 'uv'].includes(k)) g.deleteAttribute(k);
    const n = g.attributes.position.count;
    const si = new Uint16Array(n * 4), sw = new Float32Array(n * 4);
    for (let i = 0; i < n; i++) { si[i * 4] = boneIndex[bone]; sw[i * 4] = 1; }
    g.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(si, 4));
    g.setAttribute('skinWeight', new THREE.Float32BufferAttribute(sw, 4));
    if (!byMat.has(mat)) byMat.set(mat, []);
    byMat.get(mat).push(g);
  }
  const mats = [...byMat.keys()];
  const merged = mergeGeometries(mats.map(m => mergeGeometries(byMat.get(m), false)), true);
  const root = new THREE.Group(); root.name = 'vanguard_trooper';
  root.add(bones.Root); root.updateMatrixWorld(true);
  const skeleton = new THREE.Skeleton(boneList);
  const mesh = new THREE.SkinnedMesh(merged, mats);
  mesh.castShadow = true; mesh.frustumCulled = false;
  root.add(mesh); mesh.bind(skeleton);
  return { root, mesh, skeleton, bones };
}

// ---------------------------------------------------------------------------
// 3. AUTHOR THE ANIMATION CLIPS  (bone rotations baked to keyframe tracks)
// ---------------------------------------------------------------------------
const TAU = Math.PI * 2, lerp = (a, b, t) => a + (b - a) * t, smooth = t => t * t * (3 - 2 * t);
function keyframe(t, ks) {
  if (t <= ks[0][0]) return ks[0][1];
  for (let i = 1; i < ks.length; i++)
    if (t <= ks[i][0]) { const [t0, v0] = ks[i - 1], [t1, v1] = ks[i]; return lerp(v0, v1, smooth((t - t0) / (t1 - t0))); }
  return ks[ks.length - 1][1];
}
const AIM = {
  RightUpperArm: [-0.35, 0, 0.12], RightLowerArm: [-1.2, 0, 0],
  LeftUpperArm: [-0.95, 0, -0.55], LeftLowerArm: [-0.85, 0, -0.1],
};
const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const withAim = (extra = {}) => { const o = {}; for (const k in AIM) o[k] = add3(AIM[k], extra[k] || [0, 0, 0]); return o; };
function makeClip(name, dur, poseFn) {
  const n = Math.round(dur * 30), times = [];
  for (let i = 0; i <= n; i++) times.push((i / n) * dur);
  const samples = times.map(t => poseFn(t, t / dur));
  const q = new THREE.Quaternion(), e = new THREE.Euler();
  const tracks = Object.keys(samples[0]).map(bone => {
    const vals = [];
    for (const s of samples) { q.setFromEuler(e.set(...s[bone])); vals.push(q.x, q.y, q.z, q.w); }
    return new THREE.QuaternionKeyframeTrack(bone + '.quaternion', times, vals);
  });
  return new THREE.AnimationClip(name, dur, tracks);
}
function buildClips() {
  const clips = [];
  clips.push(makeClip('Idle', 3, (t, u) => {
    const b = Math.sin(u * TAU), b2 = Math.sin(u * TAU * 2);
    return { Hips: [0, 0.03 * b, 0], Spine: [0.02, 0, 0], Spine1: [0.03 * b2, 0.08, 0],
      Neck: [0, -0.06, 0], Head: [-0.03 * b2, 0.05 * b, 0],
      ...withAim({ RightUpperArm: [0.03 * b2, 0, 0], LeftUpperArm: [0.03 * b2, 0, 0] }),
      LeftUpLeg: [0, 0, 0.04], RightUpLeg: [0, 0, -0.04], LeftLeg: [0.03, 0, 0], RightLeg: [0.03, 0, 0] };
  }));
  clips.push(makeClip('Walk', 1.1, (t, u) => {
    const p = u * TAU, s = Math.sin(p), c = Math.cos(p), knee = ph => 0.1 + 0.6 * Math.max(0, Math.sin(ph));
    return { Hips: [0, 0.08 * s, 0], Spine: [0.05, -0.05 * s, 0], Spine1: [0.02, 0.04 - 0.04 * s, 0],
      ...withAim({ RightUpperArm: [0.03 * c, 0, 0], LeftUpperArm: [0.03 * c, 0, 0] }),
      LeftUpLeg: [-0.42 * s, 0, 0], RightUpLeg: [0.42 * s, 0, 0],
      LeftLeg: [knee(p + Math.PI * 0.5), 0, 0], RightLeg: [knee(p + Math.PI * 1.5), 0, 0],
      LeftFoot: [0.2 * c, 0, 0], RightFoot: [-0.2 * c, 0, 0] };
  }));
  clips.push(makeClip('Run', 0.7, (t, u) => {
    const p = u * TAU, s = Math.sin(p), c = Math.cos(p), knee = ph => 0.25 + 1.1 * Math.max(0, Math.sin(ph));
    return { Hips: [0, 0.12 * s, 0], Spine: [0.18, -0.08 * s, 0], Spine1: [0.05, -0.06 * s, 0],
      ...withAim({ RightUpperArm: [0.06 * c, 0, 0], LeftUpperArm: [0.06 * c, 0, 0] }),
      LeftUpLeg: [-0.75 * s, 0, 0], RightUpLeg: [0.75 * s, 0, 0],
      LeftLeg: [knee(p + Math.PI * 0.45), 0, 0], RightLeg: [knee(p + Math.PI * 1.45), 0, 0],
      LeftFoot: [0.3 * c, 0, 0], RightFoot: [-0.3 * c, 0, 0] };
  }));
  clips.push(makeClip('Jump', 0.9, t => {
    const thigh = keyframe(t, [[0, 0], [0.12, -0.6], [0.25, 0.1], [0.5, -0.9], [0.9, -0.3]]);
    const knee = keyframe(t, [[0, 0], [0.12, 1.0], [0.25, 0.1], [0.5, 1.4], [0.9, 0.5]]);
    const lean = keyframe(t, [[0, 0], [0.12, 0.35], [0.25, -0.05], [0.5, 0.25], [0.9, 0.15]]);
    return { Spine: [lean, 0, 0], Spine1: [0.05, 0, 0], Neck: [-lean * 0.6, 0, 0],
      ...withAim({ LeftUpperArm: [-0.2 * lean, 0, 0] }),
      LeftUpLeg: [thigh, 0, 0.05], RightUpLeg: [thigh * 0.8, 0, -0.05],
      LeftLeg: [knee, 0, 0], RightLeg: [knee * 0.9, 0, 0] };
  }));
  clips.push(makeClip('Fire', 0.24, t => {
    const k = keyframe(t, [[0, 0], [0.03, 1], [0.24, 0]]);
    return { Spine1: [-0.14 * k, 0.08, 0], Neck: [0.05 * k, -0.06, 0],
      ...withAim({ RightUpperArm: [0.18 * k, 0, 0], RightLowerArm: [0.25 * k, 0, 0],
        LeftUpperArm: [0.15 * k, 0, 0], LeftLowerArm: [0.15 * k, 0, 0] }) };
  }));
  clips.push(makeClip('Reload', 1.6, t => {
    const tilt = keyframe(t, [[0, 0], [0.25, 1], [1.3, 1], [1.6, 0]]);
    const reach = keyframe(t, [[0, 0], [0.3, 0], [0.55, 1], [0.8, 1], [1.05, 0.4], [1.15, 0], [1.6, 0]]);
    return { Spine1: [0.06 * tilt, 0.08 - 0.15 * tilt, 0], Neck: [0.25 * tilt, -0.06, 0],
      ...withAim({ RightUpperArm: [0.1 * tilt, 0, 0.15 * tilt], RightLowerArm: [0.15 * tilt, 0.2 * tilt, 0],
        LeftUpperArm: [0.85 * reach, 0, 0.35 * reach], LeftLowerArm: [-0.4 * reach, 0, 0] }) };
  }));
  clips.push(makeClip('Death', 1.6, t => {
    const fall = keyframe(t, [[0, 0], [0.15, -0.05], [1.1, -1.5], [1.25, -1.42], [1.4, -1.5]]);
    const fling = keyframe(t, [[0, 0], [0.4, 0.3], [1.1, 1], [1.6, 1]]);
    const buckle = keyframe(t, [[0, 0], [0.35, 0.9], [1.1, 0.2], [1.6, 0.25]]);
    return { Root: [fall, 0, 0], Spine: [-0.2 * fling, 0, 0.05 * fling], Neck: [-0.3 * fling, 0.2 * fling, 0],
      RightUpperArm: [lerp(-0.35, -2.2, fling), 0, lerp(0.12, -0.4, fling)], RightLowerArm: [lerp(-1.2, -0.3, fling), 0, 0],
      LeftUpperArm: [lerp(-0.95, -2.4, fling), 0, lerp(-0.55, 0.5, fling)], LeftLowerArm: [lerp(-0.85, -0.4, fling), 0, 0],
      LeftUpLeg: [-0.9 * buckle, 0, 0], RightUpLeg: [-0.6 * buckle, 0, 0],
      LeftLeg: [1.6 * buckle, 0, 0], RightLeg: [1.2 * buckle, 0, 0] };
  }));
  return clips;
}

// ---------------------------------------------------------------------------
// 4. ASSEMBLE + MIXER
// ---------------------------------------------------------------------------
const trooper = buildTrooper();
scene.add(trooper.root);
const mixer = new THREE.AnimationMixer(trooper.root);
const actions = {};
for (const c of buildClips()) {
  const a = mixer.clipAction(c);
  if (/Jump|Fire|Death/.test(c.name)) { a.setLoop(THREE.LoopOnce); a.clampWhenFinished = true; }
  actions[c.name] = a;
}
let current = null, currentName = null, locked = false;
const LOCO = { Idle: 1, Walk: 1, Run: 1 };
function play(name, fade = 0.25) {
  const next = actions[name]; if (!next || current === next) return;
  currentName = name; next.reset().setEffectiveWeight(1).play();
  if (current) current.crossFadeTo(next, fade, false);
  current = next;
}
mixer.addEventListener('finished', () => {
  locked = false;
  if (currentName === 'Death') return;
  const moving = keys.fwd || keys.back || keys.left || keys.right;
  play(moving ? (keys.run ? 'Run' : 'Walk') : 'Idle');
});
play('Idle', 0);
document.getElementById('loading').style.display = 'none';
document.getElementById('hud').style.display = 'block';

// ---------------------------------------------------------------------------
// 5. KEYBOARD CONTROLS — WASD / arrows move, Shift runs, Space jumps, F fires
// ---------------------------------------------------------------------------
const keys = { fwd: false, back: false, left: false, right: false, run: false };
function setKey(code, down) {
  switch (code) {
    case 'KeyW': case 'ArrowUp': keys.fwd = down; return true;
    case 'KeyS': case 'ArrowDown': keys.back = down; return true;
    case 'KeyA': case 'ArrowLeft': keys.left = down; return true;
    case 'KeyD': case 'ArrowRight': keys.right = down; return true;
    case 'ShiftLeft': case 'ShiftRight': keys.run = down; return true;
  }
  return false;
}
function oneShot(name) { if (actions[name]) { locked = true; play(name, 0.08); } }
addEventListener('keydown', (e) => {
  if (setKey(e.code, true)) { e.preventDefault(); return; }
  if (e.repeat) return;
  if (e.code === 'Space') { e.preventDefault(); oneShot('Jump'); }
  if (e.code === 'KeyF') { e.preventDefault(); oneShot('Fire'); }
});
addEventListener('keyup', (e) => { if (setKey(e.code, false)) e.preventDefault(); });

// ---------------------------------------------------------------------------
// 6. MOVEMENT — camera-relative, faces travel direction, clamped to ground
// ---------------------------------------------------------------------------
const SPEED = { walk: 1.6, run: 3.4, radius: 10.5 };
const fwd = new THREE.Vector3(), right = new THREE.Vector3(), dir = new THREE.Vector3(), tmp = new THREE.Vector3();
function updateMovement(dt) {
  const kx = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
  const kz = (keys.fwd ? 1 : 0) - (keys.back ? 1 : 0);
  if (kx !== 0 || kz !== 0) {
    camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
    right.crossVectors(fwd, camera.up).normalize();
    dir.set(0, 0, 0).addScaledVector(fwd, kz).addScaledVector(right, kx).normalize();
    const speed = keys.run ? SPEED.run : SPEED.walk;
    trooper.root.position.addScaledVector(dir, speed * dt);
    const r = Math.hypot(trooper.root.position.x, trooper.root.position.z);
    if (r > SPEED.radius) { trooper.root.position.x *= SPEED.radius / r; trooper.root.position.z *= SPEED.radius / r; }
    const yaw = Math.atan2(dir.x, dir.z);
    let d = yaw - trooper.root.rotation.y; while (d > Math.PI) d -= TAU; while (d < -Math.PI) d += TAU;
    trooper.root.rotation.y += d * Math.min(1, dt * 10);
    controls.target.lerp(tmp.set(trooper.root.position.x, 1, trooper.root.position.z), Math.min(1, dt * 6));
    if (!locked) { const want = keys.run ? 'Run' : 'Walk'; if (LOCO[currentName] && currentName !== want) play(want, 0.15); }
  } else if (!locked && LOCO[currentName] && currentName !== 'Idle') { play('Idle'); }
}

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
  const dt = Math.min(clock.getDelta(), 0.05);
  updateMovement(dt);
  mixer.update(dt);
  controls.update();
  renderer.render(scene, camera);
});
</script>
</body></html>