100 lines
6.4 KiB
JavaScript
100 lines
6.4 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
|
|
const particleArchivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json");
|
|
const particleArchive = fs.existsSync(particleArchivePath)
|
|
? JSON.parse(fs.readFileSync(particleArchivePath, "utf8")) : null;
|
|
const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/romPlayerCatalog.generated.json"), "utf8"));
|
|
const entries = new Map(manifest.entries.filter((entry) => entry.source === "runewaker").map((entry) => [entry.spellId, entry]));
|
|
const cohorts = [
|
|
["Priest", "rom-priest", "rom-knight"],
|
|
["Knight", "rom-knight", "rom-scout"],
|
|
["Scout", "rom-scout", "rom-rogue"],
|
|
["Rogue", "rom-rogue", "rom-warrior"],
|
|
["Mage", "rom-mage", "rom-warlock"],
|
|
];
|
|
const phases = ["cast-start", "release", "impact", "tick", "aura-start", "aura-end"];
|
|
|
|
function idsForSkill(skill) {
|
|
return [...new Set([skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)])]
|
|
.filter((id) => Number.isInteger(id) && id > 0);
|
|
}
|
|
|
|
function skillMedia(skill) {
|
|
const ids = idsForSkill(skill);
|
|
const mapped = ids.map((id) => entries.get(id)).filter(Boolean);
|
|
const models = [...new Set(mapped.flatMap((entry) => phases.flatMap((phase) =>
|
|
(entry.phases?.[phase] ?? []).map((asset) => `${phase}: ${path.posix.basename(asset.model)}`))))];
|
|
const sounds = [...new Set(mapped.flatMap((entry) => (entry.sounds ?? [])
|
|
.filter((sound) => sound.url)
|
|
.map((sound) => `${sound.phase ?? "any"}: ${path.posix.basename(sound.sourcePath)}`)))];
|
|
const playableVisuals = mapped.flatMap((entry) => Object.values(entry.phases ?? {}).flat())
|
|
.filter((asset) => asset.effectGraphUrl).length;
|
|
for (const entry of mapped) {
|
|
for (const sound of entry.sounds ?? []) {
|
|
if (!sound.url || !fs.existsSync(path.join(projectRoot, "public", sound.url))) {
|
|
throw new Error(`Missing packaged RuneWaker sound ${entry.key}: ${sound.sourcePath}`);
|
|
}
|
|
}
|
|
}
|
|
return { skill, ids, models, sounds, playableVisuals };
|
|
}
|
|
|
|
function renderTable(rows) {
|
|
const result = [
|
|
"| Skill | Source spell IDs | Effect phase and source model | Sound phase and source cue |",
|
|
"| --- | --- | --- | --- |",
|
|
];
|
|
for (const row of rows) {
|
|
const clean = (value) => value.replaceAll("|", "\\|");
|
|
result.push(`| ${clean(row.skill.name)} | ${row.ids.join(", ")} | ${row.models.map(clean).join("<br>") || "—"} | ${row.sounds.map(clean).join("<br>") || "—"} |`);
|
|
}
|
|
return result.join("\n");
|
|
}
|
|
|
|
const sections = [];
|
|
const summary = [];
|
|
for (const [label, classId, secondaryClassId] of cohorts) {
|
|
for (const [kind, secondary] of [["Base", null], ["Accepted elite pair", secondaryClassId]]) {
|
|
const rows = catalog.skills.filter((skill) => (
|
|
skill.classId === classId && (skill.secondaryClassId ?? null) === secondary && !skill.passive
|
|
)).map(skillMedia);
|
|
const visual = rows.filter((row) => row.models.length).length;
|
|
const audio = rows.filter((row) => row.sounds.length).length;
|
|
const ready = rows.filter((row) => row.playableVisuals > 0).length;
|
|
summary.push({ label, kind, skills: rows.length, sourceVisuals: visual, packagedSounds: audio, playableVisuals: ready });
|
|
sections.push(`## ${label} — ${kind.toLowerCase()}\n\n${renderTable(rows)}`);
|
|
}
|
|
}
|
|
|
|
const lines = [
|
|
"# RuneWaker primary-five spell media mapping",
|
|
"",
|
|
"Generated by `node scripts/combat-effects/audit-rom-media.mjs --write` from the player catalog and combat effect manifest. The five accepted pairings follow `src/game/romFiveIdentities.ts`. Castable skills are listed; passives have no cast media event.",
|
|
"",
|
|
`The effect column records original client ROS/RAS references and their cast phase. ${summary.reduce((total, row) => total + row.playableVisuals, 0)} castable skills have browser-playable original phases; other source visuals use the game's generated runtime effect until their controller families are supported. The sound column lists client cues that are packaged and played on their mapped phase. A dash means the source record or matching sound file did not provide that media.`,
|
|
"",
|
|
"| Primary class | Skill group | Castable skills | With source effect | With packaged sound | With playable original effect |",
|
|
"| --- | --- | ---: | ---: | ---: | ---: |",
|
|
...summary.map((row) => `| ${row.label} | ${row.kind} | ${row.skills} | ${row.sourceVisuals} | ${row.packagedSounds} | ${row.playableVisuals} |`),
|
|
"",
|
|
...sections.flatMap((section) => [section, ""]),
|
|
"## Decoded particle archive",
|
|
"",
|
|
particleArchive
|
|
? `The particle archive covers ${particleArchive.processedModels} referenced ROS models, with ${particleArchive.decodedModels} fully decoded models and ${particleArchive.partialModels} partial models. ${particleArchive.results.filter((result) => result.rendererReady).length} models are renderer-ready, including the Priest holy cast whose remaining EventTrigger is an audio callback. It exports ${particleArchive.results.reduce((sum, result) => sum + result.spriteControllers, 0)} sprite controllers and ${new Set(particleArchive.results.flatMap((result) => result.graphUrl ? JSON.parse(fs.readFileSync(path.join(projectRoot, "public", result.graphUrl.slice(1)), "utf8")).textures.map((texture) => texture.url) : [])).size} distinct PNG textures. See \`public/assets/game/combat-effects/original/runewaker/manifest.json\` for per-model details.`
|
|
: "The RuneWaker particle archive has not been packaged.",
|
|
"",
|
|
"## Next conversion step",
|
|
"",
|
|
"The WebGL sprite renderer plays Priest Urgent Heal, Heal, and Group Heal casting, Knight Charge cast and Whirlwind Shield impact, and Mage Electric Bolt projectile. The Priest holy model's EventTrigger starts a looping 3D WAV and is handled by phase-matched combat audio while its three sprite layers render. Next, verify more spell phases against the source motion and controller semantics before attaching their `effectGraphUrl`. The Direct3D source and packaged ROS data remain the reference for those ports.",
|
|
];
|
|
const markdown = `${lines.join("\n").trimEnd()}\n`;
|
|
if (process.argv.includes("--write")) {
|
|
fs.writeFileSync(path.join(projectRoot, "docs/ROM_SPELL_MEDIA_MAPPING.md"), markdown);
|
|
}
|
|
console.log(JSON.stringify(summary));
|