150 lines
6.1 KiB
JavaScript
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}`);
|