Files
gta6/tests/showroom.test.js
T
knsoandClaude Opus 5 a0cd95cceb 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>
2026-09-18 03:19:42 +02:00

212 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});