diff --git a/docs/superpowers/specs/2026-09-17-animationen-design.md b/docs/superpowers/specs/2026-09-17-animationen-design.md index add7bd2..6077264 100644 --- a/docs/superpowers/specs/2026-09-17-animationen-design.md +++ b/docs/superpowers/specs/2026-09-17-animationen-design.md @@ -107,9 +107,10 @@ Höhen der Wirbelsäule im Vergleich (Ruhepose, m): ### Seite und Szene -- **Einstieg:** `animationen.html` im Wurzelverzeichnis neben `index.html` lädt `src/showroom/main.js`. Lokal ist die Seite unter `http://localhost:5173/animationen.html` erreichbar. +- **Einstieg:** `animationen.html` im Wurzelverzeichnis neben `index.html` lädt `src/showroom/main.js`. Die Seite läuft über ihre eigene `.claude/launch.json`-Konfiguration `animationen` auf Port 5180; das Preview-Werkzeug erlaubt in einer localhost-Adresse keinen Pfad, deshalb wird der Server gestartet und danach im Browser selbst auf `/animationen.html` navigiert. - **`vite.config.js`:** `build.rollupOptions.input` nennt beide Seiten, damit `npm run build` auch die Testseite baut. - **Licht:** Renderer, Tonemapping, Kamera, Nebel, `createSky` und `createSunShadows` wie in `src/main.js`, mit denselben Werten für Sonne, Himmelslicht und Kuppelhelligkeit (dort lokale Konstanten, hier mit Verweis übernommen). +- **Kamera:** Der Nahwert ist 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. - **Boden:** eine Ebene von 200 × 200 Welteinheiten bei y = 0 mit der Textur `concrete_pavement` aus `public/textures` (Diffuse, `nor_gl`, `arm`). Sie ist der einzige Collider der Spielfigur und muss schon bei `player.init` stehen. Deshalb hängt sie nicht an `stage` (siehe Maßstab) und ist für jeden Maßstab groß genug. Die Kachel misst 1,8 Mannequin-Meter (Poly Haven misst `concrete_pavement` mit 1,8 × 1,8 m). `texture.repeat` wird nach `player.init` gesetzt. - **Nicht auf der Seite:** Stadt, Streaming, Fahrzeuge, Minimap, Figurenwahl. @@ -145,6 +146,7 @@ Reine Daten und reine Funktionen, ohne Browser testbar. - **Reihe 1:** bei z = 0, acht Plätze bei x = −12,25 + 3,5 · i. - **Reihe 2:** bei z = −8. Die ersten vier Plätze liegen wie in Reihe 1 (x = −12,25 bis −1,75), der Bogenschütze steht bei x = 8,75. Zum nächsten Dummy hat er damit 8 m Abstand, genug für seine 3,13 m Wanderung. - **Blickrichtung:** Alle Dummies schauen innerhalb von `stage` nach +Z, also zur Spielfigur (siehe Start). Die Clips sind darauf ausgerichtet (Umbau, Schritt 3), die Dummies selbst werden nicht gedreht. +- **Abstand zur Spielfigur:** `VIEW_DISTANCE = 8` ist bewusst nah gewählt, keine Nachlässigkeit: Beim Start stehen vier der acht Dummies aus Reihe 1 im Bild, die übrigen erreicht man mit wenigen Schritten. - **Schild:** ein Canvas-Sprite 2,2 m über dem Platz (nicht am Dummy, damit es beim Bogenschützen stehen bleibt). Es zeigt den Text aus der Spalte „Schild" und darunter die Waffe oder „ohne Waffe". - **„Hand":** Vorgabe ist `r`. Hält ein Darsteller die Waffe sichtbar links, zum Beispiel weil die rechte Hand nachlädt, wird der Eintrag im Browser auf `l` gesetzt und dies im Bericht festgehalten. diff --git a/src/showroom/dummies.js b/src/showroom/dummies.js index 5505ed6..39a24a3 100644 --- a/src/showroom/dummies.js +++ b/src/showroom/dummies.js @@ -37,7 +37,9 @@ export function createDummy({ template, clip, fingers, weapon = null, hand = "r" 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. + // Für ein Skinned Mesh ist die Hülle der Ruhepose ohnehin die falsche Grundlage, sobald Knochen die + // Figur bewegen — besonders sichtbar beim Bogenschützen, der bis 3 m von seinem Platz wandert. Deshalb + // für alle Dummies aus, nicht nur für ihn. object.frustumCulled = false; }); const mixer = new AnimationMixer(root); @@ -46,6 +48,7 @@ export function createDummy({ template, clip, fingers, weapon = null, hand = "r" let held = null; if (weapon) { + if (!grip) throw new Error("Dummy mit Waffe ohne Griff"); const boneName = hand === "l" ? "hand_l" : "hand_r"; const bone = root.getObjectByName(boneName); if (!bone) throw new Error(`Dummy ohne Knochen ${boneName}`); diff --git a/src/showroom/lineup.js b/src/showroom/lineup.js index 67fc630..2b54149 100644 --- a/src/showroom/lineup.js +++ b/src/showroom/lineup.js @@ -47,12 +47,24 @@ export const DUMMIES = [ ]; /** Platz eines Dummys in Mannequin-Metern. Der Bogenschütze steht abseits, er wandert bis 3,13 m. */ -export function slotPosition(index) { +export function slotPosition(index, dummies = DUMMIES) { 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 }; + 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 }; } +/** + * Lage der Aufstellung: VIEW_DISTANCE Mannequin-Meter vor der Figur in Blickrichtung, +Z zurück zu ihr gedreht. + * position ist die Figurenposition (x, z genügen), view die bereits normalisierte waagrechte Blickrichtung. + */ +export function stageTransform(position, view, scale) { + return { + x: position.x + view.x * VIEW_DISTANCE * scale, + z: position.z + view.z * VIEW_DISTANCE * scale, + rotationY: Math.atan2(-view.x, -view.z), + }; +} + /** Text des Schilds: Clip oben, Waffe darunter. */ export function labelLines(dummy) { return [dummy.label, dummy.weapon ? WEAPONS[dummy.weapon].label : "ohne Waffe"]; diff --git a/src/showroom/main.js b/src/showroom/main.js index b69305a..ca29c03 100644 --- a/src/showroom/main.js +++ b/src/showroom/main.js @@ -14,7 +14,7 @@ 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 { DUMMIES, labelLines, LABEL_HEIGHT, MOTIONS_URL, slotPosition, stageTransform, 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 @@ -81,6 +81,14 @@ await player.init({ playerModelConfig: { ...PLAYER_MODEL_BASE, model: ual.scene, animations: ual.animations }, colliders: [{ motion: "static", shape: { kind: "mesh", source: ground } }], }); +// Wie in createCharacterModule (src/characters/index.js): Der Controller setzt beim Laden selbst keinen +// Schatten, und dieses Startmodell durchläuft applyCharacter nie — ohne diese Schleife bliebe die Spielfigur +// als einzige Figur der Seite ohne Schatten. +player.playerModel.traverse((object) => { + if (!object.isMesh) return; + object.castShadow = true; + object.receiveShadow = true; +}); // 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; @@ -91,8 +99,15 @@ 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"); +// Wie beim Clip-Lookup der Dummies unten: findByName gibt bei einem fehlenden Clip still null zurück, das +// bräche erst tief in fingerPoseClip mit einem unklaren TypeError ab. +function requireAnimation(name) { + const clip = AnimationClip.findByName(ual.animations, name); + if (!clip) throw new Error(`ual.glb ohne Clip ${name}`); + return clip; +} +const gripFingers = fingerPoseClip(requireAnimation("Pistol_Idle_Loop"), "Finger_Waffe"); +const looseFingers = fingerPoseClip(requireAnimation("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) => { @@ -130,10 +145,9 @@ function placeStage() { 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); + const { x, z, rotationY } = stageTransform(player.playerCapsule.position, view, figureScale); + stage.position.set(x, 0, z); + stage.rotation.y = rotationY; return true; } diff --git a/src/showroom/panel.js b/src/showroom/panel.js index e03c28b..9257091 100644 --- a/src/showroom/panel.js +++ b/src/showroom/panel.js @@ -15,6 +15,8 @@ export function createShowroomPanel({ playback, weaponObjects }) { const folder = gui.addFolder("Griff"); const ids = Object.keys(WEAPONS); + // lil-gui zeigt bei einem Objekt { Beschriftung: Wert } die Schlüssel statt der IDs im Dropdown an. + const weaponOptions = Object.fromEntries(ids.map((id) => [WEAPONS[id].label, id])); const edit = { weapon: ids[0], x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 }; const sliders = []; const load = () => { @@ -29,7 +31,7 @@ export function createShowroomPanel({ playback, weaponObjects }) { 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); + folder.add(edit, "weapon", weaponOptions).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)); } diff --git a/tests/dummies.test.js b/tests/dummies.test.js index dc4cdac..8d49889 100644 --- a/tests/dummies.test.js +++ b/tests/dummies.test.js @@ -58,9 +58,17 @@ describe("createDummy", () => { right.name = "hand_r"; const left = new Bone(); left.name = "hand_l"; + // Ziele der Fingerspuren aus `fingers` unten (index_01_r, thumb_03_l) — ohne sie meldet three.js beim + // Binden für jeden Testlauf "No target node found for track". + const indexR = new Bone(); + indexR.name = "index_01_r"; + const thumbL = new Bone(); + thumbL.name = "thumb_03_l"; root.add(pelvis); pelvis.add(right); pelvis.add(left); + right.add(indexR); + left.add(thumbL); return root; } const clip = new AnimationClip("take", 1, [ @@ -94,8 +102,20 @@ describe("createDummy", () => { const root = new Group(); const pelvis = new Bone(); pelvis.name = "pelvis"; + const indexR = new Bone(); + indexR.name = "index_01_r"; + const thumbL = new Bone(); + thumbL.name = "thumb_03_l"; root.add(pelvis); + pelvis.add(indexR); + pelvis.add(thumbL); expect(() => createDummy({ template: root, clip, fingers, weapon, hand: "r", grip: WEAPON_GRIP })) .toThrow(/Dummy ohne Knochen hand_r/); }); + + // Bruch: Waffe ohne Griff — applyGrip bekäme kein Objekt und bräche mit einem rohen TypeError ab. + it("meldet eine Waffe ohne Griff", () => { + expect(() => createDummy({ template: template(), clip, fingers, weapon, hand: "r", grip: null })) + .toThrow(/Waffe ohne Griff/); + }); }); diff --git a/tests/showroom.test.js b/tests/showroom.test.js index bc492e0..863646b 100644 --- a/tests/showroom.test.js +++ b/tests/showroom.test.js @@ -6,7 +6,7 @@ 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"; -import { DUMMIES, labelLines, slotPosition, WEAPONS } from "../src/showroom/lineup.js"; +import { DUMMIES, labelLines, slotPosition, stageTransform, WEAPONS } from "../src/showroom/lineup.js"; 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)); @@ -102,7 +102,9 @@ describe("weapons-mocap.glb", () => { } }); - // Bruch: Drift nicht abgezogen — der Dummy wanderte von seinem Platz. + // Bruch: Drift nicht abgezogen — der Dummy wanderte von seinem Platz. Geprüft werden die lokalen + // Komponenten x und y, weil die Beckenspur im Raum von root liegt (−90° um X) und die Höhe dort auf der + // lokalen z-Achse sitzt, nicht auf y. it("beginnt jede Beckenbahn waagrecht bei 0", () => { for (const animation of animations) { const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation"); @@ -187,4 +189,23 @@ describe("Aufstellung", () => { expect(labelLines(DUMMIES[0])).toEqual(["Pistole nachladen · Take 1", "Pistole"]); expect(labelLines(DUMMIES.at(-1))).toEqual(["Bogenschütze · Take 1", "ohne Waffe"]); }); + + // Bruch: falsches Vorzeichen bei Position oder Drehwinkel — die Aufstellung stünde seitlich versetzt, oder + // die Dummies schauten an der Figur vorbei statt sie anzusehen. + it("stellt die Aufstellung bei Blick nach +Z 8 × Maßstab vor die Figur und dreht sie um 180°", () => { + const { x, z, rotationY } = stageTransform({ x: 0, z: 0 }, { x: 0, z: 1 }, 1); + expect(x).toBeCloseTo(0, 10); + expect(z).toBeCloseTo(8, 10); + // Math.atan2(-0, -1) liefert −π statt π (Vorzeichen der Null) — dieselbe Halbdrehung, deshalb Betrag. + expect(Math.abs(rotationY)).toBeCloseTo(Math.PI, 10); + }); + + // Im Browser gemessener Fall (task-8-browsercheck.md): Figur bei (0|0), Maßstab 0,4921, Ergebnis + // (−3,94|0) und 90°. + it("stellt die Aufstellung bei Blick nach −X 8 × Maßstab vor die Figur und dreht sie um 90°", () => { + const { x, z, rotationY } = stageTransform({ x: 0, z: 0 }, { x: -1, z: 0 }, 0.4921); + expect(x).toBeCloseTo(-3.94, 2); + expect(z).toBeCloseTo(0, 10); + expect(rotationY).toBeCloseTo(Math.PI / 2, 10); + }); }); diff --git a/tools/assets/motions.mjs b/tools/assets/motions.mjs index df00507..ea47859 100644 --- a/tools/assets/motions.mjs +++ b/tools/assets/motions.mjs @@ -83,7 +83,7 @@ export function rigWorld(rig) { * 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 + * keine Spur. Die Hüfte: cm -> m, mal hipsScale (Hüfthöhe Ziel ÷ Hüfthöhe Quelle), dann in den Raum ihres * Elternknochens, der dafür unbewegt sein muss. */ export function createRetargeter(rig, { boneMap = BONE_MAP, hipsScale = 1 } = {}) { diff --git a/tools/assets/weapons.mjs b/tools/assets/weapons.mjs index bd34f26..642e4ee 100644 --- a/tools/assets/weapons.mjs +++ b/tools/assets/weapons.mjs @@ -139,9 +139,14 @@ async function encodeWebp(image, quality) { 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 meshes = []; + fbx.traverse((object) => { if (object.isMesh) meshes.push(object); }); + if (meshes.length === 0) throw new Error(`${source.id}: kein Mesh in ${source.mesh}`); + // Der Entwurf misst "genau ein Mesh mit einem Material" je Datei; hier wird das geprüft statt nur vorausgesetzt. + if (meshes.length > 1) { + throw new Error(`${source.id}: ${meshes.length} Meshes in ${source.mesh}, erwartet genau eins`); + } + const mesh = meshes[0]; const { positions, normals, uvs } = meshArrays(mesh.geometry, mesh.quaternion); const length = checkLength(source.id, positions, source.length);