108 KiB
Animations-Testseite mit Waffen-Dummies — Umsetzungsplan
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: Eine eigene Seite animationen.html, auf der 13 Mannequin-Dummies die 11 Mocap-Clips in Dauerschleife spielen, acht davon mit passender Waffe in der Hand, während der Nutzer mit seiner eigenen Figur dazwischen herumläuft.
Architecture: Zwei Node-Skripte bauen die Quellen einmalig um: tools/assets/motions.mjs überträgt die Xsens-Mocap-Clips auf das Skelett von ual.glb und schreibt public/models/motions/weapons-mocap.glb, tools/assets/weapons.mjs macht aus den fünf Unreal-Meshes je eine public/models/weapons/<id>.glb. Beide bestehen aus reinen, getesteten Rechenfunktionen plus einem main(), das nur liest und schreibt. Die Seite unter src/showroom/ lädt nur fertige Dateien: lineup.js ist die Aufstellungstabelle, dummies.js baut einen Dummy aus Modell, Clip, Fingerhaltung und Waffe, main.js setzt Szene, Spielfigur und Bildtakt zusammen.
Tech Stack: three.js 0.184, three-player-controller 0.6.2, Vite 7, vitest 3.2, @gltf-transform/core|extensions|functions 4.5.0, meshoptimizer 1.2.0, sharp 0.35.4, lil-gui 0.21.
Spec: docs/superpowers/specs/2026-09-17-animationen-design.md
Global Constraints
- Keine neuen Abhängigkeiten. Alles oben steht schon in
package.json; three bleibt auf 0.184.0 festgenagelt (die Node-Platzhalter fürFBXLoaderhängen daran). - Quellen (kommen nie ins Repo,
*.zipsteht in.gitignore):C:/Users/kenso/Downloads/MotionLibrary_Weapons.zip→ entpackt nachC:/Users/kenso/AppData/Local/Temp/leonida-assets/motions/(11 FBX direkt im Ordner)C:/Users/kenso/Downloads/ASISWeaponsPack.zip→ entpackt nachC:/Users/kenso/AppData/Local/Temp/leonida-assets/weapons/(darin der OrdnerWeaponsPack)
- Erzeugte Dateien werden versioniert:
public/models/motions/weapons-mocap.glbundpublic/models/weapons/{pistol,ak47,m4,shotgun870,l96}.glb. - Clip-Namen (genau so, aus den Dateinamen):
hunter_bowArrow_tk01,leftHandThrow_grenade_tk01,leftHandThrow_grenade_tk02,taunt_shootSelfInHead_tk01,taunt_shootSelfInHead_tk02,stabTwist_Knife_tk01,stabTwist_Knife_tk02,idle_loadGun-Hand_tk01,idle_loadGun-Hand_tk02,idle_loadGun-Rifle_tk04,idle_loadGun-Rifle_tk05. - Waffen-IDs:
pistol,ak47,m4,shotgun870,l96. Von der AK47 nur „Option 2 (Same Socketing)". - Maße: Alles in
src/showroom/rechnet in Mannequin-Metern. Die Gruppestageträgt den Weltmaßstab des Spielermodells (player.playerModel.getWorldScale), ausgelesen statt nachgerechnet. - Nicht anfassen:
src/main.js,src/world/,src/ui/,src/characters/,public/data/,tools/extract/,public/models/ual.glb.src/characters/index.jsundsrc/world/{sky,shadows}.jswerden nur importiert. - Arbeitsbaum
A:/developer/GitHub-desktop/gta6/.claude/worktrees/animationen, Branchleonida/animationen, alle Befehle von dort.- Auf
leonida/strassenarbeitet parallel eine andere Sitzung. Der Hauptordner wird nie verändert. - Gestaget wird mit Pfaden, nie mit
git add -A. Keingit stash. - Die Isolationswache lehnt verschachtelte Befehle ab (Variablen als Befehl,
&&-Ketten mit Heredoc, Pfade mit Leerzeichen als Skript). Deshalb einfache Einzelbefehle und ausgeschriebene Pfade.
- Auf
- Stil: Kommentare, Testnamen und Commit-Nachrichten auf Deutsch, wie in den vorhandenen Dateien. Über einem Test, der eine bestimmte Fehlerart abfängt, steht ein Kommentar „Bruch: …". Einrückung vier Leerzeichen, doppelte Anführungszeichen, Semikolons.
- Commit-Nachrichten enden mit einer Leerzeile und
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>. - Tests: einzeln
npx vitest run tests/<datei>, allenpm test. Ausgangsstand:Test Files 18 passed (18),Tests 199 passed | 3 skipped (202).
Vorbereitung (erledigt)
Der Arbeitsbaum steht auf ee49fd7 (lokaler main) plus dem Spec-Commit 70730d9. npm ci ist gelaufen, three-player-controller/dist ist aus dem Hauptordner hineinkopiert (nicht versioniert), der Ausgangsstand ist grün.
Die Rechenwege der beiden Umbau-Skripte sind als Wegwerf-Prototyp unter node_modules/.cache/probe/ schon einmal gegen die echten Dateien gelaufen. Der Code in diesem Plan ist dieser erprobte Stand, die Zahlen in den Erwartungen sind daraus gemessen. node_modules/ ist ignoriert, dort liegt nichts, was committet werden könnte.
- Prüfen:
git status --shortgibt nichts aus,npm testmeldet 18 Dateien und 199 Tests grün.
Task 1: TGA-Leser, Maske und Geometrie der Waffen
Files:
- Create:
tools/assets/weapons.mjs(nur die reinen Funktionen,main()kommt in Task 2) - Test:
tests/weapons.test.js
Interfaces:
-
Consumes: nichts aus früheren Tasks.
-
Produces:
WEAPON_SOURCES:Array<{ id, dir, mesh, base, normal, mask, length: [min, max] }>TEXTURE_SIZE = 1024readTga(bytes: Uint8Array) -> { width, height, data: Uint8Array }(RGBA, oberste Bildzeile zuerst)maskToMetallicRoughness({ width, height, data }) -> Uint8Array(RGB)meshArrays(geometry: THREE.BufferGeometry, quaternion: THREE.Quaternion) -> { positions, normals, uvs }(Float32Array)longestExtent(positions: Float32Array) -> numbercheckLength(id: string, positions: Float32Array, [min, max]) -> numberweaponTarget(id: string) -> string(absoluter Pfad)
-
Schritt 1: Failing Tests schreiben
tests/weapons.test.js:
import { describe, expect, it } from "vitest";
import { BufferAttribute, BufferGeometry, Quaternion, Vector3 } from "three";
import { checkLength, longestExtent, maskToMetallicRoughness, meshArrays, readTga,
TEXTURE_SIZE, weaponTarget, WEAPON_SOURCES } from "../tools/assets/weapons.mjs";
/** Baut eine TGA-Datei wie die Masken im Pack: 18 Byte Kopf, danach BGRA je Pixel in Dateireihenfolge. */
function tgaFile({ width, height, pixels, type = 2, depth = 32, originTop = false }) {
const bytes = new Uint8Array(18 + pixels.length * 4);
bytes[2] = type;
bytes[12] = width;
bytes[14] = height;
bytes[16] = depth;
bytes[17] = 8 | (originTop ? 0x20 : 0);
pixels.forEach(([r, g, b, a], i) => bytes.set([b, g, r, a], 18 + i * 4));
return bytes;
}
describe("readTga", () => {
// Dateireihenfolge: rot, grün, blau, weiß
const pixels = [[255, 0, 0, 10], [0, 255, 0, 20], [0, 0, 255, 30], [255, 255, 255, 40]];
// Bruch: Ursprung unten links übersehen — die Maske läge kopfüber auf der Waffe.
it("dreht die Zeilen um, wenn der Ursprung unten links liegt", () => {
const { width, height, data } = readTga(tgaFile({ width: 2, height: 2, pixels }));
expect([width, height]).toEqual([2, 2]);
expect([...data]).toEqual([0, 0, 255, 30, 255, 255, 255, 40, 255, 0, 0, 10, 0, 255, 0, 20]);
});
it("übernimmt die Reihenfolge, wenn der Ursprung oben liegt", () => {
const { data } = readTga(tgaFile({ width: 2, height: 2, pixels, originTop: true }));
expect([...data]).toEqual([255, 0, 0, 10, 0, 255, 0, 20, 0, 0, 255, 30, 255, 255, 255, 40]);
});
// Bruch: stillschweigend falsch gelesene Datei statt einer verständlichen Meldung.
it("lehnt RLE, 24 Bit und Farbtabellen ab", () => {
expect(() => readTga(tgaFile({ width: 2, height: 2, pixels, type: 10 }))).toThrow(/Typ 10/);
expect(() => readTga(tgaFile({ width: 2, height: 2, pixels, depth: 24 }))).toThrow(/24 Bit/);
const withColorMap = tgaFile({ width: 2, height: 2, pixels });
withColorMap[1] = 1;
expect(() => readTga(withColorMap)).toThrow(/Farbtabelle 1/);
});
it("meldet eine zu kurze Datei", () => {
expect(() => readTga(tgaFile({ width: 4, height: 4, pixels }))).toThrow(/kürzer/);
});
});
describe("maskToMetallicRoughness", () => {
// Bruch: Kanäle vertauscht — Holz glänzte metallisch, Metall wirkte stumpf.
it("legt den Grauwert nach G (Rauheit) und Alpha nach B (Metall)", () => {
const mask = { width: 2, height: 1, data: Uint8Array.from([217, 217, 217, 56, 242, 242, 242, 179]) };
expect([...maskToMetallicRoughness(mask)]).toEqual([255, 217, 56, 255, 242, 179]);
});
});
describe("meshArrays", () => {
function geometry() {
const result = new BufferGeometry();
result.setAttribute("position", new BufferAttribute(Float32Array.from([1, 0, 0, 0, 1, 0, 0, 0, 1]), 3));
result.setAttribute("normal", new BufferAttribute(Float32Array.from([0, 0, 1, 0, 0, 1, 0, 0, 1]), 3));
result.setAttribute("uv", new BufferAttribute(Float32Array.from([0, 0, 1, 0.25, 0.5, 1]), 2));
return result;
}
// Bruch: v nicht gespiegelt — jede Waffentextur läge senkrecht verkehrt.
it("spiegelt v, weil FBX den UV-Ursprung unten und glTF ihn oben hat", () => {
expect([...meshArrays(geometry(), new Quaternion()).uvs]).toEqual([0, 1, 1, 0.75, 0.5, 0]);
});
// Bruch: Knotendrehung übergangen — die L96 zeigte mit dem Lauf nach hinten.
it("dreht Eckpunkte und Normalen mit der Knotendrehung", () => {
const halfTurn = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
const { positions, normals } = meshArrays(geometry(), halfTurn);
expect(positions[0]).toBeCloseTo(-1, 6);
expect(positions[8]).toBeCloseTo(-1, 6);
expect(normals[2]).toBeCloseTo(-1, 6);
});
});
describe("checkLength", () => {
const pistol = WEAPON_SOURCES.find((source) => source.id === "pistol");
const along = (length) => Float32Array.from([0, 0, 0, length, 0.02, 0.01]);
it("misst die längste Ausdehnung", () => {
expect(longestExtent(along(0.189))).toBeCloseTo(0.189, 6);
});
it("lässt eine 0,19 m lange Pistole durch", () => {
expect(checkLength("pistol", along(0.189), pistol.length)).toBeCloseTo(0.189, 6);
});
// Bruch: Knotenmaßstab 100 der Pistole mitgerechnet — eine 19 m lange Waffe in der Hand.
it("bricht ab, wenn die Einheit nicht stimmt", () => {
expect(() => checkLength("pistol", along(18.9), pistol.length)).toThrow(/falsche Einheit/);
});
});
describe("WEAPON_SOURCES", () => {
it("nennt die fünf Waffen und die AK47 nur als Option 2", () => {
expect(WEAPON_SOURCES.map((source) => source.id)).toEqual(["pistol", "ak47", "m4", "shotgun870", "l96"]);
expect(WEAPON_SOURCES.find((source) => source.id === "ak47").dir).toBe("AK47_Option2(Same Socketing)");
expect(TEXTURE_SIZE).toBe(1024);
});
it("legt jede Waffe unter public/models/weapons ab", () => {
expect(weaponTarget("pistol").replace(/\\/g, "/")).toMatch(/public\/models\/weapons\/pistol\.glb$/);
expect(() => weaponTarget("../ual")).toThrow(/Ungültige Waffen-ID/);
});
});
- Schritt 2: Tests laufen lassen, sie müssen scheitern
Run: npx vitest run tests/weapons.test.js
Expected: FAIL, „Failed to resolve import … tools/assets/weapons.mjs".
- Schritt 3: Reine Funktionen schreiben
tools/assets/weapons.mjs:
// Baut die Waffen aus dem ASIS Weapons Pack zu GLB um (docs/superpowers/specs/2026-09-17-animationen-design.md,
// "Umbau der Waffen"):
//
// node tools/assets/weapons.mjs <Ordner WeaponsPack> -> public/models/weapons/<id>.glb
//
// Das Pack bleibt außerhalb des Repos. Die Geometrie kommt über three.js' FBXLoader, die Materialien werden neu
// gebaut: T_ ist die Grundfarbe, N_ die Normalmap (schon im OpenGL-Format von glTF), M_ eine TGA-Maske mit
// Grau = Rauheit und Alpha = Metall (am AK47-Holz gemessen, s. Entwurf).
import { fileURLToPath } from "node:url";
import { Vector3 } from "three";
export const TEXTURE_SIZE = 1024;
/** Die fünf Waffen des Packs. length ist die erwartete Länge in Metern und fängt eine falsche Einheit ab. */
export const WEAPON_SOURCES = [
{ id: "pistol", dir: "Pistol", mesh: "SM_Pistol.fbx", base: "T_Pistol.png", normal: "N_Pistol.png", mask: "M_Pistol.tga", length: [0.15, 0.25] },
{ id: "ak47", dir: "AK47_Option2(Same Socketing)", mesh: "SM_AK47.fbx", base: "T_AK47.png", normal: "N_AK47.png", mask: "M_AK47.tga", length: [0.7, 1.3] },
{ id: "m4", dir: "M4 Carbine", mesh: "SM_M4_Carbine.fbx", base: "T_M4_Carbine.png", normal: "N_M4_Carbine.png", mask: "M_M4_Carbine.tga", length: [0.7, 1.3] },
{ id: "shotgun870", dir: "870 Shotgun", mesh: "SM_870_Shotgun.fbx", base: "T_870_Shotgun.png", normal: "N_870_Shotgun.png", mask: "M_870_Shotgun.tga", length: [0.7, 1.3] },
{ id: "l96", dir: "L96 Sniper Rifle", mesh: "SM_L96_Sniper_Rifle.fbx", base: "T_L96_Sniper_Rifle.png", normal: "N_L96_Sniper_Rifle.png", mask: "M_L96_Sniper_Rifle.tga", length: [0.7, 1.3] },
];
/**
* Liest ein unkomprimiertes 32-Bit-TGA (Typ 2) und gibt RGBA mit der obersten Bildzeile zuerst zurück. Mehr kann
* der Leser nicht, und mehr steckt auch nicht im Pack: alle fünf Masken sind Typ 2, 32 Bit, Ursprung unten links.
*/
export function readTga(bytes) {
const idLength = bytes[0];
const colorMapType = bytes[1];
const imageType = bytes[2];
const width = bytes[12] | (bytes[13] << 8);
const height = bytes[14] | (bytes[15] << 8);
const depth = bytes[16];
const flags = bytes[17];
if (colorMapType !== 0 || imageType !== 2 || depth !== 32) {
throw new Error(`TGA: nur unkomprimiert mit 32 Bit lesbar (Typ ${imageType}, ${depth} Bit, Farbtabelle ${colorMapType})`);
}
if (flags & 0x10) throw new Error("TGA: Zeilen von rechts nach links werden nicht unterstützt");
const start = 18 + idLength;
if (bytes.length < start + width * height * 4) throw new Error("TGA: Datei kürzer als ihre Bildgröße");
const originTop = (flags & 0x20) !== 0;
const data = new Uint8Array(width * height * 4);
for (let row = 0; row < height; row++) {
const sourceRow = originTop ? row : height - 1 - row;
for (let x = 0; x < width; x++) {
const from = start + (sourceRow * width + x) * 4;
const to = (row * width + x) * 4;
data[to] = bytes[from + 2]; // TGA speichert BGRA
data[to + 1] = bytes[from + 1];
data[to + 2] = bytes[from];
data[to + 3] = bytes[from + 3];
}
}
return { width, height, data };
}
/** Maske -> metallicRoughness von glTF: Rauheit nach G, Metall nach B. R bleibt ungenutzt. */
export function maskToMetallicRoughness({ width, height, data }) {
const rgb = new Uint8Array(width * height * 3);
for (let i = 0; i < width * height; i++) {
rgb[i * 3] = 255;
rgb[i * 3 + 1] = data[i * 4];
rgb[i * 3 + 2] = data[i * 4 + 3];
}
return rgb;
}
/**
* Eckpunkte, Normalen und UVs eines FBX-Meshes für glTF. Die Knotendrehung wird eingerechnet (die L96 liegt im
* Pack um 180° gedreht), der Knotenmaßstab nicht: Die Eckpunkte liegen schon in Metern, die Pistole trägt
* zusätzlich den Maßstab 100. Die v-Achse wird gespiegelt, weil FBX den UV-Ursprung unten links hat, glTF oben links.
*/
export function meshArrays(geometry, quaternion) {
const source = geometry.index ? geometry.toNonIndexed() : geometry;
const position = source.getAttribute("position");
const normal = source.getAttribute("normal");
const uv = source.getAttribute("uv");
if (!position || !normal || !uv) throw new Error("Mesh ohne position, normal oder uv");
const positions = new Float32Array(position.count * 3);
const normals = new Float32Array(position.count * 3);
const uvs = new Float32Array(position.count * 2);
const point = new Vector3();
for (let i = 0; i < position.count; i++) {
point.fromBufferAttribute(position, i).applyQuaternion(quaternion).toArray(positions, i * 3);
point.fromBufferAttribute(normal, i).applyQuaternion(quaternion).normalize().toArray(normals, i * 3);
uvs[i * 2] = uv.getX(i);
uvs[i * 2 + 1] = 1 - uv.getY(i);
}
return { positions, normals, uvs };
}
/** Längste Ausdehnung in Metern. */
export function longestExtent(positions) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < positions.length; i += 3) {
for (let axis = 0; axis < 3; axis++) {
min[axis] = Math.min(min[axis], positions[i + axis]);
max[axis] = Math.max(max[axis], positions[i + axis]);
}
}
return Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
}
/** Bricht ab, wenn die Waffe nicht die erwartete Länge hat — dann stimmt die Einheit nicht. */
export function checkLength(id, positions, [min, max]) {
const length = longestExtent(positions);
if (!(length >= min && length <= max)) {
throw new Error(`${id}: ${length.toFixed(3)} m lang, erwartet ${min}–${max} m — falsche Einheit?`);
}
return length;
}
/** Zielpfad einer Waffe, unabhängig vom Arbeitsverzeichnis (wie placeTarget in tools/assets/places.mjs). */
export function weaponTarget(id) {
if (!/^[a-z0-9]+$/.test(id)) throw new Error(`Ungültige Waffen-ID "${id}": nur Kleinbuchstaben und Ziffern.`);
return fileURLToPath(new URL(`../../public/models/weapons/${id}.glb`, import.meta.url));
}
- Schritt 4: Tests laufen lassen, sie müssen grün sein
Run: npx vitest run tests/weapons.test.js
Expected: PASS, 12 Tests.
- Schritt 5: Committen
git add tools/assets/weapons.mjs tests/weapons.test.js
git commit -m "feat(assets): TGA-Maske, UV-Spiegelung und Längenprüfung für die Waffen"
Task 2: Die fünf Waffen bauen
Files:
- Create:
tools/assets/fbx.mjs - Modify:
tools/assets/weapons.mjs(Importe ergänzen,encodeWebp,buildWeapon,main) - Create:
public/models/weapons/{pistol,ak47,m4,shotgun870,l96}.glb(erzeugt) - Test:
tests/showroom.test.js(neu, zunächst nur der Waffenteil)
Interfaces:
-
Consumes:
WEAPON_SOURCES,TEXTURE_SIZE,readTga,maskToMetallicRoughness,meshArrays,checkLength,weaponTargetaus Task 1. -
Produces:
loadFbx(path: string) -> Promise<THREE.Group>intools/assets/fbx.mjsbuildWeapon(source, packDir, loadFbx) -> Promise<{ document, length, vertices }>intools/assets/weapons.mjs- die fünf GLB-Dateien, je ein Mesh mit einem Material und drei WebP-Texturen
-
Schritt 1: Quelle entpacken
mkdir -p "C:/Users/kenso/AppData/Local/Temp/leonida-assets/weapons"
unzip -o -q "C:/Users/kenso/Downloads/ASISWeaponsPack.zip" -d "C:/Users/kenso/AppData/Local/Temp/leonida-assets/weapons"
Erwartet: C:/Users/kenso/AppData/Local/Temp/leonida-assets/weapons/WeaponsPack/Pistol/SM_Pistol.fbx liegt da.
- Schritt 2: Asset-Test schreiben (scheitert, weil es die Dateien noch nicht gibt)
tests/showroom.test.js:
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { TEXTURE_SIZE, WEAPON_SOURCES } from "../tools/assets/weapons.mjs";
describe.each(WEAPON_SOURCES.map((source) => [source.id, source]))("%s.glb", (id, source) => {
const file = fileURLToPath(new URL(`../public/models/weapons/${id}.glb`, import.meta.url));
let document;
beforeAll(async () => {
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
document = await io.read(file);
}, 30000);
it("hat ein Mesh mit einem Primitive und einem Material", () => {
const root = document.getRoot();
expect(root.listMeshes()).toHaveLength(1);
expect(root.listMeshes()[0].listPrimitives()).toHaveLength(1);
expect(root.listMaterials()).toHaveLength(1);
});
// Bruch: Aufbereitung übersprungen — rohe Geometrie und PNG-Texturen bis 4096 px im Spiel.
it("nutzt meshopt und WebP", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"]));
});
it("hat drei Texturen bis 1024 px", () => {
const textures = document.getRoot().listTextures();
expect(textures).toHaveLength(3);
for (const texture of textures) expect(Math.max(...texture.getSize())).toBeLessThanOrEqual(TEXTURE_SIZE);
});
// Bruch: falsche Einheit — die Waffe wäre hundertmal zu groß oder zu klein in der Hand.
it("ist so lang wie eine echte Waffe", () => {
const { min, max } = getBounds(document.getRoot().listScenes()[0]);
const length = Math.max(...max.map((value, axis) => value - min[axis]));
expect(length).toBeGreaterThanOrEqual(source.length[0]);
expect(length).toBeLessThanOrEqual(source.length[1]);
});
});
Run: npx vitest run tests/showroom.test.js
Expected: FAIL, „ENOENT … public/models/weapons/pistol.glb".
- Schritt 3: FBX-Lader für Node schreiben
tools/assets/fbx.mjs:
// three.js' FBXLoader läuft auch in Node, braucht dafür aber Browser-Platzhalter: `self` und `window` für den
// Loader-Code, `document.createElementNS` für die Bilder, die sein TextureLoader zu referenzierten Texturen anlegt.
// Diese Bilder laden nie, das schadet nicht: tools/assets/weapons.mjs baut die Materialien selbst.
// Nur die Umbau-Skripte importieren diese Datei, und zwar erst in main() — Tests sollen die Platzhalter nie sehen.
import { readFile } from "node:fs/promises";
globalThis.self ??= globalThis;
globalThis.window ??= globalThis;
globalThis.document ??= {
createElementNS: () => ({ style: {}, setAttribute() {}, addEventListener() {}, removeEventListener() {} }),
};
const { FBXLoader } = await import("three/examples/jsm/loaders/FBXLoader.js");
/** Liest eine FBX-Datei. Die Warnung zu den orthografischen Producer-Kameras von MotionBuilder wird verschluckt. */
export async function loadFbx(path) {
const bytes = await readFile(path);
const warn = console.warn;
console.warn = (...args) => {
if (!String(args[0]).includes("Orthographic cameras")) warn(...args);
};
try {
return new FBXLoader().parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "");
} finally {
console.warn = warn;
}
}
- Schritt 4: Umbau in
tools/assets/weapons.mjsergänzen
Die beiden Importzeilen und die Zeile export const TEXTURE_SIZE = 1024; ersetzen durch:
import { mkdir, readFile, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, EXTTextureWebP } from "@gltf-transform/extensions";
import { getBounds, meshopt, weld } from "@gltf-transform/functions";
import { MeshoptEncoder } from "meshoptimizer";
import sharp from "sharp";
import { Vector3 } from "three";
export const TEXTURE_SIZE = 1024;
const QUALITY = 80;
// Verlustbehaftetes WebP erzeugt in Normal-Maps sonst Blockmuster (wie in tools/assets/places.mjs).
const NORMAL_QUALITY = 92;
Ans Dateiende anhängen:
async function encodeWebp(image, quality) {
const buffer = await image
.resize({ width: TEXTURE_SIZE, height: TEXTURE_SIZE, fit: "inside", withoutEnlargement: true })
.webp({ quality })
.toBuffer();
return new Uint8Array(buffer);
}
/** Baut das Dokument einer Waffe. loadFbx kommt von außen, damit nur main() die Browser-Platzhalter zieht. */
export async function buildWeapon(source, packDir, loadFbx) {
const dir = join(packDir, source.dir);
const fbx = await loadFbx(join(dir, source.mesh));
let mesh = null;
fbx.traverse((object) => { if (!mesh && object.isMesh) mesh = object; });
if (!mesh) throw new Error(`${source.id}: kein Mesh in ${source.mesh}`);
const { positions, normals, uvs } = meshArrays(mesh.geometry, mesh.quaternion);
const length = checkLength(source.id, positions, source.length);
const document = new Document();
const buffer = document.createBuffer();
const accessor = (array, type) => document.createAccessor().setArray(array).setType(type).setBuffer(buffer);
const mask = readTga(await readFile(join(dir, source.mask)));
const [baseColor, normalMap, metallicRoughness] = await Promise.all([
encodeWebp(sharp(join(dir, source.base)).removeAlpha(), QUALITY),
encodeWebp(sharp(join(dir, source.normal)), NORMAL_QUALITY),
encodeWebp(sharp(Buffer.from(maskToMetallicRoughness(mask)),
{ raw: { width: mask.width, height: mask.height, channels: 3 } }), QUALITY),
]);
const texture = (name, image) => document.createTexture(name).setImage(image).setMimeType("image/webp");
const material = document.createMaterial(source.id)
.setBaseColorTexture(texture(`${source.id}_baseColor`, baseColor))
.setNormalTexture(texture(`${source.id}_normal`, normalMap))
.setMetallicRoughnessTexture(texture(`${source.id}_metallicRoughness`, metallicRoughness))
.setMetallicFactor(1)
.setRoughnessFactor(1);
const primitive = document.createPrimitive()
.setAttribute("POSITION", accessor(positions, "VEC3"))
.setAttribute("NORMAL", accessor(normals, "VEC3"))
.setAttribute("TEXCOORD_0", accessor(uvs, "VEC2"))
.setMaterial(material);
const node = document.createNode(source.id).setMesh(document.createMesh(source.id).addPrimitive(primitive));
document.getRoot().setDefaultScene(document.createScene(source.id).addChild(node));
document.createExtension(EXTTextureWebP).setRequired(true);
await MeshoptEncoder.ready;
await document.transform(weld(), meshopt({ encoder: MeshoptEncoder, level: "medium" }));
return { document, length, vertices: positions.length / 3 };
}
async function main() {
const [packDir] = process.argv.slice(2);
if (!packDir) {
console.error("Aufruf: node tools/assets/weapons.mjs <Ordner WeaponsPack aus ASISWeaponsPack.zip>");
process.exit(1);
}
const { loadFbx } = await import("./fbx.mjs");
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.encoder": MeshoptEncoder });
for (const source of WEAPON_SOURCES) {
const { document, length, vertices } = await buildWeapon(source, packDir, loadFbx);
const target = weaponTarget(source.id);
await mkdir(dirname(target), { recursive: true });
await io.write(target, document);
const { min, max } = getBounds(document.getRoot().listScenes()[0]);
console.log(`${source.id}.glb ${((await stat(target)).size / 1e6).toFixed(2)} MB, ${length.toFixed(2)} m lang,`,
`${vertices} Eckpunkte, Maße ${min.map((value, axis) => (max[axis] - value).toFixed(2)).join(" × ")} m`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
- Schritt 5: Umbau laufen lassen
node tools/assets/weapons.mjs "C:/Users/kenso/AppData/Local/Temp/leonida-assets/weapons/WeaponsPack"
Erwartet (im Prototyp gemessen):
pistol.glb 0.16 MB, 0.19 m lang, 8592 Eckpunkte, Maße 0.19 × 0.13 × 0.02 m
ak47.glb 0.23 MB, 0.87 m lang, 15534 Eckpunkte, Maße 0.87 × 0.24 × 0.03 m
m4.glb 0.21 MB, 0.86 m lang, 17403 Eckpunkte, Maße 0.86 × 0.26 × 0.06 m
shotgun870.glb 0.12 MB, 0.99 m lang, 7812 Eckpunkte, Maße 0.99 × 0.19 × 0.03 m
l96.glb 0.14 MB, 1.17 m lang, 21318 Eckpunkte, Maße 1.17 × 0.20 × 0.06 m
- Schritt 6: Tests laufen lassen
Run: npx vitest run tests/showroom.test.js
Expected: PASS, 20 Tests (vier je Waffe).
- Schritt 7: Committen
git add tools/assets/fbx.mjs tools/assets/weapons.mjs tests/showroom.test.js public/models/weapons
git commit -m "feat(assets): fünf Waffen aus dem Pack als GLB mit WebP und meshopt"
Task 3: Mocap auf das Mannequin übertragen
Files:
- Create:
tools/assets/motions.mjs(Zuordnung und Übertragung; Aufräumen folgt in Task 4,main()in Task 5) - Test:
tests/motions.test.js
Interfaces:
-
Consumes: nichts aus früheren Tasks.
-
Produces:
BONE_MAP: Record<MVN-Name, Mannequin-Name>(22 Einträge),SOURCE_HIPS = "Hips"clipId(fileName: string) -> stringrigWorld(rig) -> Map<name, { matrix: Matrix4, quaternion: Quaternion }>, rig istArray<{ name, parent, position, quaternion, scale? }>in Eltern-vor-Kind-ReihenfolgecreateRetargeter(rig, { boneMap?, hipsScale? }) -> (frame) => { local: Map<name, Quaternion>, hips: Vector3 }, frame ist{ rotations: Map<MVN-Name, Quaternion>, hips: Vector3 }in cm
-
Schritt 1: Failing Tests schreiben
tests/motions.test.js:
import { describe, expect, it } from "vitest";
import { MathUtils, Quaternion, Vector3 } from "three";
import { BONE_MAP, clipId, createRetargeter, rigWorld } from "../tools/assets/motions.mjs";
const turn = (x, y, z, degrees) =>
new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), MathUtils.degToRad(degrees));
/**
* Kleines Zielskelett im Bau des Mannequins: root dreht −90° um X (Blender-Z-oben), darunter Becken, Brust,
* Schlüsselbein, Oberarm und eine nicht zugeordnete Hand. Die Ruhedrehungen sind absichtlich schief, damit eine
* vertauschte Reihenfolge der Quaternionen auffällt.
*/
const RIG = [
{ name: "root", parent: null, position: [0, 0, 0], quaternion: turn(1, 0, 0, -90).toArray(), scale: [1, 1, 1] },
{ name: "pelvis", parent: "root", position: [0, 0.05, 0.917], quaternion: turn(1, 0, 0, 104.5).toArray(), scale: [1, 1, 1] },
{ name: "spine_03", parent: "pelvis", position: [0, 0.4, 0], quaternion: turn(1, 0, 0, -12).toArray(), scale: [1, 1, 1] },
{ name: "clavicle_l", parent: "spine_03", position: [0.03, 0.2, 0], quaternion: turn(0.3, -0.7, 0.2, 80).toArray(), scale: [1, 1, 1] },
{ name: "upperarm_l", parent: "clavicle_l", position: [0, 0.15, 0], quaternion: turn(0, 0, 1, 15).toArray(), scale: [1, 1, 1] },
{ name: "hand_l", parent: "upperarm_l", position: [0, 0.27, 0], quaternion: turn(0, 1, 0, 5).toArray(), scale: [1, 1, 1] },
];
const MAP = { Hips: "pelvis", Spine3: "spine_03", LeftShoulder: "clavicle_l", LeftArm: "upperarm_l" };
const restFrame = () => ({
rotations: new Map(Object.keys(MAP).map((name) => [name, new Quaternion()])),
hips: new Vector3(0, 90, 0),
});
const restOf = (name) => new Quaternion().fromArray(RIG.find((bone) => bone.name === name).quaternion);
/** Quaternionen vergleichen, ohne dass acos nahe 0 die Genauigkeit frisst. */
const expectSameRotation = (actual, expected) => expect(1 - Math.abs(actual.dot(expected))).toBeLessThan(1e-9);
describe("clipId", () => {
it("liest den Namen aus jedem der elf Dateinamen", () => {
expect(clipId("20210715_s231_idle_loadGun-Hand_tk01_ERJA-mvn359.fbx")).toBe("idle_loadGun-Hand_tk01");
expect(clipId("20210715_s231_idle_loadGun-Rifle_tk04_ERJA-mvn362.fbx")).toBe("idle_loadGun-Rifle_tk04");
expect(clipId("20210714_s022_hunter_bowArrow_tk01_ERJA-mvn006.fbx")).toBe("hunter_bowArrow_tk01");
expect(clipId("20210714_s055_leftHandThrow_grenade_tk02_ERJA-mvn052.fbx")).toBe("leftHandThrow_grenade_tk02");
expect(clipId("20210714_s077_taunt_shootSelfInHead_tk01_ERJA-mvn072.fbx")).toBe("taunt_shootSelfInHead_tk01");
expect(clipId("20210714_s085_stabTwist_Knife_tk02_ERJA-mvn086.fbx")).toBe("stabTwist_Knife_tk02");
});
// Bruch: eine fremde Datei im Ordner würde sonst einen unbrauchbaren Clipnamen erzeugen.
it("lehnt fremde Dateinamen ab", () => {
expect(() => clipId("SM_Pistol.fbx")).toThrow(/Unbekannter Dateiname/);
});
});
describe("BONE_MAP", () => {
it("ordnet 22 Knochen zu, ohne Spine2 und ohne doppeltes Ziel", () => {
const targets = Object.values(BONE_MAP);
expect(targets).toHaveLength(22);
expect(new Set(targets).size).toBe(22);
expect(BONE_MAP.Spine2).toBeUndefined();
expect(BONE_MAP.Hips).toBe("pelvis");
expect(BONE_MAP.LeftToeBase).toBe("ball_l");
});
});
describe("rigWorld", () => {
it("verkettet Ruhedrehungen und Ruhepositionen", () => {
const world = rigWorld(RIG);
const pelvis = new Vector3().setFromMatrixPosition(world.get("pelvis").matrix);
expect(pelvis.x).toBeCloseTo(0, 9);
expect(pelvis.y).toBeCloseTo(0.917, 9);
expect(pelvis.z).toBeCloseTo(-0.05, 9);
expectSameRotation(world.get("pelvis").quaternion, restOf("root").clone().multiply(restOf("pelvis")));
});
// Bruch: Reihenfolge im rig vertauscht — die Kette rechnete mit einem noch leeren Elternknochen.
it("verlangt Eltern vor Kindern", () => {
expect(() => rigWorld([RIG[1], RIG[0]])).toThrow(/steht nicht vor/);
});
});
describe("createRetargeter", () => {
// Bruch: falsche Formel — die Figur stünde dauerhaft verdreht statt in ihrer Ruhepose.
it("macht aus der MVN-Ruhepose die Ruhepose des Mannequins", () => {
const { local } = createRetargeter(RIG, { boneMap: MAP })(restFrame());
for (const name of Object.values(MAP)) expectSameRotation(local.get(name), restOf(name));
});
it("gibt nicht zugeordneten Knochen keine Spur", () => {
const { local } = createRetargeter(RIG, { boneMap: MAP })(restFrame());
expect(local.has("hand_l")).toBe(false);
expect(local.has("root")).toBe(false);
expect(local.size).toBe(4);
});
it("überträgt eine Weltdrehung am Oberarm eins zu eins", () => {
const rotation = turn(0, 0, 1, 90);
const frame = restFrame();
frame.rotations.set("LeftArm", rotation);
const { local } = createRetargeter(RIG, { boneMap: MAP })(frame);
const rest = rigWorld(RIG);
// Der Schlüsselbein-Knochen bleibt in Ruhe, also ist seine Weltdrehung die Ruhe-Weltdrehung.
const world = rest.get("clavicle_l").quaternion.clone().multiply(local.get("upperarm_l"));
expectSameRotation(world, rotation.clone().multiply(rest.get("upperarm_l").quaternion));
});
// Bruch: Elternkette übersehen — eine Drehung der Brust würde Arme und Kopf zusätzlich verbiegen.
it("lässt die lokale Drehung der Kinder unverändert, wenn sich nur der Elternknochen dreht", () => {
const rotation = turn(0, 1, 0, 40);
const frame = restFrame();
for (const name of ["Spine3", "LeftShoulder", "LeftArm"]) frame.rotations.set(name, rotation);
const { local } = createRetargeter(RIG, { boneMap: MAP })(frame);
expectSameRotation(local.get("clavicle_l"), restOf("clavicle_l"));
expectSameRotation(local.get("upperarm_l"), restOf("upperarm_l"));
expect(1 - Math.abs(local.get("spine_03").dot(restOf("spine_03")))).toBeGreaterThan(1e-3);
});
// Bruch: cm als m gelesen oder Raum verwechselt — die Figur stünde 90 m über dem Boden oder läge flach.
it("rechnet die Hüfte in Meter um, skaliert sie und legt sie in den Raum von root", () => {
const frame = restFrame();
frame.hips.set(10, 90, 20);
const { hips } = createRetargeter(RIG, { boneMap: MAP, hipsScale: 1.02 })(frame);
expect(hips.x).toBeCloseTo(0.102, 9);
expect(hips.y).toBeCloseTo(-0.204, 9);
expect(hips.z).toBeCloseTo(0.918, 9);
});
it("verlangt einen unbewegten Elternknochen über der Hüfte", () => {
expect(() => createRetargeter(RIG, { boneMap: { ...MAP, Reference: "root" } })).toThrow(/darf nicht animiert sein/);
});
});
- Schritt 2: Tests laufen lassen, sie müssen scheitern
Run: npx vitest run tests/motions.test.js
Expected: FAIL, „Failed to resolve import … tools/assets/motions.mjs".
- Schritt 3: Zuordnung und Übertragung schreiben
tools/assets/motions.mjs:
// Überträgt die Mocap-Clips aus MotionLibrary_Weapons.zip (Xsens MVN, 24 Knochen, cm) auf das Skelett des
// Mannequins aus public/models/ual.glb (docs/superpowers/specs/2026-09-17-animationen-design.md, "Umbau der
// Animationen"):
//
// node tools/assets/motions.mjs <Ordner mit den 11 FBX> -> public/models/motions/weapons-mocap.glb
//
// Das Zip bleibt außerhalb des Repos. Die Rechenschritte sind reine Funktionen (tests/motions.test.js), main()
// liest nur Dateien und schreibt das Ergebnis.
import { Matrix4, Quaternion, Vector3 } from "three";
/**
* MVN-Knochen -> Mannequin-Knochen, zugeordnet nach gemessener Höhe in der Ruhepose. Spine2 (1,187 m) hat kein
* Gegenstück: Die Übertragung rechnet mit Weltdrehungen, seine Drehung steckt deshalb in der von spine_03.
*/
export const BONE_MAP = {
Hips: "pelvis",
Spine: "spine_01",
Spine1: "spine_02",
Spine3: "spine_03",
Neck: "neck_01",
Head: "Head",
LeftShoulder: "clavicle_l",
LeftArm: "upperarm_l",
LeftForeArm: "lowerarm_l",
LeftHand: "hand_l",
RightShoulder: "clavicle_r",
RightArm: "upperarm_r",
RightForeArm: "lowerarm_r",
RightHand: "hand_r",
LeftUpLeg: "thigh_l",
LeftLeg: "calf_l",
LeftFoot: "foot_l",
LeftToeBase: "ball_l",
RightUpLeg: "thigh_r",
RightLeg: "calf_r",
RightFoot: "foot_r",
RightToeBase: "ball_r",
};
export const SOURCE_HIPS = "Hips";
/** Clip-Name aus dem Dateinamen: 20210715_s231_idle_loadGun-Hand_tk01_ERJA-mvn359.fbx -> idle_loadGun-Hand_tk01. */
export function clipId(fileName) {
const match = /^\d{8}_s\d+_(.+)_ERJA-mvn\d+\.fbx$/.exec(fileName);
if (!match) throw new Error(`Unbekannter Dateiname: ${fileName}`);
return match[1];
}
/**
* Ruhepose eines Zielskeletts in Weltkoordinaten. rig ist eine Liste in Eltern-vor-Kind-Reihenfolge:
* { name, parent, position: [x, y, z], quaternion: [x, y, z, w], scale: [x, y, z] }. Liefert je Knochen die
* Weltmatrix und die Weltdrehung (Drehungen verkettet, das Mannequin hat überall Maßstab 1).
*/
export function rigWorld(rig) {
const world = new Map();
for (const bone of rig) {
const parent = bone.parent === null ? null : world.get(bone.parent);
if (bone.parent !== null && !parent) throw new Error(`Rig: ${bone.parent} steht nicht vor ${bone.name}`);
const rotation = new Quaternion().fromArray(bone.quaternion);
const local = new Matrix4().compose(
new Vector3().fromArray(bone.position),
rotation,
new Vector3().fromArray(bone.scale ?? [1, 1, 1]),
);
world.set(bone.name, {
matrix: parent ? parent.matrix.clone().multiply(local) : local,
quaternion: parent ? parent.quaternion.clone().multiply(rotation) : rotation.clone(),
});
}
return world;
}
/**
* Baut die Übertragung eines MVN-Bilds auf das Zielskelett. Ein Bild ist { rotations: Map<MVN-Knochen,
* Weltdrehung>, hips: Weltposition der Hüfte in cm }. Beide Skelette ruhen in derselben T-Pose mit Blick nach +Z,
* und jede MVN-Ruhe-Weltdrehung ist 0°. Deshalb gilt je zugeordnetem Knochen:
*
* Weltdrehung Ziel = Weltdrehung MVN · Ruhe-Weltdrehung Ziel, lokal = (Weltdrehung Eltern)⁻¹ · Weltdrehung
*
* Knochen ohne Zuordnung (root, Finger, Blattknochen) gehen mit ihrer Ruhedrehung in die Kette ein und bekommen
* keine Spur. Die Hüfte: cm -> m, mal hipsScale (Beinlänge Ziel ÷ Beinlänge Quelle), dann in den Raum ihres
* Elternknochens, der dafür unbewegt sein muss.
*/
export function createRetargeter(rig, { boneMap = BONE_MAP, hipsScale = 1 } = {}) {
const rest = rigWorld(rig);
const sourceOf = new Map(Object.entries(boneMap).map(([source, target]) => [target, source]));
const hipsBone = rig.find((bone) => bone.name === boneMap[SOURCE_HIPS]);
if (!hipsBone) throw new Error(`Rig ohne Hüftknochen ${boneMap[SOURCE_HIPS]}`);
if (hipsBone.parent !== null && sourceOf.has(hipsBone.parent)) {
throw new Error(`Elternknochen der Hüfte (${hipsBone.parent}) darf nicht animiert sein`);
}
const hipsParentInverse = hipsBone.parent === null
? new Matrix4()
: rest.get(hipsBone.parent).matrix.clone().invert();
return function retarget(frame) {
const world = new Map();
const local = new Map();
for (const bone of rig) {
const parentWorld = bone.parent === null ? new Quaternion() : world.get(bone.parent);
const source = sourceOf.get(bone.name);
if (source === undefined) {
world.set(bone.name, parentWorld.clone().multiply(new Quaternion().fromArray(bone.quaternion)));
continue;
}
const sourceWorld = frame.rotations.get(source);
if (!sourceWorld) throw new Error(`Bild ohne Drehung für ${source}`);
const targetWorld = sourceWorld.clone().multiply(rest.get(bone.name).quaternion);
world.set(bone.name, targetWorld);
local.set(bone.name, parentWorld.clone().invert().multiply(targetWorld));
}
const hips = frame.hips.clone().multiplyScalar(hipsScale / 100).applyMatrix4(hipsParentInverse);
return { local, hips };
};
}
- Schritt 4: Tests laufen lassen
Run: npx vitest run tests/motions.test.js
Expected: PASS, 11 Tests.
- Schritt 5: Committen
git add tools/assets/motions.mjs tests/motions.test.js
git commit -m "feat(assets): Mocap-Knochen auf das Mannequin übertragen"
Task 4: Clips aufräumen — Blickrichtung, Drift, Schleife
Files:
- Modify:
tools/assets/motions.mjs(Import erweitern, Konstanten und sechs Funktionen anhängen) - Test:
tests/motions.test.js(Import erweitern, neuedescribe-Blöcke anhängen)
Interfaces:
-
Consumes:
SOURCE_HIPSaus Task 3. -
Produces:
SAMPLE_RATE = 30,BLEND_SPEED = 90,BLEND_MIN = 0.25,BLEND_MAX = 1.5sampleTimes(start, end, rate?) -> number[]headingOf(quaternion) -> number(Bogenmaß)faceForward(frames) -> framesremoveDrift(frames) -> framesseamAngle(frames) -> number(Grad)blendDuration(seamDegrees) -> number(Sekunden)closeLoop(times, poses, blend) -> poses
-
Schritt 1: Failing Tests schreiben
Die Importzeile aus three und die aus motions.mjs in tests/motions.test.js ersetzen durch:
import { MathUtils, Quaternion, Vector3 } from "three";
import { BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward,
headingOf, removeDrift, rigWorld, sampleTimes, seamAngle, SOURCE_HIPS } from "../tools/assets/motions.mjs";
Ans Dateiende anhängen:
describe("sampleTimes", () => {
// Bruch: ungleichmäßige Abstände — der Clip liefe stellenweise zu schnell.
it("verteilt round(Dauer · 30) + 1 Zeiten gleichmäßig über den Clip", () => {
const times = sampleTimes(0.008, 6.56, 30);
expect(times).toHaveLength(Math.round(6.552 * 30) + 1);
expect(times[0]).toBe(0.008);
expect(times.at(-1)).toBeCloseTo(6.56, 12);
const steps = times.slice(1).map((time, i) => time - times[i]);
expect(Math.max(...steps) - Math.min(...steps)).toBeLessThan(1e-12);
});
it("lehnt einen leeren Zeitraum ab", () => {
expect(() => sampleTimes(2, 2)).toThrow(/Leerer Zeitraum/);
});
});
describe("faceForward", () => {
const sideways = () => [0, 1].map(() => ({
rotations: new Map([[SOURCE_HIPS, turn(0, 1, 0, -30)], ["Head", turn(1, 0, 0, 10)]]),
hips: new Vector3(10, 90, 0),
}));
// Bruch: Blickrichtung nicht gedreht — die Dummies stünden schräg zur Spielfigur (bis 44°).
it("dreht den Clip, bis die Hüfte im ersten Bild nach +Z schaut", () => {
const faced = faceForward(sideways());
expect(headingOf(faced[0].rotations.get(SOURCE_HIPS))).toBeCloseTo(0, 9);
});
it("dreht Positionen und alle Knochen mit derselben Drehung", () => {
const faced = faceForward(sideways());
expect(faced[1].hips.x).toBeCloseTo(8.660254, 5);
expect(faced[1].hips.z).toBeCloseTo(-5, 5);
expect(faced[1].hips.y).toBeCloseTo(90, 9);
expectSameRotation(faced[1].rotations.get("Head"), turn(0, 1, 0, 30).multiply(turn(1, 0, 0, 10)));
});
});
describe("removeDrift", () => {
// Bruch: Drift bleibt — der Dummy wanderte bei jeder Schleife weiter von seinem Platz.
it("legt das erste Bild auf 0 und zieht die Drift linear ab", () => {
const frames = [[4, 90, 2], [6, 91, 5], [10, 92, 8]].map(([x, y, z]) =>
({ rotations: new Map(), hips: new Vector3(x, y, z) }));
expect(removeDrift(frames).map((frame) => frame.hips.toArray()))
.toEqual([[0, 90, 0], [-1, 91, 0], [0, 92, 0]]);
});
});
describe("seamAngle und blendDuration", () => {
const frame = (degrees) => ({
rotations: new Map([[SOURCE_HIPS, new Quaternion()], ["LeftFoot", turn(1, 0, 0, degrees)]]),
hips: new Vector3(),
});
it("misst die größte Abweichung zwischen letztem und erstem Bild", () => {
expect(seamAngle([frame(0), frame(5), frame(91)])).toBeCloseTo(91, 6);
});
// Bruch: feste Überblendzeit — die 91°-Naht des Bogenschützen ruckte sichtbar.
it("bemisst die Überblendung an der Naht und hält die Grenzen ein", () => {
expect(blendDuration(91)).toBeCloseTo(91 / 90, 9);
expect(blendDuration(19)).toBe(BLEND_MIN);
expect(blendDuration(400)).toBe(BLEND_MAX);
});
});
describe("closeLoop", () => {
const times = [0, 0.5, 1, 1.5, 2];
const poses = () => times.map((time) => ({
local: new Map([["pelvis", turn(0, 1, 0, 40 * time)]]),
hips: new Vector3(time, 1, 0),
}));
// Bruch: Schleife nicht geschlossen — jeder Rücksprung wäre ein sichtbarer Ruck.
it("macht das letzte Bild gleich dem ersten", () => {
const closed = closeLoop(times, poses(), 1);
expectSameRotation(closed.at(-1).local.get("pelvis"), poses()[0].local.get("pelvis"));
expect(closed.at(-1).hips.toArray()).toEqual([0, 1, 0]);
});
it("lässt alles vor der Überblendung unberührt und blendet weich", () => {
const original = poses();
const closed = closeLoop(times, original, 1);
expect(closed[0]).toBe(original[0]);
expect(closed[2]).toBe(original[2]);
expect(closed[3].hips.x).toBeCloseTo(0.75, 9);
});
});
- Schritt 2: Tests laufen lassen, sie müssen scheitern
Run: npx vitest run tests/motions.test.js
Expected: FAIL, „sampleTimes is not a function" (oder ein Importfehler für die neuen Namen).
- Schritt 3: Aufräum-Funktionen schreiben
In tools/assets/motions.mjs die three-Importzeile ersetzen durch:
import { MathUtils, Matrix4, Quaternion, Vector3 } from "three";
Ans Dateiende anhängen:
export const SAMPLE_RATE = 30;
/** Überblendzeit am Schleifenende: Naht in Grad ÷ BLEND_SPEED (°/s), begrenzt auf BLEND_MIN bis BLEND_MAX Sekunden. */
export const BLEND_SPEED = 90;
export const BLEND_MIN = 0.25;
export const BLEND_MAX = 1.5;
/** n = round((end − start) · rate) + 1 gleichmäßig verteilte Zeitpunkte von start bis end, beide eingeschlossen. */
export function sampleTimes(start, end, rate = SAMPLE_RATE) {
if (!(end > start)) throw new Error(`Leerer Zeitraum ${start}–${end}`);
const count = Math.round((end - start) * rate) + 1;
return Array.from({ length: count }, (_, i) => start + ((end - start) * i) / (count - 1));
}
/** Winkel des Vorwärtsvektors (+Z) einer Drehung gegen +Z in der Waagrechten, im Bogenmaß, positiv zu +X. */
export function headingOf(quaternion) {
const forward = new Vector3(0, 0, 1).applyQuaternion(quaternion);
return Math.atan2(forward.x, forward.z);
}
/** Dreht alle Bilder um die Hochachse, bis die Hüfte im ersten Bild nach +Z schaut. */
export function faceForward(frames) {
const angle = -headingOf(frames[0].rotations.get(SOURCE_HIPS));
const turn = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), angle);
return frames.map((frame) => ({
rotations: new Map([...frame.rotations].map(([name, rotation]) => [name, turn.clone().multiply(rotation)])),
hips: frame.hips.clone().applyQuaternion(turn),
}));
}
/** Legt die Hüfte im ersten Bild waagrecht auf 0 und zieht die Drift linear ab, sodass das letzte Bild dort endet. */
export function removeDrift(frames) {
const first = frames[0].hips;
const last = frames.at(-1).hips;
return frames.map((frame, i) => {
const along = frames.length > 1 ? i / (frames.length - 1) : 0;
const hips = frame.hips.clone();
hips.x -= first.x + along * (last.x - first.x);
hips.z -= first.z + along * (last.z - first.z);
return { rotations: frame.rotations, hips };
});
}
/** Größte Abweichung der Weltdrehung zwischen letztem und erstem Bild über alle Knochen, in Grad. */
export function seamAngle(frames) {
const last = frames.at(-1).rotations;
let largest = 0;
for (const [name, rotation] of frames[0].rotations) largest = Math.max(largest, rotation.angleTo(last.get(name)));
return MathUtils.radToDeg(largest);
}
export function blendDuration(seamDegrees) {
return MathUtils.clamp(seamDegrees / BLEND_SPEED, BLEND_MIN, BLEND_MAX);
}
/**
* Schließt die Schleife: In den letzten `blend` Sekunden werden lokale Drehungen (slerp) und die Hüfte (lerp) mit
* smoothstep-Gewicht zum ersten Bild gezogen. Das letzte Bild gleicht danach dem ersten. poses: [{ local, hips }].
*/
export function closeLoop(times, poses, blend) {
const end = times.at(-1);
const first = poses[0];
return poses.map((pose, i) => {
if (times[i] <= end - blend) return pose;
const weight = MathUtils.smoothstep(times[i], end - blend, end);
return {
local: new Map([...pose.local].map(([name, rotation]) =>
[name, rotation.clone().slerp(first.local.get(name), weight)])),
hips: pose.hips.clone().lerp(first.hips, weight),
};
});
}
- Schritt 4: Tests laufen lassen
Run: npx vitest run tests/motions.test.js
Expected: PASS, 20 Tests.
- Schritt 5: Committen
git add tools/assets/motions.mjs tests/motions.test.js
git commit -m "feat(assets): Blickrichtung, Drift und Schleifennaht der Mocap-Clips"
Task 5: Die elf Clips bauen
Files:
- Modify:
tools/assets/motions.mjs(Importe,sampleFbx,rigFromScene,stripToSkeleton,flattenPoses,addClip,main) - Create:
public/models/motions/weapons-mocap.glb(erzeugt) - Test:
tests/motions.test.js(drei neuedescribe-Blöcke),tests/showroom.test.js(Clip-Datei)
Interfaces:
-
Consumes: alles aus Task 3 und 4,
loadFbxaus Task 2. -
Produces:
sampleFbx(root, rate?, boneMap?) -> { times: number[], frames, hipsRestHeight: number }rigFromScene(scene) -> rigstripToSkeleton(document) -> Promise<void>flattenPoses(poses) -> { rotations: Map<name, Float32Array>, hips: Float32Array }addClip(document, id, times, poses, hipsBone?) -> Animationpublic/models/motions/weapons-mocap.glb: 11 Clips, je 22 Drehungskanäle und ein Translationskanal aufpelvis
-
Schritt 1: Quelle entpacken
mkdir -p "C:/Users/kenso/AppData/Local/Temp/leonida-assets/motions"
unzip -o -q "C:/Users/kenso/Downloads/MotionLibrary_Weapons.zip" -d "C:/Users/kenso/AppData/Local/Temp/leonida-assets/motions"
Erwartet: 11 .fbx liegen direkt in dem Ordner.
- Schritt 2: Failing Tests schreiben
In tests/motions.test.js die beiden Importzeilen ersetzen durch:
import { AnimationClip, Bone, Group, MathUtils, Quaternion, QuaternionKeyframeTrack, Vector3,
VectorKeyframeTrack } from "three";
import { Document } from "@gltf-transform/core";
import { addClip, BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward,
flattenPoses, headingOf, removeDrift, rigFromScene, rigWorld, sampleFbx, sampleTimes, seamAngle,
SOURCE_HIPS, stripToSkeleton } from "../tools/assets/motions.mjs";
Ans Dateiende anhängen:
describe("sampleFbx", () => {
/** Nachbau eines MVN-FBX: Reference -> Hips -> Spine, Frame 0 in T-Pose, danach Bewegung. */
function fakeFbx() {
const root = new Group();
const reference = new Bone();
reference.name = "Reference";
const hips = new Bone();
hips.name = "Hips";
hips.position.set(0, 90, 0);
const spine = new Bone();
spine.name = "Spine";
spine.position.set(0, 10, 0);
root.add(reference);
reference.add(hips);
hips.add(spine);
const quarter = turn(0, 1, 0, 90).toArray();
root.animations = [new AnimationClip("take", 1.008, [
new VectorKeyframeTrack("Hips.position", [0, 0.008, 1.008], [0, 90, 0, 5, 90, 0, 5, 90, 30]),
new QuaternionKeyframeTrack("Hips.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, ...quarter]),
new QuaternionKeyframeTrack("Spine.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]),
])];
return root;
}
// Bruch: Frame 0 mitgenommen — jede Schleife begänne mit einem Zucken in die T-Pose.
it("lässt Frame 0 weg, tastet bis zum Ende ab und liest die Hüfthöhe der Ruhepose", () => {
const { times, frames, hipsRestHeight } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" });
expect(hipsRestHeight).toBe(90);
expect(times).toHaveLength(31);
expect(times[0]).toBe(0);
expect(times.at(-1)).toBeCloseTo(1, 9);
expect(frames[0].hips.x).toBeCloseTo(5, 6);
expect(frames.at(-1).hips.z).toBeCloseTo(30, 6);
});
it("liest Weltdrehungen, nicht lokale", () => {
const { frames } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" });
expectSameRotation(frames.at(-1).rotations.get("Spine"), turn(0, 1, 0, 90));
});
});
describe("rigFromScene und stripToSkeleton", () => {
function skeletonDocument() {
const document = new Document();
const root = document.createNode("root").setRotation(turn(1, 0, 0, -90).toArray());
const pelvis = document.createNode("pelvis").setTranslation([0, 0.05, 0.917]);
const leaf = document.createNode("Head");
root.addChild(pelvis);
pelvis.addChild(leaf);
document.getRoot().setDefaultScene(document.createScene("Armature").addChild(root));
return document;
}
it("liest die Knoten in Eltern-vor-Kind-Reihenfolge", () => {
const document = skeletonDocument();
const rig = rigFromScene(document.getRoot().listScenes()[0]);
expect(rig.map((bone) => [bone.name, bone.parent]))
.toEqual([["root", null], ["pelvis", "root"], ["Head", "pelvis"]]);
expect(rig[1].position).toEqual([0, 0.05, 0.917]);
});
// Bruch: Blattknoten weggeprunt — Head und die Fingerknochen fehlten als Ziel der Clips.
it("räumt Clips und Geometrie weg, behält aber jeden Knoten", async () => {
const document = skeletonDocument();
const mesh = document.createMesh("Mannequin");
document.getRoot().listNodes()[1].setMesh(mesh);
document.createAnimation("Idle_Loop");
await stripToSkeleton(document);
expect(document.getRoot().listAnimations()).toHaveLength(0);
expect(document.getRoot().listMeshes()).toHaveLength(0);
expect(document.getRoot().listNodes().map((node) => node.getName())).toEqual(["root", "pelvis", "Head"]);
});
});
describe("addClip", () => {
it("legt einen Zeit-Accessor, Drehungen je Knochen und die Hüfte als Translation an", () => {
const document = new Document();
const pelvis = document.createNode("pelvis");
const spine = document.createNode("spine_01");
document.getRoot().setDefaultScene(document.createScene().addChild(pelvis.addChild(spine)));
const poses = [0, 1].map((i) => ({
local: new Map([["pelvis", new Quaternion()], ["spine_01", turn(0, 0, 1, 10 * i)]]),
hips: new Vector3(0, 0.9 + i, 0),
}));
const animation = addClip(document, "test_tk01", [0, 0.5], poses, "pelvis");
expect(animation.getName()).toBe("test_tk01");
expect(animation.listChannels()).toHaveLength(3);
expect(new Set(animation.listSamplers().map((sampler) => sampler.getInput())).size).toBe(1);
const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation");
expect(translation.getTargetNode().getName()).toBe("pelvis");
expect([...translation.getSampler().getOutput().getArray()]).toEqual([0, 0.9, 0, 0, 1.9, 0]);
});
it("meldet einen fehlenden Knoten", () => {
const document = new Document();
document.getRoot().setDefaultScene(document.createScene().addChild(document.createNode("pelvis")));
const poses = [{ local: new Map([["fehlt", new Quaternion()]]), hips: new Vector3() }];
expect(() => addClip(document, "test", [0], poses, "pelvis")).toThrow(/Skelett ohne Knoten fehlt/);
});
});
describe("flattenPoses", () => {
it("reiht Drehungen als xyzw und die Hüfte als xyz je Bild auf", () => {
const poses = [
{ local: new Map([["pelvis", new Quaternion(0, 0, 0, 1)]]), hips: new Vector3(1, 2, 3) },
{ local: new Map([["pelvis", new Quaternion(0.5, 0, 0, 0.8660254)]]), hips: new Vector3(4, 5, 6) },
];
const { rotations, hips } = flattenPoses(poses);
expect([...hips]).toEqual([1, 2, 3, 4, 5, 6]);
expect([...rotations.get("pelvis")].slice(0, 4)).toEqual([0, 0, 0, 1]);
expect([...rotations.get("pelvis")][4]).toBeCloseTo(0.5, 6);
});
});
- Schritt 3: Tests laufen lassen, sie müssen scheitern
Run: npx vitest run tests/motions.test.js
Expected: FAIL, „sampleFbx is not a function".
- Schritt 4: Lesen, Schreiben und
main()ergänzen
In tools/assets/motions.mjs die Importe oben ersetzen durch:
import { mkdir, readdir, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { prune } from "@gltf-transform/functions";
import { MathUtils, Matrix4, Quaternion, Vector3 } from "three";
Ans Dateiende anhängen:
/**
* Liest den ersten Clip eines FBX-Objekts ab: n gleichmäßige Zeitpunkte von Frame 1 bis zum Ende (Frame 0 ist die
* T-Pose, 8 ms davor). Je Zeitpunkt die Weltdrehungen der zugeordneten MVN-Knochen und die Hüft-Weltposition in cm.
* times beginnt bei 0. hipsRestHeight ist die Hüfthöhe der Ruhepose in cm, abgelesen bevor ein Bild gesetzt wird —
* die beiden Aufnahmetage liegen dort 0,9 cm auseinander.
*/
export function sampleFbx(root, rate = SAMPLE_RATE, boneMap = BONE_MAP) {
const clip = root.animations?.[0];
if (!clip) throw new Error("FBX ohne Animation");
const hips = root.getObjectByName(SOURCE_HIPS);
if (!hips) throw new Error(`FBX ohne Knochen ${SOURCE_HIPS}`);
root.updateMatrixWorld(true);
const hipsRestHeight = hips.getWorldPosition(new Vector3()).y;
const bindings = clip.tracks.map((track) => {
const dot = track.name.lastIndexOf(".");
const object = root.getObjectByName(track.name.slice(0, dot));
if (!object) throw new Error(`Spur ohne Knochen: ${track.name}`);
return { target: object[track.name.slice(dot + 1)], interpolant: track.createInterpolant() };
});
const hipsTimes = clip.tracks.find((track) => track.name === `${SOURCE_HIPS}.quaternion`)?.times;
if (!hipsTimes || hipsTimes.length < 3) throw new Error("Hüftspur fehlt oder ist zu kurz");
const bones = Object.keys(boneMap).map((name) => {
const bone = root.getObjectByName(name);
if (!bone) throw new Error(`FBX ohne Knochen ${name}`);
return bone;
});
const start = hipsTimes[1];
const absolute = sampleTimes(start, clip.duration, rate);
const frames = absolute.map((time) => {
for (const { target, interpolant } of bindings) target.fromArray(interpolant.evaluate(time));
root.updateMatrixWorld(true);
return {
rotations: new Map(bones.map((bone) => [bone.name, bone.getWorldQuaternion(new Quaternion())])),
hips: hips.getWorldPosition(new Vector3()),
};
});
return { times: absolute.map((time) => time - start), frames, hipsRestHeight };
}
/** Knotenhierarchie einer gltf-transform-Szene als rig (siehe rigWorld), Eltern vor Kindern. */
export function rigFromScene(scene) {
const rig = [];
const visit = (node, parent) => {
rig.push({
name: node.getName(),
parent,
position: node.getTranslation(),
quaternion: node.getRotation(),
scale: node.getScale(),
});
for (const child of node.listChildren()) visit(child, node.getName());
};
for (const node of scene.listChildren()) visit(node, null);
return rig;
}
/** Entfernt aus einem gelesenen ual.glb alles außer der Knotenhierarchie: Clips, Meshes, Skins, Materialien, Texturen. */
export async function stripToSkeleton(document) {
const root = document.getRoot();
for (const node of root.listNodes()) node.setMesh(null).setSkin(null);
// Die Accessors müssen ausdrücklich mit weg: animation.dispose() löst seine Sampler nicht auf, prune() hält
// deren Accessors darum weiter für benutzt. Ohne diese Zeile blieben die 53 Clips von ual.glb in der Datei
// (gemessen: 8745 Accessors und 3,41 MB statt 264 und 1,66 MB).
for (const property of [...root.listAnimations(), ...root.listMeshes(), ...root.listSkins(),
...root.listMaterials(), ...root.listTextures(), ...root.listAccessors()]) {
property.dispose();
}
// keepLeaves: Knochen ohne Kinder (Head, Blattknochen) sind leere Blattknoten und müssen als Ziele bleiben.
await document.transform(prune({ keepLeaves: true }));
}
/** Legt Posen als Float32-Reihen ab: je Knochen x, y, z, w pro Bild, dazu die Hüfte als x, y, z pro Bild. */
export function flattenPoses(poses) {
const rotations = new Map([...poses[0].local.keys()].map((bone) => [bone, new Float32Array(poses.length * 4)]));
const hips = new Float32Array(poses.length * 3);
poses.forEach((pose, i) => {
for (const [bone, values] of rotations) pose.local.get(bone).toArray(values, i * 4);
pose.hips.toArray(hips, i * 3);
});
return { rotations, hips };
}
/** Hängt einen Clip an das Dokument: ein Zeit-Accessor, je Knochen ein Drehungskanal, die Hüfte als Translation. */
export function addClip(document, id, times, poses, hipsBone = BONE_MAP[SOURCE_HIPS]) {
const root = document.getRoot();
const buffer = root.listBuffers()[0] ?? document.createBuffer();
const nodes = new Map(root.listNodes().map((node) => [node.getName(), node]));
const accessor = (array, type) => document.createAccessor().setArray(array).setType(type).setBuffer(buffer);
const animation = document.createAnimation(id);
const input = accessor(Float32Array.from(times), "SCALAR");
const channel = (bone, path, values, type) => {
const node = nodes.get(bone);
if (!node) throw new Error(`Skelett ohne Knoten ${bone}`);
const sampler = document.createAnimationSampler()
.setInput(input).setOutput(accessor(values, type)).setInterpolation("LINEAR");
animation.addSampler(sampler).addChannel(
document.createAnimationChannel().setTargetNode(node).setTargetPath(path).setSampler(sampler));
};
const { rotations, hips } = flattenPoses(poses);
for (const [bone, values] of rotations) channel(bone, "rotation", values, "VEC4");
channel(hipsBone, "translation", hips, "VEC3");
return animation;
}
async function main() {
const [sourceDir] = process.argv.slice(2);
if (!sourceDir) {
console.error("Aufruf: node tools/assets/motions.mjs <Ordner mit den FBX aus MotionLibrary_Weapons.zip>");
process.exit(1);
}
const { loadFbx } = await import("./fbx.mjs");
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read(fileURLToPath(new URL("../../public/models/ual.glb", import.meta.url)));
const rig = rigFromScene(document.getRoot().listScenes()[0]);
await stripToSkeleton(document);
const pelvisHeight = new Vector3().setFromMatrixPosition(rigWorld(rig).get(BONE_MAP[SOURCE_HIPS]).matrix).y;
const files = (await readdir(sourceDir)).filter((name) => name.endsWith(".fbx")).sort();
for (const file of files) {
const id = clipId(file);
const { times, frames, hipsRestHeight } = sampleFbx(await loadFbx(join(sourceDir, file)));
const cleaned = removeDrift(faceForward(frames));
const blend = blendDuration(seamAngle(cleaned));
const retarget = createRetargeter(rig, { hipsScale: pelvisHeight / (hipsRestHeight / 100) });
addClip(document, id, times, closeLoop(times, cleaned.map(retarget), blend));
console.log(`${id}: ${times.at(-1).toFixed(2)} s, ${times.length} Bilder, Überblendung ${blend.toFixed(2)} s`);
}
const target = fileURLToPath(new URL("../../public/models/motions/weapons-mocap.glb", import.meta.url));
await mkdir(dirname(target), { recursive: true });
await io.write(target, document);
console.log(`weapons-mocap.glb ${((await stat(target)).size / 1e6).toFixed(2)} MB`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
- Schritt 5: Tests laufen lassen
Run: npx vitest run tests/motions.test.js
Expected: PASS, 27 Tests.
- Schritt 6: Clips bauen
node tools/assets/motions.mjs "C:/Users/kenso/AppData/Local/Temp/leonida-assets/motions"
Erwartet (im Prototyp gemessen):
hunter_bowArrow_tk01: 77.64 s, 2330 Bilder, Überblendung 1.01 s
leftHandThrow_grenade_tk01: 6.64 s, 200 Bilder, Überblendung 0.25 s
leftHandThrow_grenade_tk02: 4.29 s, 130 Bilder, Überblendung 0.25 s
taunt_shootSelfInHead_tk01: 7.83 s, 236 Bilder, Überblendung 0.25 s
taunt_shootSelfInHead_tk02: 6.70 s, 202 Bilder, Überblendung 0.25 s
stabTwist_Knife_tk01: 8.47 s, 255 Bilder, Überblendung 0.25 s
stabTwist_Knife_tk02: 6.26 s, 189 Bilder, Überblendung 0.25 s
idle_loadGun-Hand_tk01: 6.55 s, 198 Bilder, Überblendung 0.25 s
idle_loadGun-Hand_tk02: 5.88 s, 178 Bilder, Überblendung 0.25 s
idle_loadGun-Rifle_tk04: 7.74 s, 233 Bilder, Überblendung 0.25 s
idle_loadGun-Rifle_tk05: 6.12 s, 185 Bilder, Überblendung 0.25 s
weapons-mocap.glb 1.66 MB
- Schritt 7: Asset-Test für die Clipdatei schreiben
In tests/showroom.test.js die Importzeilen ergänzen:
import { statSync } from "node:fs";
Ans Dateiende anhängen:
/** Die elf Clips, benannt nach den Dateien im Pack. */
const CLIP_IDS = [
"hunter_bowArrow_tk01",
"idle_loadGun-Hand_tk01", "idle_loadGun-Hand_tk02",
"idle_loadGun-Rifle_tk04", "idle_loadGun-Rifle_tk05",
"leftHandThrow_grenade_tk01", "leftHandThrow_grenade_tk02",
"stabTwist_Knife_tk01", "stabTwist_Knife_tk02",
"taunt_shootSelfInHead_tk01", "taunt_shootSelfInHead_tk02",
];
describe("weapons-mocap.glb", () => {
const file = fileURLToPath(new URL("../public/models/motions/weapons-mocap.glb", import.meta.url));
let animations;
beforeAll(async () => {
const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(file);
animations = document.getRoot().listAnimations();
}, 30000);
it("ist kleiner als 3 MB", () => {
expect(statSync(file).size).toBeLessThan(3e6);
});
it("enthält die elf Clips", () => {
expect(animations.map((animation) => animation.getName()).sort()).toEqual([...CLIP_IDS].sort());
});
// Bruch: ein Knochen ohne Spur — die Figur bliebe dort in der Ruhepose stehen.
it("hat je Clip 22 Drehungen und die Beckenposition", () => {
for (const animation of animations) {
const channels = animation.listChannels();
expect(channels.filter((channel) => channel.getTargetPath() === "rotation")).toHaveLength(22);
expect(channels.filter((channel) => channel.getTargetPath() === "translation")
.map((channel) => channel.getTargetNode().getName())).toEqual(["pelvis"]);
}
});
// Bruch: Schleife nicht geschlossen — bei jedem Rücksprung ein sichtbarer Ruck.
it("endet jeder Kanal dort, wo er beginnt", () => {
for (const animation of animations) {
for (const channel of animation.listChannels()) {
const output = channel.getSampler().getOutput();
const first = output.getElement(0, []);
const last = output.getElement(output.getCount() - 1, []);
for (let i = 0; i < first.length; i++) expect(last[i]).toBeCloseTo(first[i], 5);
}
}
});
// Bruch: Drift nicht abgezogen — der Dummy wanderte von seinem Platz.
it("beginnt jede Beckenbahn waagrecht bei 0", () => {
for (const animation of animations) {
const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation");
const [x, y] = translation.getSampler().getOutput().getElement(0, []);
expect(x).toBeCloseTo(0, 5);
expect(y).toBeCloseTo(0, 5);
}
});
it("tastet mit 30 Bildern je Sekunde ab", () => {
for (const animation of animations) {
const times = animation.listSamplers()[0].getInput();
const values = [...times.getArray()];
const duration = values.at(-1);
expect(values).toHaveLength(Math.round(duration * 30) + 1);
expect(values[0]).toBe(0);
}
});
});
- Schritt 8: Tests laufen lassen
Run: npx vitest run tests/showroom.test.js
Expected: PASS, 26 Tests.
- Schritt 9: Committen
git add tools/assets/motions.mjs tests/motions.test.js tests/showroom.test.js public/models/motions
git commit -m "feat(assets): elf Mocap-Clips als weapons-mocap.glb für das Mannequin"
Task 6: Aufstellung der Dummies
Files:
- Create:
src/showroom/lineup.js - Test:
tests/showroom.test.js(neuerdescribe-Block)
Interfaces:
-
Consumes: die Clipnamen aus Task 5, die Waffen-IDs aus Task 2.
-
Produces:
WEAPONS: Record<id, { label, url, grip: { position: [x, y, z], rotation: [x, y, z] } }>(Griffwerte in Task 9 gemessen)DUMMIES: Array<{ clip, label, weapon: id | null, hand: "r" | "l" | null }>(13 Einträge)MOTIONS_URL,ROW_SPACING = 3.5,ROW_DEPTH = 8,FIRST_SLOT_X = -12.25,BOW_X = 8.75,LABEL_HEIGHT = 2.2,VIEW_DISTANCE = 8slotPosition(index) -> { x, z }in Mannequin-MeternlabelLines(dummy) -> [string, string]
-
Schritt 1: Failing Tests schreiben
In tests/showroom.test.js ans Dateiende anhängen:
describe("Aufstellung", () => {
it("stellt 13 Dummies auf, acht mit Waffe", () => {
expect(DUMMIES).toHaveLength(13);
expect(DUMMIES.filter((dummy) => dummy.weapon)).toHaveLength(8);
});
// Bruch: Tippfehler im Clipnamen — der Dummy bliebe regungslos stehen.
it("nennt nur Clips, die es in weapons-mocap.glb gibt", () => {
for (const dummy of DUMMIES) expect(CLIP_IDS).toContain(dummy.clip);
});
it("spielt jeden Clip mindestens einmal", () => {
expect(new Set(DUMMIES.map((dummy) => dummy.clip)).size).toBe(CLIP_IDS.length);
});
// Bruch: Waffe ohne Datei oder ohne Griff — leere Hand oder Waffe im Boden.
it("gibt jeder Waffe Datei, Beschriftung und Griff", () => {
expect(Object.keys(WEAPONS)).toEqual(WEAPON_SOURCES.map((source) => source.id));
for (const [id, weapon] of Object.entries(WEAPONS)) {
expect(weapon.url).toBe(`./models/weapons/${id}.glb`);
expect(statSync(fileURLToPath(new URL(`../public/models/weapons/${id}.glb`, import.meta.url))).size)
.toBeGreaterThan(0);
expect(weapon.grip.position).toHaveLength(3);
expect(weapon.grip.rotation).toHaveLength(3);
expect(weapon.label.length).toBeGreaterThan(0);
}
});
it("hält jede Waffe in einer Hand und lässt sonst beide leer", () => {
for (const dummy of DUMMIES) {
if (dummy.weapon) {
expect(WEAPONS[dummy.weapon]).toBeDefined();
expect(["r", "l"]).toContain(dummy.hand);
} else {
expect(dummy.hand).toBeNull();
}
}
});
// Bruch: zwei Dummies auf einem Platz oder ineinander — die Schleifen wären nicht mehr auseinanderzuhalten.
it("hält 3,5 m Abstand zwischen allen Plätzen", () => {
const spots = DUMMIES.map((_, index) => slotPosition(index));
for (let i = 0; i < spots.length; i++) {
for (let k = i + 1; k < spots.length; k++) {
expect(Math.hypot(spots[i].x - spots[k].x, spots[i].z - spots[k].z)).toBeGreaterThanOrEqual(3.5);
}
}
});
// Bruch: der Bogenschütze wandert 3,13 m und liefe sonst durch seine Nachbarn.
it("lässt dem Bogenschützen mindestens 4 m Platz", () => {
const index = DUMMIES.findIndex((dummy) => dummy.clip === "hunter_bowArrow_tk01");
const bow = slotPosition(index);
for (let i = 0; i < DUMMIES.length; i++) {
if (i === index) continue;
const other = slotPosition(i);
expect(Math.hypot(bow.x - other.x, bow.z - other.z)).toBeGreaterThanOrEqual(4);
}
});
it("beschriftet mit Clip und Waffe", () => {
expect(labelLines(DUMMIES[0])).toEqual(["Pistole nachladen · Take 1", "Pistole"]);
expect(labelLines(DUMMIES.at(-1))).toEqual(["Bogenschütze · Take 1", "ohne Waffe"]);
});
});
Die Importzeilen oben in tests/showroom.test.js ergänzen:
import { DUMMIES, labelLines, slotPosition, WEAPONS } from "../src/showroom/lineup.js";
- Schritt 2: Tests laufen lassen, sie müssen scheitern
Run: npx vitest run tests/showroom.test.js
Expected: FAIL, „Failed to resolve import … src/showroom/lineup.js".
- Schritt 3: Aufstellung schreiben
src/showroom/lineup.js:
// Aufstellung der Animations-Testseite (docs/superpowers/specs/2026-09-17-animationen-design.md, "Aufstellung"):
// welcher Dummy welchen Clip spielt, welche Waffe er in welcher Hand hält und wo er steht. Alle Maße in
// Mannequin-Metern — src/showroom/main.js hängt die Aufstellung an eine Gruppe mit dem Maßstab der Spielfigur.
/**
* Griff = Lage der Waffe im Handknochen: Position in Metern, Drehung in Grad in der Reihenfolge XYZ. Die Werte
* sind im Browser mit dem Panel eingepasst (Plan-Task 9); das Pack verspricht für alle fünf denselben Griffpunkt.
*/
export const WEAPONS = {
pistol: { label: "Pistole", url: "./models/weapons/pistol.glb", grip: { position: [0, 0, 0], rotation: [0, 0, 0] } },
ak47: { label: "AK47", url: "./models/weapons/ak47.glb", grip: { position: [0, 0, 0], rotation: [0, 0, 0] } },
m4: { label: "M4", url: "./models/weapons/m4.glb", grip: { position: [0, 0, 0], rotation: [0, 0, 0] } },
shotgun870: { label: "870 Shotgun", url: "./models/weapons/shotgun870.glb", grip: { position: [0, 0, 0], rotation: [0, 0, 0] } },
l96: { label: "L96", url: "./models/weapons/l96.glb", grip: { position: [0, 0, 0], rotation: [0, 0, 0] } },
};
export const MOTIONS_URL = "./models/motions/weapons-mocap.glb";
/** Reihe 1 steht bei z = 0 mit acht Plätzen, Reihe 2 bei z = −ROW_DEPTH. Die Spielfigur steht VIEW_DISTANCE davor. */
export const ROW_SPACING = 3.5;
export const ROW_DEPTH = 8;
export const FIRST_SLOT_X = -12.25;
export const BOW_X = 8.75;
export const LABEL_HEIGHT = 2.2;
export const VIEW_DISTANCE = 8;
/**
* Reihe 1 (Plätze 1–8) hält die Waffen, Reihe 2 (Plätze 9–13) die Clips ohne passende Waffe. Die Gewehr-Clips
* laufen doppelt, damit auch Shotgun und L96 zu sehen sind — eine eigene Animation gibt es für sie nicht.
*/
export const DUMMIES = [
{ clip: "idle_loadGun-Hand_tk01", label: "Pistole nachladen · Take 1", weapon: "pistol", hand: "r" },
{ clip: "idle_loadGun-Hand_tk02", label: "Pistole nachladen · Take 2", weapon: "pistol", hand: "r" },
{ clip: "taunt_shootSelfInHead_tk01", label: "Taunt: Schuss in den Kopf · Take 1", weapon: "pistol", hand: "r" },
{ clip: "taunt_shootSelfInHead_tk02", label: "Taunt: Schuss in den Kopf · Take 2", weapon: "pistol", hand: "r" },
{ clip: "idle_loadGun-Rifle_tk04", label: "Gewehr nachladen · Take 4", weapon: "ak47", hand: "r" },
{ clip: "idle_loadGun-Rifle_tk05", label: "Gewehr nachladen · Take 5", weapon: "m4", hand: "r" },
{ clip: "idle_loadGun-Rifle_tk04", label: "Gewehr nachladen · Take 4", weapon: "shotgun870", hand: "r" },
{ clip: "idle_loadGun-Rifle_tk05", label: "Gewehr nachladen · Take 5", weapon: "l96", hand: "r" },
{ clip: "stabTwist_Knife_tk01", label: "Messerstich mit Drehung · Take 1", weapon: null, hand: null },
{ clip: "stabTwist_Knife_tk02", label: "Messerstich mit Drehung · Take 2", weapon: null, hand: null },
{ clip: "leftHandThrow_grenade_tk01", label: "Granate werfen (links) · Take 1", weapon: null, hand: null },
{ clip: "leftHandThrow_grenade_tk02", label: "Granate werfen (links) · Take 2", weapon: null, hand: null },
{ clip: "hunter_bowArrow_tk01", label: "Bogenschütze · Take 1", weapon: null, hand: null },
];
/** Platz eines Dummys in Mannequin-Metern. Der Bogenschütze steht abseits, er wandert bis 3,13 m. */
export function slotPosition(index) {
if (index < 8) return { x: FIRST_SLOT_X + ROW_SPACING * index, z: 0 };
if (DUMMIES[index]?.clip.startsWith("hunter_bowArrow")) return { x: BOW_X, z: -ROW_DEPTH };
return { x: FIRST_SLOT_X + ROW_SPACING * (index - 8), z: -ROW_DEPTH };
}
/** Text des Schilds: Clip oben, Waffe darunter. */
export function labelLines(dummy) {
return [dummy.label, dummy.weapon ? WEAPONS[dummy.weapon].label : "ohne Waffe"];
}
- Schritt 4: Tests laufen lassen
Run: npx vitest run tests/showroom.test.js
Expected: PASS, 34 Tests.
- Schritt 5: Committen
git add src/showroom/lineup.js tests/showroom.test.js
git commit -m "feat(showroom): Aufstellung der 13 Dummies mit Clips und Waffen"
Task 7: Dummy bauen — Clip, Fingerhaltung, Waffe
Files:
- Create:
src/showroom/dummies.js - Test:
tests/dummies.test.js
Interfaces:
-
Consumes: nichts aus früheren Tasks (die Aufstellung kommt erst in
main.jsdazu). -
Produces:
FINGER_BONE: RegExpfingerPoseClip(source: AnimationClip, name?) -> AnimationClip(nur Fingerdrehungen, erster Keyframe, 1 s lang)applyGrip(object: Object3D, grip) -> voidcreateDummy({ template, clip, fingers, weapon?, hand?, grip? }) -> { root, mixer, clip, weapon }—clipkommt zurück, damit die Browserprüfung Name und Dauer ablesen kann, ohne in den Mixer zu greifen
-
Schritt 1: Failing Tests schreiben
tests/dummies.test.js:
import { describe, expect, it } from "vitest";
import { AnimationClip, Bone, BoxGeometry, Group, Mesh, MeshBasicMaterial, Object3D,
QuaternionKeyframeTrack, VectorKeyframeTrack } from "three";
import { applyGrip, createDummy, fingerPoseClip } from "../src/showroom/dummies.js";
const source = new AnimationClip("Pistol_Idle_Loop", 2, [
new QuaternionKeyframeTrack("index_01_r.quaternion", [0, 1], [0, 0, 0.3826834, 0.9238795, 0, 0, 0, 1]),
new QuaternionKeyframeTrack("thumb_03_l.quaternion", [0, 2], [0.1, 0, 0, 0.9949874, 0, 0, 0, 1]),
new QuaternionKeyframeTrack("index_04_leaf_r.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
new QuaternionKeyframeTrack("hand_r.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
new VectorKeyframeTrack("index_01_r.position", [0, 1], [0, 0, 0, 1, 1, 1]),
]);
describe("fingerPoseClip", () => {
// Bruch: Hand oder Blattknochen mitgenommen — die Fingerhaltung würde die Mocap-Bewegung überschreiben.
it("behält nur die Drehungen der Fingerknochen", () => {
expect(fingerPoseClip(source).tracks.map((track) => track.name).sort())
.toEqual(["index_01_r.quaternion", "thumb_03_l.quaternion"]);
});
it("hält den ersten Keyframe über die ganze Dauer", () => {
const clip = fingerPoseClip(source, "Finger_Waffe");
const track = clip.tracks.find((entry) => entry.name === "index_01_r.quaternion");
expect(clip.name).toBe("Finger_Waffe");
expect(clip.duration).toBe(1);
expect([...track.times]).toEqual([0, 1]);
const expected = Float32Array.from([0, 0, 0.3826834, 0.9238795, 0, 0, 0.3826834, 0.9238795]);
expect([...track.values]).toEqual([...expected]);
});
it("meldet einen Clip ohne Fingerspuren", () => {
const empty = new AnimationClip("Leer", 1, [
new QuaternionKeyframeTrack("hand_r.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
]);
expect(() => fingerPoseClip(empty)).toThrow(/keine Fingerspuren/);
});
});
describe("applyGrip", () => {
it("setzt Position in Metern und Drehung in Grad", () => {
const object = new Object3D();
applyGrip(object, { position: [0.01, -0.02, 0.03], rotation: [90, 0, -45] });
expect(object.position.toArray()).toEqual([0.01, -0.02, 0.03]);
expect(object.rotation.x).toBeCloseTo(Math.PI / 2, 12);
expect(object.rotation.z).toBeCloseTo(-Math.PI / 4, 12);
expect(object.rotation.order).toBe("XYZ");
});
});
describe("createDummy", () => {
const WEAPON_GRIP = { position: [0, 0, 0], rotation: [0, 0, 0] };
function template() {
const root = new Group();
const pelvis = new Bone();
pelvis.name = "pelvis";
const right = new Bone();
right.name = "hand_r";
const left = new Bone();
left.name = "hand_l";
root.add(pelvis);
pelvis.add(right);
pelvis.add(left);
return root;
}
const clip = new AnimationClip("take", 1, [
new QuaternionKeyframeTrack("pelvis.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
]);
const fingers = fingerPoseClip(source, "Finger_Waffe");
const weapon = new Group();
weapon.add(new Mesh(new BoxGeometry(0.2, 0.05, 0.02), new MeshBasicMaterial()));
// Bruch: Waffe an der falschen Hand oder ohne Griff — sie steckte im Unterarm.
it("hängt eine eigene Kopie der Waffe mit Griff an die gewählte Hand", () => {
const dummy = createDummy({
template: template(), clip, fingers, weapon, hand: "l",
grip: { position: [0.1, 0, 0], rotation: [0, 90, 0] },
});
expect(dummy.weapon.parent.name).toBe("hand_l");
expect(dummy.weapon).not.toBe(weapon);
expect(dummy.weapon.position.x).toBeCloseTo(0.1, 12);
expect(dummy.weapon.rotation.y).toBeCloseTo(Math.PI / 2, 12);
});
it("spielt Clip und Fingerhaltung zugleich, lässt leere Hände leer und gibt den Clip zurück", () => {
const dummy = createDummy({ template: template(), clip, fingers });
expect(dummy.weapon).toBeNull();
expect(dummy.clip).toBe(clip);
expect(dummy.mixer.existingAction(clip).isRunning()).toBe(true);
expect(dummy.mixer.existingAction(fingers).isRunning()).toBe(true);
});
it("meldet eine fehlende Hand", () => {
const root = new Group();
const pelvis = new Bone();
pelvis.name = "pelvis";
root.add(pelvis);
expect(() => createDummy({ template: root, clip, fingers, weapon, hand: "r", grip: WEAPON_GRIP }))
.toThrow(/Dummy ohne Knochen hand_r/);
});
});
- Schritt 2: Tests laufen lassen, sie müssen scheitern
Run: npx vitest run tests/dummies.test.js
Expected: FAIL, „Failed to resolve import … src/showroom/dummies.js".
- Schritt 3: Dummy-Bausteine schreiben
src/showroom/dummies.js:
// Ein Dummy der Animations-Testseite: geklontes Mannequin, Mocap-Clip in Dauerschleife, dazu eine feste
// Fingerhaltung und wahlweise eine Waffe im Handknochen
// (docs/superpowers/specs/2026-09-17-animationen-design.md, "Dummies").
import { AnimationClip, AnimationMixer, LoopRepeat, MathUtils, QuaternionKeyframeTrack } from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
/** Fingerknochen des Mannequins ohne die Blattknochen. Die Mocap-Daten haben keine Finger. */
export const FINGER_BONE = /^(thumb|index|middle|ring|pinky)_0[1-3]_[lr]$/;
/**
* Macht aus einem Clip des Mannequins (Pistol_Idle_Loop für die Waffenhand, Idle_Loop für leere Hände) eine feste
* Fingerhaltung: nur die Drehungen der Fingerknochen, eingefroren auf ihren ersten Keyframe. Läuft als zweite
* Aktion auf demselben Mixer — sie berührt keinen Knochen, den der Mocap-Clip bewegt.
*/
export function fingerPoseClip(source, name = `${source.name}_Finger`) {
const tracks = source.tracks
.filter((track) => track.name.endsWith(".quaternion")
&& FINGER_BONE.test(track.name.slice(0, track.name.lastIndexOf("."))))
.map((track) => {
const first = [...track.values.slice(0, 4)];
return new QuaternionKeyframeTrack(track.name, [0, 1], [...first, ...first]);
});
if (tracks.length === 0) throw new Error(`${source.name}: keine Fingerspuren`);
return new AnimationClip(name, 1, tracks);
}
/** Lage der Waffe im Handknochen: Position in Metern, Drehung in Grad (XYZ). */
export function applyGrip(object, grip) {
object.position.fromArray(grip.position);
object.rotation.set(...grip.rotation.map((degrees) => MathUtils.degToRad(degrees)), "XYZ");
}
/** Baut einen Dummy. template ist das geladene Mannequin, weapon die geladene Waffenszene (wird geklont). */
export function createDummy({ template, clip, fingers, weapon = null, hand = "r", grip = null }) {
const root = cloneSkeleton(template);
root.traverse((object) => {
if (!object.isMesh) return;
object.castShadow = true;
object.receiveShadow = true;
// Der Bogenschütze wandert bis 3 m von seinem Platz; die Hülle der Ruhepose schnitte ihn am Bildrand weg.
object.frustumCulled = false;
});
const mixer = new AnimationMixer(root);
mixer.clipAction(clip).setLoop(LoopRepeat, Infinity).play();
mixer.clipAction(fingers).play();
let held = null;
if (weapon) {
const boneName = hand === "l" ? "hand_l" : "hand_r";
const bone = root.getObjectByName(boneName);
if (!bone) throw new Error(`Dummy ohne Knochen ${boneName}`);
held = weapon.clone(true);
held.traverse((object) => {
if (!object.isMesh) return;
object.castShadow = true;
object.receiveShadow = true;
});
applyGrip(held, grip);
bone.add(held);
}
return { root, mixer, clip, weapon: held };
}
- Schritt 4: Tests laufen lassen
Run: npx vitest run tests/dummies.test.js
Expected: PASS, 7 Tests.
- Schritt 5: Committen
git add src/showroom/dummies.js tests/dummies.test.js
git commit -m "feat(showroom): Dummy aus Mannequin, Clip, Fingerhaltung und Waffe"
Task 8: Die Testseite
Files:
- Create:
animationen.html - Create:
src/showroom/labels.js,src/showroom/panel.js,src/showroom/main.js - Modify:
vite.config.js(zweiter Einstieg),.claude/launch.json(eigener Dev-Server auf Port 5180)
Interfaces:
-
Consumes:
lineup.js(Task 6),dummies.js(Task 7), die GLB-Dateien aus Task 2 und 5,PLAYER_MODEL_BASEaussrc/characters/index.js,createSkyundcreateSunShadowsaussrc/world/. -
Produces: die laufende Seite unter
/animationen.html, dazuwindow.__showroom = { renderer, scene, camera, player, stage, mixers, playback, dummies, loop }für die Browserprüfung. -
Schritt 1: Seite und Einstieg anlegen
animationen.html:
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leonida · Animationen</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="container"></div>
<script type="module" src="/src/showroom/main.js"></script>
</body>
</html>
In vite.config.js den Import ergänzen und den build-Block einfügen (vor server):
import { fileURLToPath } from "node:url";
// Zwei Seiten: das Spiel und die Animations-Testseite. Ohne diese Liste baut vite nur index.html.
build: {
rollupOptions: {
input: {
main: fileURLToPath(new URL("./index.html", import.meta.url)),
animationen: fileURLToPath(new URL("./animationen.html", import.meta.url)),
},
},
},
In .claude/launch.json eine dritte Konfiguration an das Ende von configurations hängen — das Komma hinter dem
bisher letzten Eintrag nicht vergessen. Eigener Port, weil auf 5173 die Sitzung des Hauptordners laufen kann:
{
"name": "animationen",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"port": 5180,
"url": "http://localhost:5180"
}
- Schritt 2: Schild schreiben
src/showroom/labels.js:
// Schild über einem Platz der Animations-Testseite: zwei Zeilen auf dunklem Grund, immer zur Kamera gedreht.
import { CanvasTexture, SRGBColorSpace, Sprite, SpriteMaterial } from "three";
const WIDTH = 512;
const HEIGHT = 128;
/** breite in Mannequin-Metern; die Höhe folgt dem Seitenverhältnis der Zeichenfläche. */
export function createLabel([title, subtitle], width = 2.4) {
const canvas = document.createElement("canvas");
canvas.width = WIDTH;
canvas.height = HEIGHT;
const context = canvas.getContext("2d");
context.fillStyle = "rgba(0, 0, 0, 0.6)";
context.beginPath();
context.roundRect(0, 0, WIDTH, HEIGHT, 24);
context.fill();
context.textAlign = "center";
context.fillStyle = "#ffffff";
context.font = "600 40px system-ui, sans-serif";
context.fillText(title, WIDTH / 2, 56, WIDTH - 32);
context.fillStyle = "#ffd27a";
context.font = "400 34px system-ui, sans-serif";
context.fillText(subtitle, WIDTH / 2, 102, WIDTH - 32);
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
// toneMapped: false, sonst zieht ACESFilmicToneMapping die Schrift grau.
const sprite = new Sprite(new SpriteMaterial({ map: texture, depthWrite: false, toneMapped: false }));
sprite.scale.set(width, (width * HEIGHT) / WIDTH, 1);
return sprite;
}
- Schritt 3: Panel schreiben
src/showroom/panel.js:
// Panel der Animations-Testseite: Tempo und Pause für alle Dummies, dazu der Griff je Waffe zum Einpassen im
// Browser (docs/superpowers/specs/2026-09-17-animationen-design.md, "Panel").
import GUI from "lil-gui";
import { applyGrip } from "./dummies.js";
import { WEAPONS } from "./lineup.js";
/**
* playback = { tempo, paused } liest der Bildtakt in main.js. weaponObjects ist id -> alle Kopien dieser Waffe an
* den Dummies; ein verschobener Griff wirkt dadurch sofort auf alle.
*/
export function createShowroomPanel({ playback, weaponObjects }) {
const gui = new GUI({ title: "Animationen" });
gui.add(playback, "tempo", 0, 2, 0.05).name("Tempo");
gui.add(playback, "paused").name("Pause");
const folder = gui.addFolder("Griff");
const ids = Object.keys(WEAPONS);
const edit = { weapon: ids[0], x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 };
const sliders = [];
const load = () => {
const { position, rotation } = WEAPONS[edit.weapon].grip;
[edit.x, edit.y, edit.z] = position;
[edit.rx, edit.ry, edit.rz] = rotation;
for (const slider of sliders) slider.updateDisplay();
};
const apply = () => {
const grip = WEAPONS[edit.weapon].grip;
grip.position = [edit.x, edit.y, edit.z];
grip.rotation = [edit.rx, edit.ry, edit.rz];
for (const object of weaponObjects.get(edit.weapon) ?? []) applyGrip(object, grip);
};
folder.add(edit, "weapon", ids).name("Waffe").onChange(load);
for (const axis of ["x", "y", "z"]) {
sliders.push(folder.add(edit, axis, -0.3, 0.3, 0.001).name(`Position ${axis} (m)`).onChange(apply));
}
for (const axis of ["rx", "ry", "rz"]) {
sliders.push(folder.add(edit, axis, -180, 180, 1).name(`Drehung ${axis.slice(1)} (Grad)`).onChange(apply));
}
folder.add({
ausgeben: () => console.log(`${edit.weapon}:`, JSON.stringify(WEAPONS[edit.weapon].grip)),
}, "ausgeben").name("Werte ausgeben");
load();
return gui;
}
- Schritt 4: Seite zusammensetzen
src/showroom/main.js:
// Animations-Testseite (docs/superpowers/specs/2026-09-17-animationen-design.md): 13 Mannequin-Dummies spielen die
// Mocap-Clips aus weapons-mocap.glb in Dauerschleife, acht davon mit Waffe in der Hand. Die Spielfigur läuft wie
// im Spiel. Keine Stadt, kein Streaming, keine Fahrzeuge.
import { ACESFilmicToneMapping, AnimationClip, Fog, Group, Mesh, MeshStandardMaterial, PerspectiveCamera,
PlaneGeometry, RepeatWrapping, Scene, SRGBColorSpace, TextureLoader, Timer, Vector3,
WebGLRenderer } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { playerController } from "three-player-controller";
import { PLAYER_MODEL_BASE } from "../characters/index.js";
import { createSky } from "../world/sky.js";
import { createSunShadows } from "../world/shadows.js";
import { createDummy, fingerPoseClip } from "./dummies.js";
import { createLabel } from "./labels.js";
import { DUMMIES, labelLines, LABEL_HEIGHT, MOTIONS_URL, slotPosition, VIEW_DISTANCE, WEAPONS } from "./lineup.js";
import { createShowroomPanel } from "./panel.js";
// Dieselben Werte wie in src/main.js (dort lokale Konstanten): Sonne in gemessener Stärke, Himmelslicht und
// Kuppelhelligkeit fünffach.
const SUN_STRENGTH = 1;
const SKY_LIGHT = 5;
const SKY_BRIGHTNESS = 5;
// Poly Haven misst concrete_pavement mit 1,8 × 1,8 m. Die Ebene ist groß genug für jeden Figurenmaßstab.
const GROUND_TILE = 1.8;
const GROUND_SIZE = 200;
const container = document.querySelector("#container");
const scene = new Scene();
const renderer = new WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.toneMapping = ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
// Nahwert 0,1 statt 0,5 wie im Spiel: Hier steht nur eine Ebene, es gibt keine übereinanderliegenden Flächen,
// die im Tiefenpuffer flackern könnten, dafür geht die Kamera näher an die Dummies heran.
const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.1, 2500);
const controls = new OrbitControls(camera, renderer.domElement);
scene.fog = new Fog(0xffffff, 500, 2200); // Farbe setzt sky.setBrightness() passend zum Horizont
const sky = await createSky({ renderer, scene });
sky.setBrightness(SKY_BRIGHTNESS);
scene.environmentIntensity = SKY_LIGHT;
const sun = createSunShadows({ scene, camera, sun: { ...sky.sun, intensity: sky.sun.intensity * SUN_STRENGTH } });
const textureLoader = new TextureLoader();
const groundTextures = await Promise.all(
["Diffuse", "nor_gl", "arm"].map((map) => textureLoader.loadAsync(`./textures/concrete_pavement_${map}.jpg`)));
for (const texture of groundTextures) {
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
}
const [groundColor, groundNormal, groundArm] = groundTextures;
groundColor.colorSpace = SRGBColorSpace;
const ground = new Mesh(new PlaneGeometry(GROUND_SIZE, GROUND_SIZE), new MeshStandardMaterial({
map: groundColor, normalMap: groundNormal, aoMap: groundArm, roughnessMap: groundArm, metalness: 0,
}));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
ground.updateMatrixWorld(true);
const gltfLoader = new GLTFLoader();
gltfLoader.setMeshoptDecoder(MeshoptDecoder); // die Waffen sind meshopt-komprimiert
const weaponIds = Object.keys(WEAPONS);
const [ual, motions, ...weaponFiles] = await Promise.all([
gltfLoader.loadAsync("./models/ual.glb"),
gltfLoader.loadAsync(MOTIONS_URL),
...weaponIds.map((id) => gltfLoader.loadAsync(WEAPONS[id].url)),
]);
// Vor player.init klonen: Der Controller skaliert ual.scene an Ort und Stelle auf seine Kapselhöhe.
const template = cloneSkeleton(ual.scene);
const player = new playerController();
await player.init({
scene, camera, controls,
initPos: new Vector3(0, 1, 0),
playerModelConfig: { ...PLAYER_MODEL_BASE, model: ual.scene, animations: ual.animations },
colliders: [{ motion: "static", shape: { kind: "mesh", source: ground } }],
});
// Der Controller skaliert das Modell selbst (playerCapsuleHeight × scale). Ausgelesen statt nachgerechnet:
// daran hängen Dummies, Waffen, Schilder und alle Abstände.
const figureScale = player.playerModel.getWorldScale(new Vector3()).x;
for (const texture of groundTextures) texture.repeat.setScalar(GROUND_SIZE / (GROUND_TILE * figureScale));
const stage = new Group();
stage.scale.setScalar(figureScale);
scene.add(stage);
const clips = new Map(motions.animations.map((clip) => [clip.name, clip]));
const gripFingers = fingerPoseClip(AnimationClip.findByName(ual.animations, "Pistol_Idle_Loop"), "Finger_Waffe");
const looseFingers = fingerPoseClip(AnimationClip.findByName(ual.animations, "Idle_Loop"), "Finger_locker");
const weaponScenes = new Map(weaponIds.map((id, index) => [id, weaponFiles[index].scene]));
const weaponObjects = new Map();
const dummies = DUMMIES.map((entry, index) => {
const clip = clips.get(entry.clip);
if (!clip) throw new Error(`weapons-mocap.glb ohne Clip ${entry.clip}`);
const dummy = createDummy({
template,
clip,
fingers: entry.weapon ? gripFingers : looseFingers,
weapon: entry.weapon ? weaponScenes.get(entry.weapon) : null,
hand: entry.hand ?? "r",
grip: entry.weapon ? WEAPONS[entry.weapon].grip : null,
});
const { x, z } = slotPosition(index);
dummy.root.position.set(x, 0, z);
stage.add(dummy.root);
const label = createLabel(labelLines(entry));
label.position.set(x, LABEL_HEIGHT, z);
stage.add(label);
if (dummy.weapon) weaponObjects.set(entry.weapon, [...(weaponObjects.get(entry.weapon) ?? []), dummy.weapon]);
return dummy;
});
const playback = { tempo: 1, paused: false };
createShowroomPanel({ playback, weaponObjects });
/**
* Stellt die Aufstellung vor die Spielfigur. Wohin die Kamera anfangs schaut, bestimmt der Controller
* (rotateY der Kapsel, daraus die Kameraposition in CameraSystem.ts) — deshalb wird die Aufstellung nach dem
* ersten Bild an den Blick gehängt statt die Figur zu versetzen. stage +Z zeigt zurück zur Figur, die Dummies
* schauen sie also an.
*/
function placeStage() {
const view = camera.getWorldDirection(new Vector3());
view.y = 0;
if (view.lengthSq() < 1e-6) return false;
view.normalize();
const position = player.playerCapsule.position;
stage.position.set(
position.x + view.x * VIEW_DISTANCE * figureScale, 0, position.z + view.z * VIEW_DISTANCE * figureScale);
stage.rotation.y = Math.atan2(-view.x, -view.z);
return true;
}
const timer = new Timer();
timer.connect(document);
let staged = false;
function loop(time) {
timer.update(time);
const delta = Math.min(timer.getDelta(), 1 / 40);
player.update(delta);
if (!staged) staged = placeStage();
if (!playback.paused) {
for (const dummy of dummies) dummy.mixer.update(delta * playback.tempo);
}
sun.update();
renderer.render(scene, camera);
}
renderer.setAnimationLoop(loop);
addEventListener("resize", () => {
renderer.setSize(container.clientWidth, container.clientHeight);
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
});
// Für die Prüfung im Browser (Griff einpassen, Schleifennaht, Bildzeit). Die Seite ist ein Prüfstand, kein Spiel.
window.__showroom = { renderer, scene, camera, player, stage, dummies, playback, figureScale, loop };
- Schritt 5: Seite im Browser prüfen
preview_start mit { name: "animationen" }, dann navigate auf http://localhost:5180/animationen.html.
Prüfen:
read_console_messagesmitonlyErrors: true— keine Fehler.javascript_tool:Erwartet: 13 Dummies, 8 Waffen,const s = window.__showroom; ({ dummies: s.dummies.length, maßstab: s.figureScale, stage: s.stage.position.toArray().map((v) => +v.toFixed(2)), drehung: +(s.stage.rotation.y * 180 / Math.PI).toFixed(1), waffen: s.dummies.filter((d) => d.weapon).length })figureScaleetwa 0,49,stagenicht im Ursprung.- Laufen die Mixer? Zeit zweimal lesen:
Erwartet: jeder Wert etwa 0,5.
const a = window.__showroom.dummies.map((d) => d.mixer.time); await new Promise((r) => setTimeout(r, 500)); const b = window.__showroom.dummies.map((d) => d.mixer.time); b.map((t, i) => +(t - a[i]).toFixed(2)); - Sind die Dummies im Bild? Reihe 1 auf die Bildfläche projizieren (
camera.position.clone()liefert den Zielvektor, ohne three im Browser noch einmal zu importieren):Erwartet: mehrere Einträge mit |x| ≤ 1, |y| ≤ 1 und z < 1. Sonstconst s = window.__showroom; s.stage.updateMatrixWorld(true); s.dummies.slice(0, 8).map((d) => { const p = d.root.getWorldPosition(s.camera.position.clone()).project(s.camera); return [+p.x.toFixed(2), +p.y.toFixed(2), +p.z.toFixed(2)]; })placeStagenachbessern. - Läuft die Spielfigur? Canvas anklicken,
Wpercomputer-Aktion 1 s halten, Position vorher/nachher überwindow.__showroom.player.playerCapsule.position.toArray()vergleichen. computerscreenshotfür den Gesamteindruck.
- Schritt 6: Committen
git add animationen.html vite.config.js .claude/launch.json src/showroom/labels.js src/showroom/panel.js src/showroom/main.js
git commit -m "feat(showroom): Testseite mit Dummies, Schildern und Panel"
Task 9: Griff einpassen und Hand bestätigen
Files:
- Modify:
src/showroom/lineup.js(Griffwerte je Waffe, gegebenenfallshand) - Modify:
docs/superpowers/specs/2026-09-17-animationen-design.md(Abschnitt „Griff einpassen": gemessene Werte)
Interfaces:
-
Consumes: die laufende Seite aus Task 8,
window.__showroom. -
Produces:
WEAPONS[id].gripmit eingepassten Werten, bestätigtehandje Dummy. -
Schritt 1: Hand je Clip ablesen
Seite laden, playback.paused setzen und den Pistolen-Dummy an einer ruhigen Stelle betrachten:
const s = window.__showroom;
s.playback.paused = true;
s.dummies.forEach((d) => d.mixer.setTime(1.0));
Nahaufnahme ohne den Controller zu stören: Bildtakt anhalten, mit einer eigenen Kamera einmal zeichnen.
const s = window.__showroom;
const THREE = s.camera.constructor;
s.renderer.setAnimationLoop(null);
const cam = s.camera.clone();
const hand = s.dummies[0].root.getObjectByName("hand_r");
const target = hand.getWorldPosition(s.camera.position.clone());
const offset = s.camera.position.clone().set(0.6, 0.2, 0.6).multiplyScalar(s.figureScale);
cam.position.copy(target).add(offset);
cam.lookAt(target);
cam.updateProjectionMatrix();
s.renderer.render(s.scene, cam);
Danach screenshot. Für die Draufsicht denselben Block mit dem Versatz (0, 0.9, 0.05) × figureScale.
Zum Weiterlaufen: window.__showroom.renderer.setAnimationLoop(window.__showroom.loop).
Festhalten: Hält die Figur die Waffe rechts oder links? Bei „links" den Eintrag in DUMMIES auf hand: "l" ändern.
- Schritt 2: Pistole einpassen
Im Panel unter „Griff" die Pistole wählen und Position und Drehung so einstellen, dass:
- das Griffstück in der Handfläche liegt,
- der Lauf entlang der Hand nach vorn zeigt,
- nichts im Unterarm steckt.
Startwert: Drehung [0, 0, 90] bringt die Längsachse der Waffe (+X) auf die Knochenachse der Hand (+Y). Von dort in
Schritten von 10° und 1 cm nachziehen. Nach jeder Änderung Nahaufnahme wie in Schritt 1.
- Schritt 3: Gewehre einpassen
Die vier langen Waffen mit dem Wert der Pistole beginnen (das Pack verspricht denselben Griffpunkt) und nur
nachziehen, was sichtbar abweicht. Prüfen an einem Gewehr-Dummy mit mixer.setTime, dass die Waffe über den ganzen
Clip in der Hand bleibt (drei Zeitpunkte: Anfang, Mitte, Ende).
- Schritt 4: Werte übernehmen
Im Panel je Waffe „Werte ausgeben" drücken und die ausgegebenen Zahlen in src/showroom/lineup.js in WEAPONS
eintragen. Seite neu laden und mit read_console_messages prüfen, dass nichts bricht, dann je Waffe ein Bild
machen.
- Schritt 5: Maske und Normalmap gegenprüfen
Nahaufnahme der AK47 (Holz und Metall im selben Bild) und der Pistole. Erwartet: Das Holz wirkt matt und nicht
metallisch, das Metall spiegelt die Umgebung. Fällt es umgekehrt aus, in tools/assets/weapons.mjs in
maskToMetallicRoughness die beiden Kanäle tauschen, Waffen neu bauen und den Befund im Entwurf berichtigen.
- Schritt 6: Tests laufen lassen
Run: npm test
Expected: PASS, alle Dateien grün.
- Schritt 7: Committen
git add src/showroom/lineup.js docs/superpowers/specs/2026-09-17-animationen-design.md
git commit -m "feat(showroom): Griffe der fünf Waffen im Browser eingepasst"
Task 10: Schlussprüfung
Files:
- Modify:
docs/superpowers/specs/2026-09-17-animationen-design.md(Abschnitt „Gemessen im Browser")
Interfaces:
-
Consumes: alles aus Task 1–9.
-
Produces: den Nachweis, dass Seite, Spiel und Tests zusammen laufen.
-
Schritt 1: Schleifennaht messen
const s = window.__showroom;
function pose(dummy, time) {
dummy.mixer.setTime(time);
dummy.root.updateMatrixWorld(true);
const out = [];
dummy.root.traverse((o) => { if (o.isBone) out.push(o.getWorldQuaternion(s.camera.quaternion.clone())); });
return out;
}
const worst = (a, b) => Math.max(...a.map((q, i) => q.angleTo(b[i]) * 180 / Math.PI));
s.dummies.map((d) => {
const step = 1 / 30;
const naht = worst(pose(d, d.clip.duration - step), pose(d, step));
let größterSchritt = 0;
for (let t = 0; t + 2 * step < d.clip.duration; t += 10 * step) {
größterSchritt = Math.max(größterSchritt, worst(pose(d, t), pose(d, t + 2 * step)));
}
return { clip: d.clip.name, naht: +naht.toFixed(1), größterSchritt: +größterSchritt.toFixed(1) };
});
Erwartet: naht je Dummy nicht größer als größterSchritt. Danach Seite neu laden.
- Schritt 2: Bildzeit messen
await new Promise((done) => {
const zeiten = [];
let vorher = performance.now();
const zähle = () => {
const jetzt = performance.now();
zeiten.push(jetzt - vorher);
vorher = jetzt;
if (zeiten.length < 120) requestAnimationFrame(zähle);
else done(null);
};
requestAnimationFrame(zähle);
window.__zeiten = zeiten;
});
const z = [...window.__zeiten].sort((a, b) => a - b);
({ median: +z[60].toFixed(1), max: +z.at(-1).toFixed(1), calls: window.__showroom.renderer.info.render.calls })
Erwartet: Median unter 20 ms auf diesem Rechner. Wert festhalten.
- Schritt 3: Das Spiel prüfen
navigate auf http://localhost:5180/ (dieselbe Dev-Instanz), warten, bis die Welt steht, read_console_messages
mit onlyErrors: true. Erwartet: keine neuen Fehler, das Spiel läuft wie vorher.
- Schritt 4: Bauen und testen
npm run build
Erwartet: dist/index.html und dist/animationen.html entstehen, kein Fehler.
npm test
Erwartet: Test Files 22 passed (22) — die 18 vorhandenen plus weapons, motions, dummies und showroom,
keine fehlgeschlagenen Tests.
- Schritt 5: Messwerte in den Entwurf eintragen
In docs/superpowers/specs/2026-09-17-animationen-design.md vor „Fehlerfälle und Risiken" einen Abschnitt
„## Gemessen im Browser (2026-09-17)" einfügen, mit:
-
Dateigrößen:
weapons-mocap.glbund die fünf Waffen, -
Maßstab der Spielfigur und daraus die Größe der Dummies,
-
Hand je Clip (bestätigt oder geändert),
-
Griffwerte je Waffe,
-
Naht und größter Schritt je Dummy,
-
Bildzeit (Median, Maximum, Draw Calls),
-
Befund zu Maske und Normalmap,
-
auffällige Durchdringungen (Taunt am Kopf, linke Hand am Gewehr).
-
Schritt 6: Committen
git add docs/superpowers/specs/2026-09-17-animationen-design.md
git commit -m "docs: Messwerte der Browserprüfung im Entwurf festhalten"
- Schritt 7: Abschluss
Der Branch leonida/animationen ist fertig. Für das Zusammenführen die Skill superpowers:finishing-a-development-branch
verwenden und den Nutzer entscheiden lassen, ob er nach main will.