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