fix(showroom): Funde der Schlussdurchsicht auf der Testseite behoben

Die Spielfigur wirft jetzt einen Schatten (player.init durchlief applyCharacter
nie); drei stille Fehlerpfade meckern jetzt statt roh abzubrechen (fehlender
Clip fuer die Fingerhaltung, Waffe ohne Griff, mit neuem Test); die
Waffenauswahl im Panel zeigt Beschriftungen statt IDs; der Waffen-Umbau prueft
jetzt die Meshzahl je Datei; stageTransform ist aus main.js nach lineup.js
gezogen und mit dem im Browser gemessenen Fall getestet; slotPosition nimmt
DUMMIES als Argument statt es aus dem Modul zu lesen; dazu Kommentar- und
Entwurfskorrekturen (Docstring, Beckenspur-Test, Frustum-Culling, Port/Kamera/
VIEW_DISTANCE im Entwurf).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-18 03:19:42 +02:00
1 parent c8f1832eae
commit a0cd95cceb
9 files changed
+97 -18

No files matched your search

@@ -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.
+4 -1
View File
@@ -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}`);
+14 -2
View File
@@ -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"];
+21 -7
View File
@@ -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;
}
+3 -1
View File
@@ -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));
}
+20
View File
@@ -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/);
});
});
+23 -2
View File
@@ -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);
});
});
+1 -1
View File
@@ -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 } = {}) {
+8 -3
View File
@@ -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);