#!/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));