Files
healer-man/scripts/add-combat-creature-clips.mjs
2026-08-14 15:56:39 -04:00

150 lines
6.1 KiB
JavaScript

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() ?? "<none>"}".`,
);
}
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}`);