# 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.