159 lines
5.6 KiB
JavaScript
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));
|