Files
2026-08-14 15:56:39 -04:00

159 lines
5.6 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Logger, NodeIO } from "@gltf-transform/core";
import { getBounds } from "@gltf-transform/functions";
import {
CREATURE_MESH_CONSOLIDATION_POLICY,
consolidateCreatureSkinnedMeshes,
} from "./creature-mesh-consolidation.mjs";
import {
CREATURE_RUNTIME_ANIMATION_POLICY,
semanticCreatureAnimations,
trimCreatureRuntimeAnimations,
} from "./runtime-creature-animations.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourceFile = path.join(
projectRoot,
"dungeon-pipeline",
"epoch-five-player-instances.json",
);
const outputRoot = path.join(projectRoot, "public", "assets", "creatures", "epoch");
const catalogFile = path.join(
projectRoot,
"src",
"game",
"generated",
"epochCreatureModels.json",
);
const exists = async (file) => {
try {
await access(file);
return true;
} catch {
return false;
}
};
const readJson = async (file) => JSON.parse(await readFile(file, "utf8"));
const sha256 = async (file) => createHash("sha256").update(await readFile(file)).digest("hex");
const rounded = (value) => Number(value.toFixed(4));
const source = await readJson(sourceFile);
const candidates = new Map();
const dungeonCoverage = [];
for (const dungeon of source.dungeons) {
const resultFile = path.join(
projectRoot,
"dungeon-pipeline",
"work",
dungeon.slug,
"creature-source",
"automation-result.json",
);
if (!await exists(resultFile)) {
dungeonCoverage.push({ slug: dungeon.slug, status: "missing-export", displays: 0 });
continue;
}
const result = await readJson(resultFile);
if (!result.ok || !result.animated) {
dungeonCoverage.push({ slug: dungeon.slug, status: "invalid-export", displays: 0 });
continue;
}
const displayIds = new Set();
for (const creature of result.creatures ?? []) {
const displayId = Number(creature.displayId);
if (!displayId || !creature.outputGLB) continue;
displayIds.add(displayId);
if (!candidates.has(displayId)) candidates.set(displayId, creature);
}
dungeonCoverage.push({ slug: dungeon.slug, status: "green", displays: displayIds.size });
}
await mkdir(outputRoot, { recursive: true });
const models = {};
const io = new NodeIO();
for (const [displayId, creature] of [...candidates].sort((left, right) => left[0] - right[0])) {
const sourceGlb = path.resolve(creature.outputGLB);
if (!await exists(sourceGlb)) throw new Error(`Display ${displayId}: ${sourceGlb} is missing.`);
const document = await io.read(sourceGlb);
document.setLogger(new Logger(Logger.Verbosity.WARN));
const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
if (!scene) throw new Error(`Display ${displayId}: exported GLB has no scene.`);
const bounds = getBounds(scene);
const sourceAnimationNames = document.getRoot().listAnimations()
.map((animation) => animation.getName())
.filter(Boolean);
if (!sourceAnimationNames.length) {
throw new Error(`Display ${displayId}: exported GLB has no animation clips.`);
}
const animationOptimization = await trimCreatureRuntimeAnimations(document);
const meshOptimization = await consolidateCreatureSkinnedMeshes(document);
const animationNames = animationOptimization.retainedNames;
const outputName = `display-${displayId}-animated.glb`;
const output = path.join(outputRoot, outputName);
await io.write(output, document);
const info = await stat(output);
const groundOffset = Math.max(0, -bounds.min[1]);
const height = Math.max(0.5, bounds.max[1] - bounds.min[1]);
const horizontalSpan = Math.max(
bounds.max[0] - bounds.min[0],
bounds.max[2] - bounds.min[2],
);
models[displayId] = {
displayId,
name: creature.name,
url: `/assets/creatures/epoch/${outputName}`,
rotationY: -Math.PI / 2,
groundOffset: rounded(groundOffset),
labelHeight: rounded(groundOffset + bounds.max[1] + Math.max(0.35, height * 0.12)),
markerRadius: rounded(Math.max(0.65, Math.min(4.5, horizontalSpan * 0.4))),
animationClips: animationNames.length,
sourceAnimationClips: sourceAnimationNames.length,
animationOptimization: {
policy: CREATURE_RUNTIME_ANIMATION_POLICY,
removedClips: animationOptimization.removedNames.length,
},
meshOptimization: {
policy: CREATURE_MESH_CONSOLIDATION_POLICY,
sourceDrawCalls: meshOptimization.before.drawCalls,
runtimeDrawCalls: meshOptimization.after.drawCalls,
savedDrawCalls: meshOptimization.before.drawCalls - meshOptimization.after.drawCalls,
},
animations: semanticCreatureAnimations(animationNames),
checksum: await sha256(output),
byteLength: info.size,
source: {
clientBuild: source.source.clientBuild,
entry: creature.entry,
modelPath: creature.modelPath,
rigType: creature.rigType,
composite: creature.rigType === "character-composite",
},
};
}
const catalog = {
schemaVersion: 1,
source: {
client: source.source.client,
clientBuild: source.source.clientBuild,
},
counts: {
dungeons: source.dungeons.length,
exportedDungeons: dungeonCoverage.filter((entry) => entry.status === "green").length,
models: Object.keys(models).length,
},
dungeonCoverage,
models,
};
await writeFile(catalogFile, `${JSON.stringify(catalog, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
status: "green",
output: path.relative(projectRoot, catalogFile).replace(/\\/g, "/"),
...catalog.counts,
}, null, 2));