From 456fbe89b978671070eeee065593ea01b2d76aed Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 20:53:02 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Umsetzungsplan=20f=C3=BCr=20die=20Anima?= =?UTF-8?q?tions-Testseite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-17-animationen.md | 2448 +++++++++++++++++ .../specs/2026-09-17-animationen-design.md | 8 +- 2 files changed, 2452 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-17-animationen.md diff --git a/docs/superpowers/plans/2026-09-17-animationen.md b/docs/superpowers/plans/2026-09-17-animationen.md new file mode 100644 index 0000000..151e760 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-animationen.md @@ -0,0 +1,2448 @@ +# 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/.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 `. +- **Tests:** einzeln `npx vitest run tests/`, 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`: + +```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`: + +```js +// 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 -> public/models/weapons/.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** + +```bash +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` 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** + +```bash +mkdir -p "C:/Users/kenso/AppData/Local/Temp/leonida-assets/weapons" +``` + +```bash +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`: + +```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`: + +```js +// 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: + +```js +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: + +```js +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 "); + 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** + +```bash +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** + +```bash +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` (22 Einträge), `SOURCE_HIPS = "Hips"` + - `clipId(fileName: string) -> string` + - `rigWorld(rig) -> Map`, rig ist `Array<{ name, parent, position, quaternion, scale? }>` in Eltern-vor-Kind-Reihenfolge + - `createRetargeter(rig, { boneMap?, hipsScale? }) -> (frame) => { local: Map, hips: Vector3 }`, frame ist `{ rotations: Map, hips: Vector3 }` in cm + +- [ ] **Schritt 1: Failing Tests schreiben** + +`tests/motions.test.js`: + +```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`: + +```js +// Ü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 -> 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, 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** + +```bash +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: + +```js +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: + +```js +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: + +```js +import { MathUtils, Matrix4, Quaternion, Vector3 } from "three"; +``` + +Ans Dateiende anhängen: + +```js +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** + +```bash +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` + - `flattenPoses(poses) -> { rotations: Map, 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** + +```bash +mkdir -p "C:/Users/kenso/AppData/Local/Temp/leonida-assets/motions" +``` + +```bash +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: + +```js +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: + +```js +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: + +```js +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: + +```js +/** + * 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 "); + 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** + +```bash +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: + +```js +import { statSync } from "node:fs"; +``` + +Ans Dateiende anhängen: + +```js +/** 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** + +```bash +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` (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: + +```js +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: + +```js +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`: + +```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** + +```bash +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`: + +```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`: + +```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** + +```bash +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`: + +```html + + + + + + Leonida · Animationen + + + +
+ + + +``` + +In `vite.config.js` den Import ergänzen und den `build`-Block einfügen (vor `server`): + +```js +import { fileURLToPath } from "node:url"; +``` + +```js + // 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: + +```json + { + "name": "animationen", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"], + "port": 5180, + "url": "http://localhost:5180" + } +``` + +- [ ] **Schritt 2: Schild schreiben** + +`src/showroom/labels.js`: + +```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`: + +```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`: + +```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`: + ```js + 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: + ```js + 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): + ```js + 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** + +```bash +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: + +```js +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. + +```js +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** + +```bash +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** + +```js +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** + +```js +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** + +```bash +npm run build +``` + +Erwartet: `dist/index.html` und `dist/animationen.html` entstehen, kein Fehler. + +```bash +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** + +```bash +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. diff --git a/docs/superpowers/specs/2026-09-17-animationen-design.md b/docs/superpowers/specs/2026-09-17-animationen-design.md index e6fbf1c..5a0745e 100644 --- a/docs/superpowers/specs/2026-09-17-animationen-design.md +++ b/docs/superpowers/specs/2026-09-17-animationen-design.md @@ -115,12 +115,12 @@ Höhen der Wirbelsäule im Vergleich (Ruhepose, m): ### Maßstab -Die Spielfigur hat nicht die 1,83 m des Mannequins (siehe Befund). Die Aufstellung hängt deshalb an einer Gruppe `stage`. Sie bekommt nach `player.init` den **Weltmaßstab des Spielermodells** (`player.playerModel.getWorldScale`), ausgelesen und nicht nachgerechnet. Alle Maße unten sind Mannequin-Meter: Dummies, Waffen, Schilder, Abstände und der Startpunkt. Welteinheit = Mannequin-Meter × Maßstab. Dummies und Spielfigur sind damit gleich groß. +Die Spielfigur hat nicht die 1,83 m des Mannequins (siehe Befund). Die Aufstellung hängt deshalb an einer Gruppe `stage`. Sie bekommt nach `player.init` den **Weltmaßstab des Spielermodells** (`player.playerModel.getWorldScale`), ausgelesen und nicht nachgerechnet. Alle Maße unten sind Mannequin-Meter: Dummies, Waffen, Schilder, Abstände und der Abstand zur Spielfigur. Welteinheit = Mannequin-Meter × Maßstab. Dummies und Spielfigur sind damit gleich groß. ### Spielfigur - **Modell:** `ual.glb` mit `playerController` und `PLAYER_MODEL_BASE` aus `src/characters/index.js`, wie im Spiel. Laufen, Sprinten, Springen und Kamera verhalten sich wie gewohnt. -- **Start:** bei (0 | 8) Mannequin-Metern vor Reihe 1, mit Blick nach −Z auf die Dummies. `player.init` beginnt über dem Ursprung, danach setzt `player.reset` die Figur auf 8 × Maßstab. +- **Start:** Die Figur beginnt über dem Ursprung. Wohin die Kamera anfangs schaut, bestimmt der Controller: `PLAYER_MODEL_BASE.rotateY` dreht die Kapsel um −90°, und `CameraSystem.ts` setzt die Kamera daraus seitlich. Deshalb wird nicht die Figur versetzt, sondern die Aufstellung. Nach dem ersten Bild steht `stage` 8 Mannequin-Meter vor der Figur in Blickrichtung der Kamera und ist so gedreht, dass die Dummies zur Figur schauen. ### Aufstellung (`src/showroom/lineup.js`) @@ -144,7 +144,7 @@ Reine Daten und reine Funktionen, ohne Browser testbar. - **Reihe 1:** bei z = 0, acht Plätze bei x = −12,25 + 3,5 · i. - **Reihe 2:** bei z = −8. Die ersten vier Plätze liegen wie in Reihe 1 (x = −12,25 bis −1,75), der Bogenschütze steht bei x = 8,75. Zum nächsten Dummy hat er damit 8 m Abstand, genug für seine 3,13 m Wanderung. -- **Blickrichtung:** Alle Dummies schauen nach +Z zur Spielfigur. Die Clips sind darauf ausgerichtet (siehe Umbau, Schritt 3), die Dummies werden also nicht gedreht. +- **Blickrichtung:** Alle Dummies schauen innerhalb von `stage` nach +Z, also zur Spielfigur (siehe Start). Die Clips sind darauf ausgerichtet (Umbau, Schritt 3), die Dummies selbst werden nicht gedreht. - **Schild:** ein Canvas-Sprite 2,2 m über dem Platz (nicht am Dummy, damit es beim Bogenschützen stehen bleibt). Es zeigt den Text aus der Spalte „Schild" und darunter die Waffe oder „ohne Waffe". - **„Hand":** Vorgabe ist `r`. Hält ein Darsteller die Waffe sichtbar links, zum Beispiel weil die rechte Hand nachlädt, wird der Eintrag im Browser auf `l` gesetzt und dies im Bericht festgehalten. @@ -260,7 +260,7 @@ node tools/assets/weapons.mjs -> public/models/weapons/ - **Browser:** - `animationen.html` lädt ohne Konsolenfehler, alle 13 Mixer laufen. - Übersichtsbild und Nahaufnahme je Waffe. - - Schleifennaht: Die Knochendrehungen eines Dummys ein Bild vor und ein Bild nach dem Rücksprung liegen nicht weiter auseinander als zwei benachbarte Bilder mitten im Clip, gemessen an den Knochen mit der größten Naht vor dem Umbau. + - Schleifennaht: Bei jedem Dummy ist der größte Knochenwinkel zwischen der Pose 1/30 s vor und 1/30 s nach dem Rücksprung nicht größer als der größte Winkel zwischen zwei Posen im Abstand von 2/30 s im Clipinneren. - Die Spielfigur läuft per Tastatur, ihre Position ändert sich. - Bildzeit mit allen Dummies. - Maskenbelegung auf Sicht: Das Holz der AK47 glänzt nicht metallisch.