Files
gta6/docs/superpowers/plans/2026-09-17-animationen.md
T
2026-09-17 20:53:02 +02:00

108 KiB
Raw Blame History

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ür FBXLoader hängen daran).
  • Quellen (kommen nie ins Repo, *.zip steht in .gitignore):
    • C:/Users/kenso/Downloads/MotionLibrary_Weapons.zip → entpackt nach C:/Users/kenso/AppData/Local/Temp/leonida-assets/motions/ (11 FBX direkt im Ordner)
    • C:/Users/kenso/Downloads/ASISWeaponsPack.zip → entpackt nach C:/Users/kenso/AppData/Local/Temp/leonida-assets/weapons/ (darin der Ordner WeaponsPack)
  • Erzeugte Dateien werden versioniert: public/models/motions/weapons-mocap.glb und public/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 Gruppe stage trä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.js und src/world/{sky,shadows}.js werden nur importiert.
  • Arbeitsbaum A:/developer/GitHub-desktop/gta6/.claude/worktrees/animationen, Branch leonida/animationen, alle Befehle von dort.
    • Auf leonida/strassen arbeitet parallel eine andere Sitzung. Der Hauptordner wird nie verändert.
    • Gestaget wird mit Pfaden, nie mit git add -A. Kein git stash.
    • Die Isolationswache lehnt verschachtelte Befehle ab (Variablen als Befehl, &&-Ketten mit Heredoc, Pfade mit Leerzeichen als Skript). Deshalb einfache Einzelbefehle und ausgeschriebene Pfade.
  • 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>, alle npm 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 --short gibt nichts aus, npm test meldet 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 = 1024
    • readTga(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) -> number
    • checkLength(id: string, positions: Float32Array, [min, max]) -> number
    • weaponTarget(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, weaponTarget aus Task 1.

  • Produces:

    • loadFbx(path: string) -> Promise<THREE.Group> in tools/assets/fbx.mjs
    • buildWeapon(source, packDir, loadFbx) -> Promise<{ document, length, vertices }> in tools/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.mjs ergä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) -> string
    • rigWorld(rig) -> Map<name, { matrix: Matrix4, quaternion: Quaternion }>, rig ist Array<{ name, parent, position, quaternion, scale? }> in Eltern-vor-Kind-Reihenfolge
    • createRetargeter(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, neue describe-Blöcke anhängen)

Interfaces:

  • Consumes: SOURCE_HIPS aus Task 3.

  • Produces:

    • SAMPLE_RATE = 30, BLEND_SPEED = 90, BLEND_MIN = 0.25, BLEND_MAX = 1.5
    • sampleTimes(start, end, rate?) -> number[]
    • headingOf(quaternion) -> number (Bogenmaß)
    • faceForward(frames) -> frames
    • removeDrift(frames) -> frames
    • seamAngle(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 neue describe-Blöcke), tests/showroom.test.js (Clip-Datei)

Interfaces:

  • Consumes: alles aus Task 3 und 4, loadFbx aus Task 2.

  • Produces:

    • sampleFbx(root, rate?, boneMap?) -> { times: number[], frames, hipsRestHeight: number }
    • rigFromScene(scene) -> rig
    • stripToSkeleton(document) -> Promise<void>
    • flattenPoses(poses) -> { rotations: Map<name, Float32Array>, hips: Float32Array }
    • addClip(document, id, times, poses, hipsBone?) -> Animation
    • public/models/motions/weapons-mocap.glb: 11 Clips, je 22 Drehungskanäle und ein Translationskanal auf pelvis
  • 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 (neuer describe-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 = 8
    • slotPosition(index) -> { x, z } in Mannequin-Metern
    • labelLines(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.js dazu).

  • Produces:

    • FINGER_BONE: RegExp
    • fingerPoseClip(source: AnimationClip, name?) -> AnimationClip (nur Fingerdrehungen, erster Keyframe, 1 s lang)
    • applyGrip(object: Object3D, grip) -> void
    • createDummy({ template, clip, fingers, weapon?, hand?, grip? }) -> { root, mixer, clip, weapon } — clip kommt 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_BASE aus src/characters/index.js, createSky und createSunShadows aus src/world/.

  • Produces: die laufende Seite unter /animationen.html, dazu window.__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:

  1. read_console_messages mit onlyErrors: true — keine Fehler.
  2. javascript_tool:
    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 })
    
    Erwartet: 13 Dummies, 8 Waffen, figureScale etwa 0,49, stage nicht im Ursprung.
  3. Laufen die Mixer? Zeit zweimal lesen:
    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));
    
    Erwartet: jeder Wert etwa 0,5.
  4. 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):
    const 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)];
    })
    
    Erwartet: mehrere Einträge mit |x| ≤ 1, |y| ≤ 1 und z < 1. Sonst placeStage nachbessern.
  5. Läuft die Spielfigur? Canvas anklicken, W per computer-Aktion 1 s halten, Position vorher/nachher über window.__showroom.player.playerCapsule.position.toArray() vergleichen.
  6. computer screenshot fü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, gegebenenfalls hand)
  • 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].grip mit eingepassten Werten, bestätigte hand je 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.glb und 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.