116 lines
4.7 KiB
TypeScript
116 lines
4.7 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import { WAILING_CAVERNS_ENTITIES } from "./mobPopulation";
|
|
|
|
interface GlbDocument {
|
|
readonly accessors?: readonly {
|
|
readonly bufferView?: number;
|
|
readonly byteOffset?: number;
|
|
readonly componentType?: number;
|
|
readonly count?: number;
|
|
readonly type?: string;
|
|
}[];
|
|
readonly animations?: readonly { readonly name?: string }[];
|
|
readonly bufferViews?: readonly {
|
|
readonly byteOffset?: number;
|
|
readonly byteStride?: number;
|
|
}[];
|
|
readonly materials?: readonly { readonly name?: string }[];
|
|
readonly meshes?: readonly {
|
|
readonly primitives?: readonly {
|
|
readonly attributes?: Readonly<Record<string, number>>;
|
|
}[];
|
|
}[];
|
|
readonly nodes?: readonly { readonly skin?: number }[];
|
|
readonly skins?: readonly unknown[];
|
|
}
|
|
|
|
const projectDirectory = fileURLToPath(new URL("../..", import.meta.url));
|
|
const modelUrls: string[] = Array.from(new Set<string>(
|
|
Object.values(WAILING_CAVERNS_ENTITIES).flatMap((entity): string[] => (
|
|
"model" in entity.visual ? [entity.visual.model.url] : []
|
|
)),
|
|
));
|
|
function readGlb(url: string): { document: GlbDocument; binary: Buffer } {
|
|
const filePath = path.join(projectDirectory, "public", url.replace(/^\//, ""));
|
|
const glb = readFileSync(filePath);
|
|
expect(glb.readUInt32LE(0)).toBe(0x46546c67);
|
|
expect(glb.readUInt32LE(4)).toBe(2);
|
|
expect(glb.readUInt32LE(16)).toBe(0x4e4f534a);
|
|
|
|
const jsonLength = glb.readUInt32LE(12);
|
|
const binaryChunkOffset = 20 + jsonLength;
|
|
expect(glb.readUInt32LE(binaryChunkOffset + 4)).toBe(0x004e4942);
|
|
const binaryLength = glb.readUInt32LE(binaryChunkOffset);
|
|
|
|
return {
|
|
document: JSON.parse(glb.subarray(20, 20 + jsonLength).toString("utf8").trimEnd()) as GlbDocument,
|
|
binary: glb.subarray(binaryChunkOffset + 8, binaryChunkOffset + 8 + binaryLength),
|
|
};
|
|
}
|
|
|
|
function expectUnitNormals(document: GlbDocument, binary: Buffer): void {
|
|
const normalAccessors = new Set<number>();
|
|
for (const mesh of document.meshes ?? []) {
|
|
for (const primitive of mesh.primitives ?? []) {
|
|
const accessorIndex = primitive.attributes?.NORMAL;
|
|
if (accessorIndex !== undefined) normalAccessors.add(accessorIndex);
|
|
}
|
|
}
|
|
|
|
const invalid: Array<{ accessor: number; vertex: number; length: number }> = [];
|
|
for (const accessorIndex of normalAccessors) {
|
|
const accessor = document.accessors?.[accessorIndex];
|
|
const bufferView = accessor?.bufferView === undefined
|
|
? undefined
|
|
: document.bufferViews?.[accessor.bufferView];
|
|
expect(accessor?.componentType).toBe(5126);
|
|
expect(accessor?.type).toBe("VEC3");
|
|
expect(bufferView).toBeDefined();
|
|
|
|
const count = accessor?.count ?? 0;
|
|
const stride = bufferView?.byteStride ?? 12;
|
|
const start = (bufferView?.byteOffset ?? 0) + (accessor?.byteOffset ?? 0);
|
|
for (let vertex = 0; vertex < count; vertex += 1) {
|
|
const offset = start + vertex * stride;
|
|
const length = Math.hypot(
|
|
binary.readFloatLE(offset),
|
|
binary.readFloatLE(offset + 4),
|
|
binary.readFloatLE(offset + 8),
|
|
);
|
|
if (!Number.isFinite(length) || Math.abs(length - 1) > 1e-4) {
|
|
invalid.push({ accessor: accessorIndex, vertex, length });
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(invalid).toEqual([]);
|
|
}
|
|
|
|
describe("browser-facing animated creature assets", () => {
|
|
it("routes every imported creature model to an animated GLB", () => {
|
|
expect(modelUrls).toHaveLength(16);
|
|
expect(modelUrls.every((url) => url.endsWith("-animated.glb"))).toBe(true);
|
|
});
|
|
|
|
it.each(modelUrls)("ships its complete native locomotion and combat library: %s", (url) => {
|
|
const { document, binary } = readGlb(url);
|
|
const clipNames = document.animations?.map((animation) => animation.name) ?? [];
|
|
|
|
expect(document.skins).toHaveLength(1);
|
|
expect(document.nodes?.some((node) => node.skin === 0)).toBe(true);
|
|
expect(clipNames).toContain("Stand (ID 0 variation 0)");
|
|
expect(clipNames).toContain("Walk (ID 4 variation 0)");
|
|
expect(clipNames).toContain("Run (ID 5 variation 0)");
|
|
expect(clipNames.some((name) => /\(ID (?:16|17|18|19|57|58|85|87|88|95|118) variation \d+\)$/i.test(name ?? ""))).toBe(true);
|
|
expect(clipNames.some((name) => /\(ID 1 variation \d+\)$/i.test(name ?? ""))).toBe(true);
|
|
expect(clipNames.some((name) => /\(ID (?:8|9|10) variation \d+\)$/i.test(name ?? ""))).toBe(true);
|
|
expect(clipNames.length).toBeGreaterThanOrEqual(8);
|
|
expect(new Set(clipNames).size).toBe(clipNames.length);
|
|
expect(document.materials?.every((material) => /_B[0124]$/i.test(material.name ?? ""))).toBe(true);
|
|
expectUnitNormals(document, binary);
|
|
});
|
|
});
|