import { statSync } from "node:fs"; 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"; 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)); 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]); }); }); /** 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. Bei Drehungen zählt dafür nur // die Drehung selbst, nicht ihr Vorzeichen: q und −q stellen dieselbe Rotation dar, und three.js' slerp // liefert bei Gewicht 1 die dem Ausgangswert näherliegende der beiden Varianten (Quaternion.slerp kennt // keinen Sonderfall für t=1). Darum wird das letzte Element vor dem Vergleich gespiegelt, wenn sein // Skalarprodukt mit dem ersten negativ ist — eine echte Winkelabweichung am Schleifenende bleibt davon // unberührt und schlägt weiterhin an. Bei Positionen (kein Vorzeichen-Zwilling) bleibt der Vergleich exakt. 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, []); const dot = first.reduce((sum, value, i) => sum + value * last[i], 0); const sign = channel.getTargetPath() === "rotation" && dot < 0 ? -1 : 1; for (let i = 0; i < first.length; i++) expect(sign * last[i]).toBeCloseTo(first[i], 5); } } }); // 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"); 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); } }); }); 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"]); }); // 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); }); });