Files
gta6/tests/showroom.test.js
T

191 lines
8.9 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, 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.
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"]);
});
});