import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { NodeIO } from "@gltf-transform/core"; import { selectCreatureRuntimeAnimations } from "./dungeon-pipeline/runtime-creature-animations.mjs"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const projectDirectory = path.resolve(scriptDirectory, ".."); const defaultOutputDirectory = path.resolve( projectDirectory, "public/assets/creatures/wailing-caverns", ); async function firstExistingDirectory(candidates) { for (const candidate of candidates) { try { await access(candidate); return candidate; } catch { // Try the next supported workspace layout. } } throw new Error(`None of the expected directories exist:\n${candidates.join("\n")}`); } // Support both this writable staging tree and the installed Documents project. const creatureSourceDirectory = await firstExistingDirectory([ path.resolve(projectDirectory, "../../WailingCaverns/creatures"), path.resolve(projectDirectory, "../wow335a/dungeon-export-work/WailingCaverns/creatures"), ]); const defaultBaseDirectory = await firstExistingDirectory([ defaultOutputDirectory, path.resolve(projectDirectory, "../../../../Healer-Man/public/assets/creatures/wailing-caverns"), ]); const baseDirectory = path.resolve(process.argv[2] ?? defaultBaseDirectory); const outputDirectory = path.resolve(process.argv[3] ?? defaultOutputDirectory); const automationResults = [ path.join(creatureSourceDirectory, "animated-source/automation-result.json"), path.join(creatureSourceDirectory, "composite-animated-source/automation-result.json"), ]; const io = new NodeIO(); function animationDuration(animation) { let duration = 0; for (const sampler of animation.listSamplers()) { const times = sampler.getInput()?.getArray(); if (times?.length) duration = Math.max(duration, times[times.length - 1]); } return duration; } function exportedAnimations(animations) { return selectCreatureRuntimeAnimations(animations).animations; } function uniqueNodeMap(document, label) { const nodes = new Map(); for (const node of document.getRoot().listNodes()) { const name = node.getName(); if (!name) continue; if (nodes.has(name)) throw new Error(`${label} contains duplicate node name "${name}".`); nodes.set(name, node); } return nodes; } function copyAccessor(source, document, buffer, suffix) { const array = source?.getArray(); if (!source || !array) throw new Error(`Animation accessor ${suffix} has no data.`); return document.createAccessor(`${source.getName() || "animation"}-${suffix}`, buffer) .setType(source.getType()) .setNormalized(source.getNormalized()) .setArray(array.slice()); } function copyAnimation(sourceAnimation, targetDocument, targetNodes) { const targetRoot = targetDocument.getRoot(); if (targetRoot.listAnimations().some((animation) => animation.getName() === sourceAnimation.getName())) { return; } const buffer = targetRoot.listBuffers()[0] ?? targetDocument.createBuffer("combat-animation-buffer"); const animation = targetDocument.createAnimation(sourceAnimation.getName()); const samplerMap = new Map(); sourceAnimation.listSamplers().forEach((sourceSampler, index) => { const sampler = targetDocument.createAnimationSampler(`${sourceAnimation.getName()} sampler ${index}`) .setInterpolation(sourceSampler.getInterpolation()) .setInput(copyAccessor(sourceSampler.getInput(), targetDocument, buffer, `${index}-input`)) .setOutput(copyAccessor(sourceSampler.getOutput(), targetDocument, buffer, `${index}-output`)); samplerMap.set(sourceSampler, sampler); animation.addSampler(sampler); }); sourceAnimation.listChannels().forEach((sourceChannel, index) => { const sourceNode = sourceChannel.getTargetNode(); const sourceSampler = sourceChannel.getSampler(); const targetNode = sourceNode ? targetNodes.get(sourceNode.getName()) : null; const targetSampler = sourceSampler ? samplerMap.get(sourceSampler) : null; const targetPath = sourceChannel.getTargetPath(); if (!targetNode || !targetSampler || !targetPath) { throw new Error( `${sourceAnimation.getName()} channel ${index} cannot map target "${sourceNode?.getName() ?? ""}".`, ); } animation.addChannel( targetDocument.createAnimationChannel(`${sourceAnimation.getName()} channel ${index}`) .setTargetNode(targetNode) .setTargetPath(targetPath) .setSampler(targetSampler), ); }); } await mkdir(outputDirectory, { recursive: true }); const report = []; for (const resultPath of automationResults) { const result = JSON.parse(await readFile(resultPath, "utf8")); for (const creature of result.creatures) { const sourceDocument = await io.read(creature.outputGLB); const basePath = path.join(baseDirectory, `${creature.slug}-animated.glb`); const outputPath = path.join(outputDirectory, `${creature.slug}-animated.glb`); const targetDocument = await io.read(basePath); const targetNodes = uniqueNodeMap(targetDocument, creature.slug); const selected = exportedAnimations(sourceDocument.getRoot().listAnimations()); for (const animation of selected) copyAnimation(animation, targetDocument, targetNodes); await io.write(outputPath, targetDocument); report.push({ slug: creature.slug, output: `/assets/creatures/wailing-caverns/${creature.slug}-animated.glb`, added: selected.map((animation) => ({ name: animation.getName(), duration: Number(animationDuration(animation).toFixed(3)), })), finalClips: targetDocument.getRoot().listAnimations().map((animation) => animation.getName()), fallback: null, }); } } const reportPath = path.join(outputDirectory, "combat-animation-report.json"); await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); for (const entry of report) { console.log(`${entry.slug}: ${entry.finalClips.join(", ")}${entry.fallback ? ` (${entry.fallback})` : ""}`); } console.log(`Wrote ${reportPath}`);