From 149ec9c5d9b8520027b48156bb6ca1c61a30bd3b Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 22:28:59 +0200 Subject: [PATCH] feat(showroom): Aufstellung der 13 Dummies mit Clips und Waffen Co-Authored-By: Claude Opus 5 --- src/showroom/lineup.js | 56 +++++++++++++++++++++++++++++++++++ tests/showroom.test.js | 67 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/showroom/lineup.js diff --git a/src/showroom/lineup.js b/src/showroom/lineup.js new file mode 100644 index 0000000..1afd302 --- /dev/null +++ b/src/showroom/lineup.js @@ -0,0 +1,56 @@ +// 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"]; +} diff --git a/tests/showroom.test.js b/tests/showroom.test.js index ea15845..bc492e0 100644 --- a/tests/showroom.test.js +++ b/tests/showroom.test.js @@ -6,6 +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"; 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)); @@ -121,3 +122,69 @@ describe("weapons-mocap.glb", () => { } }); }); + +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"]); + }); +});