2449 lines
108 KiB
Markdown
2449 lines
108 KiB
Markdown
# Animations-Testseite mit Waffen-Dummies — Umsetzungsplan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Eine eigene Seite `animationen.html`, auf der 13 Mannequin-Dummies die 11 Mocap-Clips in Dauerschleife spielen, acht davon mit passender Waffe in der Hand, während der Nutzer mit seiner eigenen Figur dazwischen herumläuft.
|
||
|
||
**Architecture:** Zwei Node-Skripte bauen die Quellen einmalig um: `tools/assets/motions.mjs` überträgt die Xsens-Mocap-Clips auf das Skelett von `ual.glb` und schreibt `public/models/motions/weapons-mocap.glb`, `tools/assets/weapons.mjs` macht aus den fünf Unreal-Meshes je eine `public/models/weapons/<id>.glb`. Beide bestehen aus reinen, getesteten Rechenfunktionen plus einem `main()`, das nur liest und schreibt. Die Seite unter `src/showroom/` lädt nur fertige Dateien: `lineup.js` ist die Aufstellungstabelle, `dummies.js` baut einen Dummy aus Modell, Clip, Fingerhaltung und Waffe, `main.js` setzt Szene, Spielfigur und Bildtakt zusammen.
|
||
|
||
**Tech Stack:** three.js 0.184, three-player-controller 0.6.2, Vite 7, vitest 3.2, `@gltf-transform/core|extensions|functions` 4.5.0, `meshoptimizer` 1.2.0, `sharp` 0.35.4, `lil-gui` 0.21.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-17-animationen-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- **Keine neuen Abhängigkeiten.** Alles oben steht schon in `package.json`; three bleibt auf 0.184.0 festgenagelt (die Node-Platzhalter für `FBXLoader` hängen daran).
|
||
- **Quellen** (kommen nie ins Repo, `*.zip` steht in `.gitignore`):
|
||
- `C:/Users/kenso/Downloads/MotionLibrary_Weapons.zip` → entpackt nach `C:/Users/kenso/AppData/Local/Temp/leonida-assets/motions/` (11 FBX direkt im Ordner)
|
||
- `C:/Users/kenso/Downloads/ASISWeaponsPack.zip` → entpackt nach `C:/Users/kenso/AppData/Local/Temp/leonida-assets/weapons/` (darin der Ordner `WeaponsPack`)
|
||
- **Erzeugte Dateien werden versioniert:** `public/models/motions/weapons-mocap.glb` und `public/models/weapons/{pistol,ak47,m4,shotgun870,l96}.glb`.
|
||
- **Clip-Namen** (genau so, aus den Dateinamen): `hunter_bowArrow_tk01`, `leftHandThrow_grenade_tk01`, `leftHandThrow_grenade_tk02`, `taunt_shootSelfInHead_tk01`, `taunt_shootSelfInHead_tk02`, `stabTwist_Knife_tk01`, `stabTwist_Knife_tk02`, `idle_loadGun-Hand_tk01`, `idle_loadGun-Hand_tk02`, `idle_loadGun-Rifle_tk04`, `idle_loadGun-Rifle_tk05`.
|
||
- **Waffen-IDs:** `pistol`, `ak47`, `m4`, `shotgun870`, `l96`. Von der AK47 nur „Option 2 (Same Socketing)".
|
||
- **Maße:** Alles in `src/showroom/` rechnet in Mannequin-Metern. Die Gruppe `stage` trägt den Weltmaßstab des Spielermodells (`player.playerModel.getWorldScale`), ausgelesen statt nachgerechnet.
|
||
- **Nicht anfassen:** `src/main.js`, `src/world/`, `src/ui/`, `src/characters/`, `public/data/`, `tools/extract/`, `public/models/ual.glb`. `src/characters/index.js` und `src/world/{sky,shadows}.js` werden nur importiert.
|
||
- **Arbeitsbaum** `A:/developer/GitHub-desktop/gta6/.claude/worktrees/animationen`, Branch `leonida/animationen`, alle Befehle von dort.
|
||
- Auf `leonida/strassen` arbeitet parallel eine andere Sitzung. Der Hauptordner wird nie verändert.
|
||
- Gestaget wird mit Pfaden, nie mit `git add -A`. Kein `git stash`.
|
||
- Die Isolationswache lehnt verschachtelte Befehle ab (Variablen als Befehl, `&&`-Ketten mit Heredoc, Pfade mit Leerzeichen als Skript). Deshalb einfache Einzelbefehle und ausgeschriebene Pfade.
|
||
- **Stil:** Kommentare, Testnamen und Commit-Nachrichten auf Deutsch, wie in den vorhandenen Dateien. Über einem Test, der eine bestimmte Fehlerart abfängt, steht ein Kommentar „Bruch: …". Einrückung vier Leerzeichen, doppelte Anführungszeichen, Semikolons.
|
||
- **Commit-Nachrichten** enden mit einer Leerzeile und `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
|
||
- **Tests:** einzeln `npx vitest run tests/<datei>`, alle `npm test`. Ausgangsstand: `Test Files 18 passed (18)`, `Tests 199 passed | 3 skipped (202)`.
|
||
|
||
## Vorbereitung (erledigt)
|
||
|
||
Der Arbeitsbaum steht auf `ee49fd7` (lokaler `main`) plus dem Spec-Commit `70730d9`. `npm ci` ist gelaufen, `three-player-controller/dist` ist aus dem Hauptordner hineinkopiert (nicht versioniert), der Ausgangsstand ist grün.
|
||
|
||
Die Rechenwege der beiden Umbau-Skripte sind als Wegwerf-Prototyp unter `node_modules/.cache/probe/` schon einmal gegen die echten Dateien gelaufen. Der Code in diesem Plan ist dieser erprobte Stand, die Zahlen in den Erwartungen sind daraus gemessen. `node_modules/` ist ignoriert, dort liegt nichts, was committet werden könnte.
|
||
|
||
- [ ] **Prüfen:** `git status --short` gibt nichts aus, `npm test` meldet 18 Dateien und 199 Tests grün.
|
||
|
||
---
|
||
|
||
### Task 1: TGA-Leser, Maske und Geometrie der Waffen
|
||
|
||
**Files:**
|
||
- Create: `tools/assets/weapons.mjs` (nur die reinen Funktionen, `main()` kommt in Task 2)
|
||
- Test: `tests/weapons.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts aus früheren Tasks.
|
||
- Produces:
|
||
- `WEAPON_SOURCES`: `Array<{ id, dir, mesh, base, normal, mask, length: [min, max] }>`
|
||
- `TEXTURE_SIZE = 1024`
|
||
- `readTga(bytes: Uint8Array) -> { width, height, data: Uint8Array }` (RGBA, oberste Bildzeile zuerst)
|
||
- `maskToMetallicRoughness({ width, height, data }) -> Uint8Array` (RGB)
|
||
- `meshArrays(geometry: THREE.BufferGeometry, quaternion: THREE.Quaternion) -> { positions, normals, uvs }` (Float32Array)
|
||
- `longestExtent(positions: Float32Array) -> number`
|
||
- `checkLength(id: string, positions: Float32Array, [min, max]) -> number`
|
||
- `weaponTarget(id: string) -> string` (absoluter Pfad)
|
||
|
||
- [ ] **Schritt 1: Failing Tests schreiben**
|
||
|
||
`tests/weapons.test.js`:
|
||
|
||
```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 <Ordner WeaponsPack> -> public/models/weapons/<id>.glb
|
||
//
|
||
// Das Pack bleibt außerhalb des Repos. Die Geometrie kommt über three.js' FBXLoader, die Materialien werden neu
|
||
// gebaut: T_ ist die Grundfarbe, N_ die Normalmap (schon im OpenGL-Format von glTF), M_ eine TGA-Maske mit
|
||
// Grau = Rauheit und Alpha = Metall (am AK47-Holz gemessen, s. Entwurf).
|
||
import { fileURLToPath } from "node:url";
|
||
import { Vector3 } from "three";
|
||
|
||
export const TEXTURE_SIZE = 1024;
|
||
|
||
/** Die fünf Waffen des Packs. length ist die erwartete Länge in Metern und fängt eine falsche Einheit ab. */
|
||
export const WEAPON_SOURCES = [
|
||
{ id: "pistol", dir: "Pistol", mesh: "SM_Pistol.fbx", base: "T_Pistol.png", normal: "N_Pistol.png", mask: "M_Pistol.tga", length: [0.15, 0.25] },
|
||
{ id: "ak47", dir: "AK47_Option2(Same Socketing)", mesh: "SM_AK47.fbx", base: "T_AK47.png", normal: "N_AK47.png", mask: "M_AK47.tga", length: [0.7, 1.3] },
|
||
{ id: "m4", dir: "M4 Carbine", mesh: "SM_M4_Carbine.fbx", base: "T_M4_Carbine.png", normal: "N_M4_Carbine.png", mask: "M_M4_Carbine.tga", length: [0.7, 1.3] },
|
||
{ id: "shotgun870", dir: "870 Shotgun", mesh: "SM_870_Shotgun.fbx", base: "T_870_Shotgun.png", normal: "N_870_Shotgun.png", mask: "M_870_Shotgun.tga", length: [0.7, 1.3] },
|
||
{ id: "l96", dir: "L96 Sniper Rifle", mesh: "SM_L96_Sniper_Rifle.fbx", base: "T_L96_Sniper_Rifle.png", normal: "N_L96_Sniper_Rifle.png", mask: "M_L96_Sniper_Rifle.tga", length: [0.7, 1.3] },
|
||
];
|
||
|
||
/**
|
||
* Liest ein unkomprimiertes 32-Bit-TGA (Typ 2) und gibt RGBA mit der obersten Bildzeile zuerst zurück. Mehr kann
|
||
* der Leser nicht, und mehr steckt auch nicht im Pack: alle fünf Masken sind Typ 2, 32 Bit, Ursprung unten links.
|
||
*/
|
||
export function readTga(bytes) {
|
||
const idLength = bytes[0];
|
||
const colorMapType = bytes[1];
|
||
const imageType = bytes[2];
|
||
const width = bytes[12] | (bytes[13] << 8);
|
||
const height = bytes[14] | (bytes[15] << 8);
|
||
const depth = bytes[16];
|
||
const flags = bytes[17];
|
||
if (colorMapType !== 0 || imageType !== 2 || depth !== 32) {
|
||
throw new Error(`TGA: nur unkomprimiert mit 32 Bit lesbar (Typ ${imageType}, ${depth} Bit, Farbtabelle ${colorMapType})`);
|
||
}
|
||
if (flags & 0x10) throw new Error("TGA: Zeilen von rechts nach links werden nicht unterstützt");
|
||
const start = 18 + idLength;
|
||
if (bytes.length < start + width * height * 4) throw new Error("TGA: Datei kürzer als ihre Bildgröße");
|
||
const originTop = (flags & 0x20) !== 0;
|
||
const data = new Uint8Array(width * height * 4);
|
||
for (let row = 0; row < height; row++) {
|
||
const sourceRow = originTop ? row : height - 1 - row;
|
||
for (let x = 0; x < width; x++) {
|
||
const from = start + (sourceRow * width + x) * 4;
|
||
const to = (row * width + x) * 4;
|
||
data[to] = bytes[from + 2]; // TGA speichert BGRA
|
||
data[to + 1] = bytes[from + 1];
|
||
data[to + 2] = bytes[from];
|
||
data[to + 3] = bytes[from + 3];
|
||
}
|
||
}
|
||
return { width, height, data };
|
||
}
|
||
|
||
/** Maske -> metallicRoughness von glTF: Rauheit nach G, Metall nach B. R bleibt ungenutzt. */
|
||
export function maskToMetallicRoughness({ width, height, data }) {
|
||
const rgb = new Uint8Array(width * height * 3);
|
||
for (let i = 0; i < width * height; i++) {
|
||
rgb[i * 3] = 255;
|
||
rgb[i * 3 + 1] = data[i * 4];
|
||
rgb[i * 3 + 2] = data[i * 4 + 3];
|
||
}
|
||
return rgb;
|
||
}
|
||
|
||
/**
|
||
* Eckpunkte, Normalen und UVs eines FBX-Meshes für glTF. Die Knotendrehung wird eingerechnet (die L96 liegt im
|
||
* Pack um 180° gedreht), der Knotenmaßstab nicht: Die Eckpunkte liegen schon in Metern, die Pistole trägt
|
||
* zusätzlich den Maßstab 100. Die v-Achse wird gespiegelt, weil FBX den UV-Ursprung unten links hat, glTF oben links.
|
||
*/
|
||
export function meshArrays(geometry, quaternion) {
|
||
const source = geometry.index ? geometry.toNonIndexed() : geometry;
|
||
const position = source.getAttribute("position");
|
||
const normal = source.getAttribute("normal");
|
||
const uv = source.getAttribute("uv");
|
||
if (!position || !normal || !uv) throw new Error("Mesh ohne position, normal oder uv");
|
||
const positions = new Float32Array(position.count * 3);
|
||
const normals = new Float32Array(position.count * 3);
|
||
const uvs = new Float32Array(position.count * 2);
|
||
const point = new Vector3();
|
||
for (let i = 0; i < position.count; i++) {
|
||
point.fromBufferAttribute(position, i).applyQuaternion(quaternion).toArray(positions, i * 3);
|
||
point.fromBufferAttribute(normal, i).applyQuaternion(quaternion).normalize().toArray(normals, i * 3);
|
||
uvs[i * 2] = uv.getX(i);
|
||
uvs[i * 2 + 1] = 1 - uv.getY(i);
|
||
}
|
||
return { positions, normals, uvs };
|
||
}
|
||
|
||
/** Längste Ausdehnung in Metern. */
|
||
export function longestExtent(positions) {
|
||
const min = [Infinity, Infinity, Infinity];
|
||
const max = [-Infinity, -Infinity, -Infinity];
|
||
for (let i = 0; i < positions.length; i += 3) {
|
||
for (let axis = 0; axis < 3; axis++) {
|
||
min[axis] = Math.min(min[axis], positions[i + axis]);
|
||
max[axis] = Math.max(max[axis], positions[i + axis]);
|
||
}
|
||
}
|
||
return Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
|
||
}
|
||
|
||
/** Bricht ab, wenn die Waffe nicht die erwartete Länge hat — dann stimmt die Einheit nicht. */
|
||
export function checkLength(id, positions, [min, max]) {
|
||
const length = longestExtent(positions);
|
||
if (!(length >= min && length <= max)) {
|
||
throw new Error(`${id}: ${length.toFixed(3)} m lang, erwartet ${min}–${max} m — falsche Einheit?`);
|
||
}
|
||
return length;
|
||
}
|
||
|
||
/** Zielpfad einer Waffe, unabhängig vom Arbeitsverzeichnis (wie placeTarget in tools/assets/places.mjs). */
|
||
export function weaponTarget(id) {
|
||
if (!/^[a-z0-9]+$/.test(id)) throw new Error(`Ungültige Waffen-ID "${id}": nur Kleinbuchstaben und Ziffern.`);
|
||
return fileURLToPath(new URL(`../../public/models/weapons/${id}.glb`, import.meta.url));
|
||
}
|
||
```
|
||
|
||
- [ ] **Schritt 4: Tests laufen lassen, sie müssen grün sein**
|
||
|
||
Run: `npx vitest run tests/weapons.test.js`
|
||
Expected: PASS, 12 Tests.
|
||
|
||
- [ ] **Schritt 5: Committen**
|
||
|
||
```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<THREE.Group>` in `tools/assets/fbx.mjs`
|
||
- `buildWeapon(source, packDir, loadFbx) -> Promise<{ document, length, vertices }>` in `tools/assets/weapons.mjs`
|
||
- die fünf GLB-Dateien, je ein Mesh mit einem Material und drei WebP-Texturen
|
||
|
||
- [ ] **Schritt 1: Quelle entpacken**
|
||
|
||
```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 <Ordner WeaponsPack aus ASISWeaponsPack.zip>");
|
||
process.exit(1);
|
||
}
|
||
const { loadFbx } = await import("./fbx.mjs");
|
||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.encoder": MeshoptEncoder });
|
||
for (const source of WEAPON_SOURCES) {
|
||
const { document, length, vertices } = await buildWeapon(source, packDir, loadFbx);
|
||
const target = weaponTarget(source.id);
|
||
await mkdir(dirname(target), { recursive: true });
|
||
await io.write(target, document);
|
||
const { min, max } = getBounds(document.getRoot().listScenes()[0]);
|
||
console.log(`${source.id}.glb ${((await stat(target)).size / 1e6).toFixed(2)} MB, ${length.toFixed(2)} m lang,`,
|
||
`${vertices} Eckpunkte, Maße ${min.map((value, axis) => (max[axis] - value).toFixed(2)).join(" × ")} m`);
|
||
}
|
||
}
|
||
|
||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|
||
```
|
||
|
||
- [ ] **Schritt 5: Umbau laufen lassen**
|
||
|
||
```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<MVN-Name, Mannequin-Name>` (22 Einträge), `SOURCE_HIPS = "Hips"`
|
||
- `clipId(fileName: string) -> string`
|
||
- `rigWorld(rig) -> Map<name, { matrix: Matrix4, quaternion: Quaternion }>`, rig ist `Array<{ name, parent, position, quaternion, scale? }>` in Eltern-vor-Kind-Reihenfolge
|
||
- `createRetargeter(rig, { boneMap?, hipsScale? }) -> (frame) => { local: Map<name, Quaternion>, hips: Vector3 }`, frame ist `{ rotations: Map<MVN-Name, Quaternion>, hips: Vector3 }` in cm
|
||
|
||
- [ ] **Schritt 1: Failing Tests schreiben**
|
||
|
||
`tests/motions.test.js`:
|
||
|
||
```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 <Ordner mit den 11 FBX> -> public/models/motions/weapons-mocap.glb
|
||
//
|
||
// Das Zip bleibt außerhalb des Repos. Die Rechenschritte sind reine Funktionen (tests/motions.test.js), main()
|
||
// liest nur Dateien und schreibt das Ergebnis.
|
||
import { Matrix4, Quaternion, Vector3 } from "three";
|
||
|
||
/**
|
||
* MVN-Knochen -> Mannequin-Knochen, zugeordnet nach gemessener Höhe in der Ruhepose. Spine2 (1,187 m) hat kein
|
||
* Gegenstück: Die Übertragung rechnet mit Weltdrehungen, seine Drehung steckt deshalb in der von spine_03.
|
||
*/
|
||
export const BONE_MAP = {
|
||
Hips: "pelvis",
|
||
Spine: "spine_01",
|
||
Spine1: "spine_02",
|
||
Spine3: "spine_03",
|
||
Neck: "neck_01",
|
||
Head: "Head",
|
||
LeftShoulder: "clavicle_l",
|
||
LeftArm: "upperarm_l",
|
||
LeftForeArm: "lowerarm_l",
|
||
LeftHand: "hand_l",
|
||
RightShoulder: "clavicle_r",
|
||
RightArm: "upperarm_r",
|
||
RightForeArm: "lowerarm_r",
|
||
RightHand: "hand_r",
|
||
LeftUpLeg: "thigh_l",
|
||
LeftLeg: "calf_l",
|
||
LeftFoot: "foot_l",
|
||
LeftToeBase: "ball_l",
|
||
RightUpLeg: "thigh_r",
|
||
RightLeg: "calf_r",
|
||
RightFoot: "foot_r",
|
||
RightToeBase: "ball_r",
|
||
};
|
||
export const SOURCE_HIPS = "Hips";
|
||
|
||
/** Clip-Name aus dem Dateinamen: 20210715_s231_idle_loadGun-Hand_tk01_ERJA-mvn359.fbx -> idle_loadGun-Hand_tk01. */
|
||
export function clipId(fileName) {
|
||
const match = /^\d{8}_s\d+_(.+)_ERJA-mvn\d+\.fbx$/.exec(fileName);
|
||
if (!match) throw new Error(`Unbekannter Dateiname: ${fileName}`);
|
||
return match[1];
|
||
}
|
||
|
||
/**
|
||
* Ruhepose eines Zielskeletts in Weltkoordinaten. rig ist eine Liste in Eltern-vor-Kind-Reihenfolge:
|
||
* { name, parent, position: [x, y, z], quaternion: [x, y, z, w], scale: [x, y, z] }. Liefert je Knochen die
|
||
* Weltmatrix und die Weltdrehung (Drehungen verkettet, das Mannequin hat überall Maßstab 1).
|
||
*/
|
||
export function rigWorld(rig) {
|
||
const world = new Map();
|
||
for (const bone of rig) {
|
||
const parent = bone.parent === null ? null : world.get(bone.parent);
|
||
if (bone.parent !== null && !parent) throw new Error(`Rig: ${bone.parent} steht nicht vor ${bone.name}`);
|
||
const rotation = new Quaternion().fromArray(bone.quaternion);
|
||
const local = new Matrix4().compose(
|
||
new Vector3().fromArray(bone.position),
|
||
rotation,
|
||
new Vector3().fromArray(bone.scale ?? [1, 1, 1]),
|
||
);
|
||
world.set(bone.name, {
|
||
matrix: parent ? parent.matrix.clone().multiply(local) : local,
|
||
quaternion: parent ? parent.quaternion.clone().multiply(rotation) : rotation.clone(),
|
||
});
|
||
}
|
||
return world;
|
||
}
|
||
|
||
/**
|
||
* Baut die Übertragung eines MVN-Bilds auf das Zielskelett. Ein Bild ist { rotations: Map<MVN-Knochen,
|
||
* Weltdrehung>, hips: Weltposition der Hüfte in cm }. Beide Skelette ruhen in derselben T-Pose mit Blick nach +Z,
|
||
* und jede MVN-Ruhe-Weltdrehung ist 0°. Deshalb gilt je zugeordnetem Knochen:
|
||
*
|
||
* Weltdrehung Ziel = Weltdrehung MVN · Ruhe-Weltdrehung Ziel, lokal = (Weltdrehung Eltern)⁻¹ · Weltdrehung
|
||
*
|
||
* Knochen ohne Zuordnung (root, Finger, Blattknochen) gehen mit ihrer Ruhedrehung in die Kette ein und bekommen
|
||
* keine Spur. Die Hüfte: cm -> m, mal hipsScale (Beinlänge Ziel ÷ Beinlänge Quelle), dann in den Raum ihres
|
||
* Elternknochens, der dafür unbewegt sein muss.
|
||
*/
|
||
export function createRetargeter(rig, { boneMap = BONE_MAP, hipsScale = 1 } = {}) {
|
||
const rest = rigWorld(rig);
|
||
const sourceOf = new Map(Object.entries(boneMap).map(([source, target]) => [target, source]));
|
||
const hipsBone = rig.find((bone) => bone.name === boneMap[SOURCE_HIPS]);
|
||
if (!hipsBone) throw new Error(`Rig ohne Hüftknochen ${boneMap[SOURCE_HIPS]}`);
|
||
if (hipsBone.parent !== null && sourceOf.has(hipsBone.parent)) {
|
||
throw new Error(`Elternknochen der Hüfte (${hipsBone.parent}) darf nicht animiert sein`);
|
||
}
|
||
const hipsParentInverse = hipsBone.parent === null
|
||
? new Matrix4()
|
||
: rest.get(hipsBone.parent).matrix.clone().invert();
|
||
|
||
return function retarget(frame) {
|
||
const world = new Map();
|
||
const local = new Map();
|
||
for (const bone of rig) {
|
||
const parentWorld = bone.parent === null ? new Quaternion() : world.get(bone.parent);
|
||
const source = sourceOf.get(bone.name);
|
||
if (source === undefined) {
|
||
world.set(bone.name, parentWorld.clone().multiply(new Quaternion().fromArray(bone.quaternion)));
|
||
continue;
|
||
}
|
||
const sourceWorld = frame.rotations.get(source);
|
||
if (!sourceWorld) throw new Error(`Bild ohne Drehung für ${source}`);
|
||
const targetWorld = sourceWorld.clone().multiply(rest.get(bone.name).quaternion);
|
||
world.set(bone.name, targetWorld);
|
||
local.set(bone.name, parentWorld.clone().invert().multiply(targetWorld));
|
||
}
|
||
const hips = frame.hips.clone().multiplyScalar(hipsScale / 100).applyMatrix4(hipsParentInverse);
|
||
return { local, hips };
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Schritt 4: Tests laufen lassen**
|
||
|
||
Run: `npx vitest run tests/motions.test.js`
|
||
Expected: PASS, 11 Tests.
|
||
|
||
- [ ] **Schritt 5: Committen**
|
||
|
||
```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<void>`
|
||
- `flattenPoses(poses) -> { rotations: Map<name, Float32Array>, hips: Float32Array }`
|
||
- `addClip(document, id, times, poses, hipsBone?) -> Animation`
|
||
- `public/models/motions/weapons-mocap.glb`: 11 Clips, je 22 Drehungskanäle und ein Translationskanal auf `pelvis`
|
||
|
||
- [ ] **Schritt 1: Quelle entpacken**
|
||
|
||
```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 <Ordner mit den FBX aus MotionLibrary_Weapons.zip>");
|
||
process.exit(1);
|
||
}
|
||
const { loadFbx } = await import("./fbx.mjs");
|
||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||
const document = await io.read(fileURLToPath(new URL("../../public/models/ual.glb", import.meta.url)));
|
||
const rig = rigFromScene(document.getRoot().listScenes()[0]);
|
||
await stripToSkeleton(document);
|
||
const pelvisHeight = new Vector3().setFromMatrixPosition(rigWorld(rig).get(BONE_MAP[SOURCE_HIPS]).matrix).y;
|
||
|
||
const files = (await readdir(sourceDir)).filter((name) => name.endsWith(".fbx")).sort();
|
||
for (const file of files) {
|
||
const id = clipId(file);
|
||
const { times, frames, hipsRestHeight } = sampleFbx(await loadFbx(join(sourceDir, file)));
|
||
const cleaned = removeDrift(faceForward(frames));
|
||
const blend = blendDuration(seamAngle(cleaned));
|
||
const retarget = createRetargeter(rig, { hipsScale: pelvisHeight / (hipsRestHeight / 100) });
|
||
addClip(document, id, times, closeLoop(times, cleaned.map(retarget), blend));
|
||
console.log(`${id}: ${times.at(-1).toFixed(2)} s, ${times.length} Bilder, Überblendung ${blend.toFixed(2)} s`);
|
||
}
|
||
|
||
const target = fileURLToPath(new URL("../../public/models/motions/weapons-mocap.glb", import.meta.url));
|
||
await mkdir(dirname(target), { recursive: true });
|
||
await io.write(target, document);
|
||
console.log(`weapons-mocap.glb ${((await stat(target)).size / 1e6).toFixed(2)} MB`);
|
||
}
|
||
|
||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|
||
```
|
||
|
||
- [ ] **Schritt 5: Tests laufen lassen**
|
||
|
||
Run: `npx vitest run tests/motions.test.js`
|
||
Expected: PASS, 27 Tests.
|
||
|
||
- [ ] **Schritt 6: Clips bauen**
|
||
|
||
```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<id, { label, url, grip: { position: [x, y, z], rotation: [x, y, z] } }>` (Griffwerte in Task 9 gemessen)
|
||
- `DUMMIES: Array<{ clip, label, weapon: id | null, hand: "r" | "l" | null }>` (13 Einträge)
|
||
- `MOTIONS_URL`, `ROW_SPACING = 3.5`, `ROW_DEPTH = 8`, `FIRST_SLOT_X = -12.25`, `BOW_X = 8.75`, `LABEL_HEIGHT = 2.2`, `VIEW_DISTANCE = 8`
|
||
- `slotPosition(index) -> { x, z }` in Mannequin-Metern
|
||
- `labelLines(dummy) -> [string, string]`
|
||
|
||
- [ ] **Schritt 1: Failing Tests schreiben**
|
||
|
||
In `tests/showroom.test.js` ans Dateiende anhängen:
|
||
|
||
```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
|
||
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Leonida · Animationen</title>
|
||
<style>
|
||
html, body { margin: 0; height: 100%; overflow: hidden; }
|
||
#container { width: 100%; height: 100%; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="container"></div>
|
||
<script type="module" src="/src/showroom/main.js"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
In `vite.config.js` den Import ergänzen und den `build`-Block einfügen (vor `server`):
|
||
|
||
```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.
|