1 Commits
Author SHA1 Message Date
phenom eca6b5db9b Release Healer Man 0.1.7 2026-08-22 15:53:43 -04:00
99 changed files with 1931 additions and 150 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.6",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.6",
"version": "0.1.7",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+5 -3
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.6",
"version": "0.1.7",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -20,7 +20,8 @@
"coa:sync:live": "node scripts/sync-ascension-coa.mjs --tooltips",
"coa:icons": "node scripts/export-spell-icons.mjs",
"ascension:talents": "node scripts/generate-wow335-talents.mjs ../wow335a/Interface/AddOns/PlayerBotManager/PBM/PBM_TalentData.lua ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc src/game/wow335Talents.generated.ts ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Talent.dbc",
"ascension:sync": "npm run coa:sync && npm run wow335:abilities && npm run ascension:talents && npm run coa:icons",
"catalogs:shard": "vite-node scripts/generate-catalog-shards.ts",
"ascension:sync": "npm run coa:sync && npm run wow335:abilities && npm run ascension:talents && npm run coa:icons && npm run catalogs:shard",
"manastorm:import": "node scripts/manastorm-import/cli.mjs",
"manastorm:forensics": "node scripts/manastorm-import/forensics-cli.mjs",
"manastorm:sync": "npm run manastorm:import && npm run manastorm:forensics && npm run coa:icons",
@@ -91,7 +92,8 @@
"assets:ktx2:creatures": "node scripts/asset-pipeline/compress-creature-ktx2.mjs",
"assets:ktx2:audit": "node scripts/asset-pipeline/audit-ktx2.mjs --visual-only --require-all",
"assets:ktx2:refresh-metadata": "node scripts/asset-pipeline/refresh-ktx2-metadata.mjs",
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit",
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"prebuild": "npm run loot:generate && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
"android:sync": "npm run build:android:web && cap sync android",
@@ -13,8 +13,8 @@
{
"id": "visual",
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
"byteLength": 10266900,
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
"byteLength": 7068884,
"triangleCount": 248302,
"compression": "meshopt+ktx2"
}
@@ -7,8 +7,8 @@
{
"id": "visual",
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
"byteLength": 10266900,
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
"byteLength": 7068884,
"triangleCount": 248302,
"compression": "meshopt+ktx2"
}
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.6",
"versionCode": 1006
"versionName": "0.1.7",
"versionCode": 1007
}
@@ -0,0 +1,121 @@
#!/usr/bin/env node
import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { analyzeGlbFile } from "./glb-performance.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const baselineFile = path.join(
projectRoot,
"scripts/asset-pipeline/environment-performance-baseline.json",
);
const defaultRoots = [
path.join(projectRoot, "public/assets/game/manastorm"),
path.join(projectRoot, "src/assets/game/dungeons"),
];
const defaultThresholds = {
maxEstimatedDrawCalls: 1_500,
maxUnbatchedDrawCallSavings: 250,
};
async function exists(file) {
try {
await access(file);
return true;
} catch {
return false;
}
}
async function collectVisualGlbs(root, files = []) {
if (!await exists(root)) return files;
const entry = await stat(root);
if (entry.isFile()) {
if (/(?:^|[-_])visual\.glb$/i.test(path.basename(root))) files.push(root);
return files;
}
for (const child of await readdir(root, { withFileTypes: true })) {
const target = path.join(root, child.name);
if (child.isDirectory()) await collectVisualGlbs(target, files);
else if (/(?:^|[-_])visual\.glb$/i.test(child.name)) files.push(target);
}
return files;
}
function projectPath(file) {
return path.relative(projectRoot, file).split(path.sep).join("/");
}
function exceeds(report, limits) {
return report.estimatedDrawCalls > limits.maxEstimatedDrawCalls
|| report.unbatchedDrawCallSavings > limits.maxUnbatchedDrawCallSavings;
}
const args = process.argv.slice(2);
const writeBaseline = args.includes("--write-baseline");
const roots = args.filter((argument) => argument !== "--write-baseline")
.map((argument) => path.resolve(projectRoot, argument));
const files = [...new Set((await Promise.all(
(roots.length ? roots : defaultRoots).map((root) => collectVisualGlbs(root)),
)).flat())].sort();
const reports = await Promise.all(files.map(async (file) => ({
file: projectPath(file),
byteLength: (await stat(file)).size,
...(await analyzeGlbFile(file)),
})));
if (writeBaseline) {
const baseline = {
schemaVersion: 1,
thresholds: defaultThresholds,
exceptions: Object.fromEntries(
reports
.filter((report) => exceeds(report, defaultThresholds))
.map((report) => [report.file, {
maxEstimatedDrawCalls: Math.max(defaultThresholds.maxEstimatedDrawCalls, report.estimatedDrawCalls),
maxUnbatchedDrawCallSavings: Math.max(
defaultThresholds.maxUnbatchedDrawCallSavings,
report.unbatchedDrawCallSavings,
),
}]),
),
};
await mkdir(path.dirname(baselineFile), { recursive: true });
await writeFile(baselineFile, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
console.log(`Wrote ${Object.keys(baseline.exceptions).length} performance exceptions to ${projectPath(baselineFile)}.`);
process.exit(0);
}
if (!await exists(baselineFile)) {
throw new Error(`Missing ${projectPath(baselineFile)}. Run this script with --write-baseline.`);
}
const baseline = JSON.parse(await readFile(baselineFile, "utf8"));
const thresholds = { ...defaultThresholds, ...baseline.thresholds };
const regressions = reports.filter((report) => {
const limits = { ...thresholds, ...baseline.exceptions?.[report.file] };
return exceeds(report, limits);
});
const debt = reports.filter((report) => exceeds(report, thresholds));
const top = [...reports]
.sort((left, right) => right.estimatedDrawCalls - left.estimatedDrawCalls)
.slice(0, 12)
.map((report) => ({
asset: report.file,
calls: report.estimatedDrawCalls,
avoidable: report.unbatchedDrawCallSavings,
gpuBatches: report.gpuInstanceBatches,
nodes: report.nodes,
}));
console.table(top);
console.log(JSON.stringify({
status: regressions.length ? "blocked" : "green",
assets: reports.length,
knownBudgetExceptions: debt.length,
regressions: regressions.map((report) => ({
file: report.file,
estimatedDrawCalls: report.estimatedDrawCalls,
unbatchedDrawCallSavings: report.unbatchedDrawCallSavings,
})),
}, null, 2));
if (regressions.length) process.exitCode = 1;
@@ -0,0 +1,249 @@
{
"schemaVersion": 1,
"thresholds": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 250
},
"exceptions": {
"public/assets/game/manastorm/109-sunkentemple/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 320
},
"public/assets/game/manastorm/129-razorfendowns/visual.glb": {
"maxEstimatedDrawCalls": 1507,
"maxUnbatchedDrawCallSavings": 1406
},
"public/assets/game/manastorm/1771-custom-marsh/visual.glb": {
"maxEstimatedDrawCalls": 2493,
"maxUnbatchedDrawCallSavings": 2114
},
"public/assets/game/manastorm/1774-custom-tropical/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 910
},
"public/assets/game/manastorm/1775-custom-tuskarr/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 500
},
"public/assets/game/manastorm/1776-custom-vrykul1/visual.glb": {
"maxEstimatedDrawCalls": 2223,
"maxUnbatchedDrawCallSavings": 1818
},
"public/assets/game/manastorm/1781-forgottenmine/visual.glb": {
"maxEstimatedDrawCalls": 5756,
"maxUnbatchedDrawCallSavings": 3818
},
"public/assets/game/manastorm/1785-valsharaharena/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 392
},
"public/assets/game/manastorm/1787-baradinholdarena/visual.glb": {
"maxEstimatedDrawCalls": 10462,
"maxUnbatchedDrawCallSavings": 9362
},
"public/assets/game/manastorm/1794-twistingnether/visual.glb": {
"maxEstimatedDrawCalls": 3077,
"maxUnbatchedDrawCallSavings": 2557
},
"public/assets/game/manastorm/209-tanarisinstance/visual.glb": {
"maxEstimatedDrawCalls": 4579,
"maxUnbatchedDrawCallSavings": 3547
},
"public/assets/game/manastorm/229-blackrockspire/visual.glb": {
"maxEstimatedDrawCalls": 3972,
"maxUnbatchedDrawCallSavings": 3598
},
"public/assets/game/manastorm/269-cavernsoftime/visual.glb": {
"maxEstimatedDrawCalls": 5446,
"maxUnbatchedDrawCallSavings": 5289
},
"public/assets/game/manastorm/289-schoolofnecromancy/visual.glb": {
"maxEstimatedDrawCalls": 5140,
"maxUnbatchedDrawCallSavings": 4790
},
"public/assets/game/manastorm/309-zul-gurub/visual.glb": {
"maxEstimatedDrawCalls": 17377,
"maxUnbatchedDrawCallSavings": 15605
},
"public/assets/game/manastorm/329-stratholme/visual.glb": {
"maxEstimatedDrawCalls": 2954,
"maxUnbatchedDrawCallSavings": 2725
},
"public/assets/game/manastorm/34-stormwindjail/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 487
},
"public/assets/game/manastorm/349-mauradon/visual.glb": {
"maxEstimatedDrawCalls": 5624,
"maxUnbatchedDrawCallSavings": 5352
},
"public/assets/game/manastorm/36-deadminesinstance/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 309
},
"public/assets/game/manastorm/389-orgrimmarinstance/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 881
},
"public/assets/game/manastorm/429-diremaul/visual.glb": {
"maxEstimatedDrawCalls": 5021,
"maxUnbatchedDrawCallSavings": 4636
},
"public/assets/game/manastorm/469-blackwinglair/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 452
},
"public/assets/game/manastorm/47-razorfenkraulinstance/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 897
},
"public/assets/game/manastorm/48-blackfathom/visual.glb": {
"maxEstimatedDrawCalls": 2164,
"maxUnbatchedDrawCallSavings": 1950
},
"public/assets/game/manastorm/509-ahnqiraj/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 600
},
"public/assets/game/manastorm/531-ahnqirajtemple/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 953
},
"public/assets/game/manastorm/532-karazahn/visual.glb": {
"maxEstimatedDrawCalls": 8540,
"maxUnbatchedDrawCallSavings": 7892
},
"public/assets/game/manastorm/533-stratholme-raid/visual.glb": {
"maxEstimatedDrawCalls": 4165,
"maxUnbatchedDrawCallSavings": 4014
},
"public/assets/game/manastorm/540-hellfiremilitary/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 1150
},
"public/assets/game/manastorm/542-hellfiredemon/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 444
},
"public/assets/game/manastorm/543-hellfirerampart/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 296
},
"public/assets/game/manastorm/545-coilfangpumping/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 674
},
"public/assets/game/manastorm/546-coilfangmarsh/visual.glb": {
"maxEstimatedDrawCalls": 3104,
"maxUnbatchedDrawCallSavings": 3033
},
"public/assets/game/manastorm/547-coilfangdraenei/visual.glb": {
"maxEstimatedDrawCalls": 1535,
"maxUnbatchedDrawCallSavings": 1455
},
"public/assets/game/manastorm/548-coilfangraid/visual.glb": {
"maxEstimatedDrawCalls": 1916,
"maxUnbatchedDrawCallSavings": 1683
},
"public/assets/game/manastorm/550-tempestkeepraid/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 420
},
"public/assets/game/manastorm/552-tempestkeeparcane/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 999
},
"public/assets/game/manastorm/553-tempestkeepatrium/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 1115
},
"public/assets/game/manastorm/554-tempestkeepfactory/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 872
},
"public/assets/game/manastorm/555-auchindounshadow/visual.glb": {
"maxEstimatedDrawCalls": 2624,
"maxUnbatchedDrawCallSavings": 2516
},
"public/assets/game/manastorm/556-auchindoundemon/visual.glb": {
"maxEstimatedDrawCalls": 2095,
"maxUnbatchedDrawCallSavings": 1987
},
"public/assets/game/manastorm/557-auchindounethereal/visual.glb": {
"maxEstimatedDrawCalls": 3164,
"maxUnbatchedDrawCallSavings": 3026
},
"public/assets/game/manastorm/558-auchindoundraenei/visual.glb": {
"maxEstimatedDrawCalls": 2613,
"maxUnbatchedDrawCallSavings": 2523
},
"public/assets/game/manastorm/560-hillsbradpast/visual.glb": {
"maxEstimatedDrawCalls": 28259,
"maxUnbatchedDrawCallSavings": 24733
},
"public/assets/game/manastorm/568-zulaman/visual.glb": {
"maxEstimatedDrawCalls": 4307,
"maxUnbatchedDrawCallSavings": 3850
},
"public/assets/game/manastorm/574-valgarde70/visual.glb": {
"maxEstimatedDrawCalls": 8271,
"maxUnbatchedDrawCallSavings": 7072
},
"public/assets/game/manastorm/575-utgardepinnacle/visual.glb": {
"maxEstimatedDrawCalls": 16277,
"maxUnbatchedDrawCallSavings": 14018
},
"public/assets/game/manastorm/576-nexus70/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 396
},
"public/assets/game/manastorm/578-nexus80/visual.glb": {
"maxEstimatedDrawCalls": 2283,
"maxUnbatchedDrawCallSavings": 1896
},
"public/assets/game/manastorm/585-sunwell5manfix/visual.glb": {
"maxEstimatedDrawCalls": 1810,
"maxUnbatchedDrawCallSavings": 1618
},
"public/assets/game/manastorm/595-stratholmecot/visual.glb": {
"maxEstimatedDrawCalls": 4353,
"maxUnbatchedDrawCallSavings": 3386
},
"public/assets/game/manastorm/600-draktheronkeep/visual.glb": {
"maxEstimatedDrawCalls": 3518,
"maxUnbatchedDrawCallSavings": 2876
},
"public/assets/game/manastorm/602-ulduar80/visual.glb": {
"maxEstimatedDrawCalls": 1973,
"maxUnbatchedDrawCallSavings": 1295
},
"public/assets/game/manastorm/619-azjol-lowercity/visual.glb": {
"maxEstimatedDrawCalls": 1596,
"maxUnbatchedDrawCallSavings": 1243
},
"public/assets/game/manastorm/658-quarryoftears/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 618
},
"public/assets/game/manastorm/668-hallsofreflection/visual.glb": {
"maxEstimatedDrawCalls": 5464,
"maxUnbatchedDrawCallSavings": 4606
},
"public/assets/game/manastorm/70-uldaman/visual.glb": {
"maxEstimatedDrawCalls": 3268,
"maxUnbatchedDrawCallSavings": 3115
},
"public/assets/game/manastorm/821-tinketechshowdown/visual.glb": {
"maxEstimatedDrawCalls": 2585,
"maxUnbatchedDrawCallSavings": 2379
},
"public/assets/game/manastorm/90-gnomeragoninstance/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 1051
},
"public/assets/game/manastorm/908-alvaencounter/visual.glb": {
"maxEstimatedDrawCalls": 1500,
"maxUnbatchedDrawCallSavings": 1096
}
}
}
@@ -0,0 +1,98 @@
import { open } from "node:fs/promises";
const GLB_MAGIC = 0x46546c67;
const JSON_CHUNK_TYPE = 0x4e4f534a;
const GPU_INSTANCING_EXTENSION = "EXT_mesh_gpu_instancing";
function primitiveCount(json, meshIndex) {
return json.meshes?.[meshIndex]?.primitives?.length ?? 0;
}
function gpuInstanceCount(json, node) {
const attributes = node.extensions?.[GPU_INSTANCING_EXTENSION]?.attributes;
if (!attributes || typeof attributes !== "object") return 0;
return Math.max(
0,
...Object.values(attributes).map((accessorIndex) => (
json.accessors?.[accessorIndex]?.count ?? 0
)),
);
}
export function analyzeGlbJson(json) {
const repeatedMeshes = new Map();
let estimatedDrawCalls = 0;
let expandedPrimitiveInstances = 0;
let meshNodes = 0;
let gpuInstanceBatches = 0;
let gpuInstances = 0;
for (const node of json.nodes ?? []) {
if (!Number.isInteger(node.mesh)) continue;
meshNodes += 1;
const primitives = primitiveCount(json, node.mesh);
const instances = gpuInstanceCount(json, node);
estimatedDrawCalls += primitives;
expandedPrimitiveInstances += primitives * Math.max(1, instances);
if (instances > 0) {
gpuInstanceBatches += 1;
gpuInstances += instances;
continue;
}
repeatedMeshes.set(node.mesh, (repeatedMeshes.get(node.mesh) ?? 0) + 1);
}
let eligibleRepeatedMeshes = 0;
let eligibleRepeatedNodes = 0;
let unbatchedDrawCallSavings = 0;
for (const [meshIndex, count] of repeatedMeshes) {
if (count < 3) continue;
eligibleRepeatedMeshes += 1;
eligibleRepeatedNodes += count;
unbatchedDrawCallSavings += (count - 1) * primitiveCount(json, meshIndex);
}
const materials = json.materials ?? [];
return {
nodes: json.nodes?.length ?? 0,
meshNodes,
meshes: json.meshes?.length ?? 0,
materials: materials.length,
textures: json.textures?.length ?? 0,
doubleSidedMaterials: materials.filter((material) => material.doubleSided === true).length,
blendedMaterials: materials.filter((material) => material.alphaMode === "BLEND").length,
estimatedDrawCalls,
expandedPrimitiveInstances,
gpuInstanceBatches,
gpuInstances,
eligibleRepeatedMeshes,
eligibleRepeatedNodes,
unbatchedDrawCallSavings,
usesGpuInstancing: (json.extensionsUsed ?? []).includes(GPU_INSTANCING_EXTENSION),
};
}
export async function readGlbJson(file) {
const handle = await open(file, "r");
try {
const header = Buffer.alloc(20);
const { bytesRead } = await handle.read(header, 0, header.length, 0);
if (bytesRead !== header.length
|| header.readUInt32LE(0) !== GLB_MAGIC
|| header.readUInt32LE(4) !== 2
|| header.readUInt32LE(16) !== JSON_CHUNK_TYPE) {
throw new Error(`${file} is not a supported GLB 2.0 file.`);
}
const jsonLength = header.readUInt32LE(12);
const jsonBuffer = Buffer.alloc(jsonLength);
const jsonRead = await handle.read(jsonBuffer, 0, jsonLength, 20);
if (jsonRead.bytesRead !== jsonLength) throw new Error(`${file} has a truncated JSON chunk.`);
return JSON.parse(jsonBuffer.toString("utf8"));
} finally {
await handle.close();
}
}
export async function analyzeGlbFile(file) {
return analyzeGlbJson(await readGlbJson(file));
}
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { analyzeGlbJson } from "./glb-performance.mjs";
test("counts repeated mesh placements as avoidable draw calls", () => {
const report = analyzeGlbJson({
meshes: [
{ primitives: [{}, {}] },
{ primitives: [{}] },
],
nodes: [
{ mesh: 0 },
{ mesh: 0 },
{ mesh: 0 },
{ mesh: 1 },
],
materials: [
{ doubleSided: true, alphaMode: "BLEND" },
{},
],
textures: [{}, {}],
});
assert.equal(report.estimatedDrawCalls, 7);
assert.equal(report.expandedPrimitiveInstances, 7);
assert.equal(report.eligibleRepeatedMeshes, 1);
assert.equal(report.eligibleRepeatedNodes, 3);
assert.equal(report.unbatchedDrawCallSavings, 4);
assert.equal(report.doubleSidedMaterials, 1);
assert.equal(report.blendedMaterials, 1);
});
test("counts one draw-call set for an EXT_mesh_gpu_instancing batch", () => {
const report = analyzeGlbJson({
extensionsUsed: ["EXT_mesh_gpu_instancing"],
accessors: [{ count: 12 }],
meshes: [{ primitives: [{}, {}, {}] }],
nodes: [{
mesh: 0,
extensions: {
EXT_mesh_gpu_instancing: {
attributes: { TRANSLATION: 0 },
},
},
}],
});
assert.equal(report.estimatedDrawCalls, 3);
assert.equal(report.expandedPrimitiveInstances, 36);
assert.equal(report.gpuInstanceBatches, 1);
assert.equal(report.gpuInstances, 12);
assert.equal(report.unbatchedDrawCallSavings, 0);
assert.equal(report.usesGpuInstancing, true);
});
+106
View File
@@ -0,0 +1,106 @@
import {
mkdirSync,
readFileSync,
readdirSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { basename, resolve } from "node:path";
import {
CLASSES,
COA_CLASS_IDS,
type ClassId,
type CoaClassId,
} from "../src/app/characterCatalog";
import { TALENT_NODES, TALENT_TREES } from "../src/game/talentCatalog";
const projectRoot = resolve(import.meta.dirname, "..");
const talentOutputDirectory = resolve(projectRoot, "src/game/generated/talentUi");
const coaAbilityOutputDirectory = resolve(projectRoot, "src/game/generated/coaAbilities");
function writeJson(file: string, value: unknown): void {
writeFileSync(file, `${JSON.stringify(value)}\n`, "utf8");
console.log(`Wrote ${file}`);
}
function removeStaleJson(directory: string, expectedNames: ReadonlySet<string>): void {
for (const name of readdirSync(directory)) {
if (!name.endsWith(".json") || expectedNames.has(name)) continue;
unlinkSync(resolve(directory, name));
}
}
mkdirSync(talentOutputDirectory, { recursive: true });
const talentClassIds = new Set<ClassId>(TALENT_TREES.map((tree) => tree.classId));
const talentFileNames = new Set([...talentClassIds].map((classId) => `${classId}.json`));
removeStaleJson(talentOutputDirectory, talentFileNames);
for (const classId of talentClassIds) {
const trees = TALENT_TREES.filter((tree) => tree.classId === classId).map((tree) => ({
id: tree.id,
classId: tree.classId,
name: tree.name,
icon: tree.icon,
background: tree.background,
description: tree.description,
system: tree.system,
}));
const treeIds = new Set(trees.map((tree) => tree.id));
const nodes = TALENT_NODES.filter((node) => treeIds.has(node.treeId)).map((node) => ({
id: node.id,
treeId: node.treeId,
index: node.index,
prerequisiteId: node.prerequisiteId,
prerequisiteRank: node.prerequisiteRank,
prerequisites: node.prerequisites,
column: node.column,
row: node.row,
icon: node.icon,
maxRank: node.maxRank,
name: node.name,
description: node.description,
rankDescriptions: node.rankDescriptions,
system: node.system,
entryType: node.entryType,
abilityEssenceCost: node.abilityEssenceCost,
talentEssenceCost: node.talentEssenceCost,
requiredClassPoints: node.requiredClassPoints,
requiredSpecPoints: node.requiredSpecPoints,
requiredLevel: node.requiredLevel,
isPassive: node.isPassive,
}));
writeJson(resolve(talentOutputDirectory, `${classId}.json`), {
schemaVersion: 1,
classId,
trees,
nodes,
});
}
interface CoaAbilitySnapshot {
readonly schemaVersion: 1;
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly unknown[]>>;
readonly resourcesByClass: Readonly<Record<CoaClassId, unknown>>;
readonly triggeredAbilitiesBySpellId: Readonly<Record<string, { readonly classId?: ClassId }>>;
}
const coaAbilityInput = resolve(projectRoot, "src/game/generated/coaAbilityRuntimeCatalog.json");
const coaSnapshot = JSON.parse(readFileSync(coaAbilityInput, "utf8")) as CoaAbilitySnapshot;
mkdirSync(coaAbilityOutputDirectory, { recursive: true });
const coaAbilityFileNames = new Set(COA_CLASS_IDS.map((classId) => `${classId}.json`));
removeStaleJson(coaAbilityOutputDirectory, coaAbilityFileNames);
for (const classId of COA_CLASS_IDS) {
const triggeredAbilitiesBySpellId = Object.fromEntries(Object.entries(
coaSnapshot.triggeredAbilitiesBySpellId,
).filter(([, ability]) => ability.classId === classId));
writeJson(resolve(coaAbilityOutputDirectory, `${classId}.json`), {
schemaVersion: 1,
classId,
abilities: coaSnapshot.abilitiesByClass[classId] ?? [],
resource: coaSnapshot.resourcesByClass[classId],
triggeredAbilitiesBySpellId,
});
}
console.log(`Generated ${talentFileNames.size} talent UI shards and ${coaAbilityFileNames.size} Conquest ability shards from ${basename(coaAbilityInput)}.`);
+25 -8
View File
@@ -404,15 +404,23 @@ async function optimizeStage(slug) {
const source = path.join(staging, entry.path);
const outputName = optimizedChunkName(role, index, entries.length);
const output = path.join(staging, outputName);
await run(process.execPath, [
cli,
"meshopt",
source,
output,
"--level",
"high",
]);
if (role === "visual") {
await run(process.execPath, [
cli,
"optimize",
source,
output,
"--compress",
"meshopt",
"--meshopt-level",
"high",
"--instance",
"true",
"--instance-min",
"3",
"--texture-size",
"2048",
]);
await run(process.execPath, [
path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs"),
output,
@@ -421,6 +429,15 @@ async function optimizeStage(slug) {
"--jobs",
"2",
]);
} else {
await run(process.execPath, [
cli,
"meshopt",
source,
output,
"--level",
"high",
]);
}
const geometry = await inspectGlb(output);
const descriptor = {
+38 -2
View File
@@ -1,5 +1,7 @@
import { lazy, Suspense, useEffect } from "react";
import { lazy, Suspense, useEffect, useMemo, useState, type ReactNode } from "react";
import { BASE_CLASS_IDS, classById } from "./app/characterCatalog";
import { useShellStore } from "./app/shellStore";
import { preloadAbilityCatalogsForClasses } from "./game/abilityCatalog";
import { useManastormAdminStore } from "./game/manastormAdminStore";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { LoginScreen } from "./ui/LoginScreen";
@@ -18,6 +20,39 @@ function ShellTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
}
function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
const character = useShellStore((state) => (
state.characters.find((candidate) => candidate.id === state.selectedCharacterId) ?? null
));
const classIds = useMemo(() => {
if (!character) return [];
const definition = classById(character.classId);
if (definition.mode === "conquest") return [...BASE_CLASS_IDS, character.classId];
return [character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])];
}, [character]);
const requestKey = classIds.join(":");
const [loadedKey, setLoadedKey] = useState("");
const [failedKey, setFailedKey] = useState("");
useEffect(() => {
if (!requestKey) return;
let active = true;
setFailedKey("");
void preloadAbilityCatalogsForClasses(classIds).then(() => {
if (active) setLoadedKey(requestKey);
}).catch(() => {
if (active) setFailedKey(requestKey);
});
return () => { active = false; };
}, [classIds, requestKey]);
if (!requestKey || loadedKey === requestKey) return children;
if (failedKey === requestKey) {
return <div className="world-transition"><span>!</span><strong>Catalog unavailable</strong><small>Reload the app to retry loading combat data.</small></div>;
}
return <ShellTransition />;
}
export default function App() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
@@ -39,5 +74,6 @@ export default function App() {
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
return <><OnlineGroupCoordinator />{screen}</>;
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
return <><OnlineGroupCoordinator />{requiresRuntimeCatalog ? <RuntimeCatalogGate>{screen}</RuntimeCatalogGate> : screen}</>;
}
+141 -41
View File
@@ -1,13 +1,18 @@
import type { ClassId } from "../app/characterCatalog";
import {
COA_RUNTIME_ABILITIES_BY_CLASS as COA_ABILITIES_BY_CLASS,
COA_RUNTIME_CLASS_RESOURCES as COA_CLASS_RESOURCES,
} from "./coaAbilityRuntimeCatalog";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
BASE_CLASS_IDS,
COA_CLASS_IDS,
ROM_CLASS_IDS,
type BaseClassId,
type ClassId,
type CoaClassId,
type RomClassId,
} from "../app/characterCatalog";
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
export type ResourceType =
| "mana"
| "rage"
@@ -301,7 +306,7 @@ function defineAbility(definition: AbilityDefinitionInput): AbilityDefinition {
};
}
export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> = {
const classResources: Partial<Record<ClassId, ClassResourceProfile>> = {
// The prototype has no auto-attack swing timer yet, so a small passive Rage
// gain stands in for white-hit generation and keeps the full kit usable.
warrior: { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 5 },
@@ -314,7 +319,6 @@ export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> =
mage: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
warlock: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
druid: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
...COA_CLASS_RESOURCES,
"rom-warrior": { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 0 },
"rom-scout": { type: "focus", name: "Focus", color: "#55b995", maximum: 100, initial: 100, regenerationPerSecond: 6 },
"rom-rogue": { type: "energy", name: "Energy", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
@@ -327,6 +331,9 @@ export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> =
"rom-champion": { type: "rage", name: "Rage", color: "#bb604c", maximum: 100, initial: 0, regenerationPerSecond: 0 },
};
/** Populated per mode/class by preloadAbilityCatalogsForClasses before gameplay mounts. */
export const CLASS_RESOURCES = classResources as Readonly<Record<ClassId, ClassResourceProfile>>;
const WARRIOR = [
defineAbility({
id: "warrior-heroic-strike", classId: "warrior", dbcSpellId: 78, name: "Heroic Strike",
@@ -789,48 +796,131 @@ function mergeWow335ClassicAbilities(
];
}
export const ABILITIES_BY_CLASS: Readonly<Record<ClassId, readonly AbilityDefinition[]>> = {
warrior: mergeWow335ClassicAbilities(WARRIOR, WOW335_ABILITIES_BY_CLASS.warrior),
paladin: mergeWow335ClassicAbilities(PALADIN, WOW335_ABILITIES_BY_CLASS.paladin),
hunter: mergeWow335ClassicAbilities(HUNTER, WOW335_ABILITIES_BY_CLASS.hunter),
rogue: mergeWow335ClassicAbilities(ROGUE, WOW335_ABILITIES_BY_CLASS.rogue),
priest: mergeWow335ClassicAbilities(PRIEST, WOW335_ABILITIES_BY_CLASS.priest),
"death-knight": mergeWow335ClassicAbilities(DEATH_KNIGHT, WOW335_ABILITIES_BY_CLASS["death-knight"]),
shaman: mergeWow335ClassicAbilities(SHAMAN, WOW335_ABILITIES_BY_CLASS.shaman),
mage: mergeWow335ClassicAbilities(MAGE, WOW335_ABILITIES_BY_CLASS.mage),
warlock: mergeWow335ClassicAbilities(WARLOCK, WOW335_ABILITIES_BY_CLASS.warlock),
druid: mergeWow335ClassicAbilities(DRUID, WOW335_ABILITIES_BY_CLASS.druid),
...COA_ABILITIES_BY_CLASS,
"rom-warrior": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warrior"],
"rom-scout": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-scout"],
"rom-rogue": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-rogue"],
"rom-mage": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-mage"],
"rom-priest": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-priest"],
"rom-knight": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-knight"],
"rom-warden": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warden"],
"rom-druid": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-druid"],
"rom-warlock": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warlock"],
"rom-champion": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-champion"],
const CLASSIC_CORE_ABILITIES: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> = {
warrior: WARRIOR,
paladin: PALADIN,
hunter: HUNTER,
rogue: ROGUE,
priest: PRIEST,
"death-knight": DEATH_KNIGHT,
shaman: SHAMAN,
mage: MAGE,
warlock: WARLOCK,
druid: DRUID,
};
const abilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
...CLASSIC_CORE_ABILITIES,
};
const abilityCatalog: Record<string, AbilityDefinition> = {};
let romEliteAbilities: readonly AbilityDefinition[] = [];
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
const coaTriggeredAbilities = new Map<number, AbilityDefinition>();
let classicLoad: Promise<void> | null = null;
let romLoad: Promise<void> | null = null;
const coaLoads = new Map<CoaClassId, Promise<void>>();
export const ABILITY_CATALOG: Readonly<Record<string, AbilityDefinition>> = Object.freeze(
Object.fromEntries([...Object.values(ABILITIES_BY_CLASS).flat(), ...ROM_ALL_ABILITIES].map((ability) => [ability.id, ability])),
);
/** Live read-only views retained for catalog consumers and validation tests. */
export const ABILITIES_BY_CLASS = abilitiesByClass as Readonly<Record<ClassId, readonly AbilityDefinition[]>>;
export const ABILITY_CATALOG = abilityCatalog as Readonly<Record<string, AbilityDefinition>>;
function registerClassAbilities(classId: ClassId, abilities: readonly AbilityDefinition[]): void {
for (const previous of abilitiesByClass[classId] ?? []) delete abilityCatalog[previous.id];
abilitiesByClass[classId] = abilities;
for (const ability of abilities) abilityCatalog[ability.id] = ability;
}
for (const classId of BASE_CLASS_IDS) {
registerClassAbilities(classId, CLASSIC_CORE_ABILITIES[classId]);
}
async function preloadClassicAbilityCatalog(): Promise<void> {
if (classicLoad) return classicLoad;
classicLoad = import("./wow335AbilityCatalog").then(({ WOW335_ABILITIES_BY_CLASS }) => {
for (const classId of BASE_CLASS_IDS) {
registerClassAbilities(
classId,
mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]),
);
}
}).catch((error: unknown) => {
classicLoad = null;
throw error;
});
return classicLoad;
}
async function preloadRomAbilityCatalog(): Promise<void> {
if (romLoad) return romLoad;
romLoad = import("./romAbilityCatalog").then((catalog) => {
for (const classId of ROM_CLASS_IDS) {
registerClassAbilities(classId, catalog.ROM_ORDINARY_ABILITIES_BY_CLASS[classId]);
}
romEliteAbilities = catalog.ROM_ELITE_ABILITIES;
romTriggeredAbilityResolver = catalog.romTriggeredAbilityBySpellId;
for (const ability of catalog.ROM_ALL_ABILITIES) abilityCatalog[ability.id] = ability;
}).catch((error: unknown) => {
romLoad = null;
throw error;
});
return romLoad;
}
async function preloadCoaAbilityCatalog(classId: CoaClassId): Promise<void> {
const cached = coaLoads.get(classId);
if (cached) return cached;
const pending = loadCoaAbilityCatalogShard(classId).then((shard) => {
registerClassAbilities(classId, shard.abilities);
classResources[classId] = shard.resource;
for (const [spellId, ability] of Object.entries(shard.triggeredAbilitiesBySpellId)) {
coaTriggeredAbilities.set(Number(spellId), ability);
}
}).catch((error: unknown) => {
coaLoads.delete(classId);
throw error;
});
coaLoads.set(classId, pending);
return pending;
}
/**
* Loads only the executable catalogs needed by the selected character and its
* party mode. Callers gate gameplay mounting on this promise so combat APIs can
* stay synchronous inside the simulation loop.
*/
export async function preloadAbilityCatalogsForClasses(classIds: readonly ClassId[]): Promise<void> {
const requested = new Set(classIds);
const loads: Promise<void>[] = [];
if (BASE_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadClassicAbilityCatalog());
if (ROM_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadRomAbilityCatalog());
for (const classId of COA_CLASS_IDS) {
if (requested.has(classId)) loads.push(preloadCoaAbilityCatalog(classId));
}
await Promise.all(loads);
}
export function triggeredAbilityBySpellId(
spellId: number,
includeCoa = true,
): AbilityDefinition | null {
return romTriggeredAbilityResolver?.(spellId)
?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined)
?? null;
}
export function abilitiesForClass(classId: ClassId): readonly AbilityDefinition[] {
return ABILITIES_BY_CLASS[classId];
return abilitiesByClass[classId] ?? [];
}
export function abilitiesForCharacter(
classId: ClassId,
secondaryClassId: ClassId | null | undefined,
): readonly AbilityDefinition[] {
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return ABILITIES_BY_CLASS[classId];
const primary = ABILITIES_BY_CLASS[classId];
const secondaryGeneral = ABILITIES_BY_CLASS[secondaryClassId].filter((ability) => (
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return abilitiesForClass(classId);
const primary = abilitiesForClass(classId);
const secondaryGeneral = abilitiesForClass(secondaryClassId).filter((ability) => (
ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-"))
));
const elites = ROM_ELITE_ABILITIES.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
const elites = romEliteAbilities.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
return [...primary, ...secondaryGeneral, ...elites];
}
@@ -885,7 +975,7 @@ export function abilitiesUnlockedBetweenLevels(
previousLevel: number,
currentLevel: number,
): readonly AbilityDefinition[] {
return ABILITIES_BY_CLASS[classId].filter((ability) => (
return abilitiesForClass(classId).filter((ability) => (
ability.unlockLevel > previousLevel && ability.unlockLevel <= currentLevel
));
}
@@ -920,7 +1010,7 @@ export function defaultActionBarForClass(classId: ClassId): readonly string[] {
|| effect.kind === "rune"
|| effect.kind === "trigger-spell"
)) ? 0 : 1;
const ordered = ABILITIES_BY_CLASS[classId]
const ordered = abilitiesForClass(classId)
.map((ability, index) => ({ ability, index }))
.sort((left, right) => (
left.ability.unlockLevel - right.ability.unlockLevel
@@ -962,5 +1052,15 @@ export function defaultActionBarForCharacter(classId: ClassId, secondaryClassId?
}
export function resourceProfileForClass(classId: ClassId): ClassResourceProfile {
return CLASS_RESOURCES[classId];
const profile = classResources[classId];
if (!profile) throw new Error(`Ability catalog for ${classId} was used before it was loaded.`);
return profile;
}
if (import.meta.env.MODE === "test") {
await preloadAbilityCatalogsForClasses([
...BASE_CLASS_IDS,
...COA_CLASS_IDS,
...ROM_CLASS_IDS,
]);
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
import {
COA_RUNTIME_ABILITIES_BY_CLASS,
COA_RUNTIME_CLASS_RESOURCES,
} from "./coaAbilityRuntimeCatalog";
describe("Conquest ability catalog shards", () => {
it.each(["barbarian", "runemaster"] as const)("loads only the %s executable catalog", async (classId) => {
const shard = await loadCoaAbilityCatalogShard(classId);
expect(shard.classId).toBe(classId);
expect(shard.abilities).toEqual(COA_RUNTIME_ABILITIES_BY_CLASS[classId]);
expect(shard.resource).toEqual(COA_RUNTIME_CLASS_RESOURCES[classId]);
expect(Object.values(shard.triggeredAbilitiesBySpellId).every((ability) => (
ability.classId === classId
))).toBe(true);
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { CoaClassId } from "../app/characterCatalog";
import type { AbilityDefinition, ClassResourceProfile } from "./abilityCatalog";
export interface CoaAbilityCatalogShard {
readonly schemaVersion: 1;
readonly classId: CoaClassId;
readonly abilities: readonly AbilityDefinition[];
readonly resource: ClassResourceProfile;
readonly triggeredAbilitiesBySpellId: Readonly<Record<number, AbilityDefinition>>;
}
interface CoaAbilityCatalogShardModule {
readonly default: CoaAbilityCatalogShard;
}
const shardLoaders = import.meta.glob<CoaAbilityCatalogShardModule>("./generated/coaAbilities/*.json");
const loadCache = new Map<CoaClassId, Promise<CoaAbilityCatalogShard>>();
export function loadCoaAbilityCatalogShard(classId: CoaClassId): Promise<CoaAbilityCatalogShard> {
const cached = loadCache.get(classId);
if (cached) return cached;
const key = `./generated/coaAbilities/${classId}.json`;
const loader = shardLoaders[key];
if (!loader) return Promise.reject(new Error(`No Conquest ability catalog exists for ${classId}.`));
const pending = loader().then((module) => {
if (module.default.schemaVersion !== 1 || module.default.classId !== classId) {
throw new Error(`Conquest ability catalog ${classId} has incompatible metadata.`);
}
return module.default;
}).catch((error: unknown) => {
loadCache.delete(classId);
throw error;
});
loadCache.set(classId, pending);
return pending;
}
+2 -3
View File
@@ -16,6 +16,7 @@ import {
defaultActionBarForCharacter,
isAbilityUnlocked,
resourceProfileForClass,
triggeredAbilityBySpellId,
type AbilityAmountScaling,
type AbilityDefinition,
type AbilityEffect,
@@ -57,8 +58,6 @@ import {
spendDeathKnightRunes,
} from "./combatResources";
import { resolveHealing, type HealingResolution } from "./combatHealing";
import { romTriggeredAbilityBySpellId } from "./romAbilityCatalog";
import { coaTriggeredAbilityBySpellId } from "./coaAbilityRuntimeCatalog";
import {
awardExperience,
healerManExperienceAward,
@@ -2578,7 +2577,7 @@ function executeTriggeredSpellInWork(
const catalogAbility = work.abilities.find((candidate) => (
candidate.dbcSpellId === spellId
|| candidate.ranks?.some((rank) => rank.spellId === spellId)
)) ?? romTriggeredAbilityBySpellId(spellId) ?? (allowCoaHidden ? coaTriggeredAbilityBySpellId(spellId) : null);
)) ?? triggeredAbilityBySpellId(spellId, allowCoaHidden);
if (!catalogAbility) return;
const ability = abilityAtLevel(catalogAbility, work.progression.level);
const source = playerThreatSource({ talentModifiers: work.talentModifiers }, ability.id, true);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13370,8 +13370,8 @@
{
"id": "visual",
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
"byteLength": 10266900,
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
"byteLength": 7068884,
"triangleCount": 248302,
"compression": "meshopt+ktx2"
}
@@ -54510,8 +54510,8 @@
{
"id": "visual",
"url": "/assets/game/manastorm/230-blackrockdepths/visual.glb",
"checksum": "42d57961c31f54ecc7bc78829751110fc89294e6318336b8f260e132e478d13d",
"byteLength": 10266900,
"checksum": "7e4c7c75a898f5000fcb686dcfc6d3c1c4e675dc3dac5f7a8d48e53372b9f364",
"byteLength": 7068884,
"triangleCount": 248302,
"compression": "meshopt+ktx2"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -4
View File
@@ -4,6 +4,7 @@ import {
abilityById,
isAbilityUnlocked,
resourceProfileForClass,
triggeredAbilityBySpellId,
type AbilityDefinition,
type AbilityEffect,
type ResourceType,
@@ -51,8 +52,6 @@ import {
} from "./partyAbilityAi";
import { normalizeActorResourcePool } from "./combatActors";
import { advanceCombatResourcePool } from "./combatResources";
import { romTriggeredAbilityBySpellId } from "./romAbilityCatalog";
import { coaTriggeredAbilityBySpellId } from "./coaAbilityRuntimeCatalog";
import {
combatHasLineOfSight,
combatSpatialActor,
@@ -1010,8 +1009,7 @@ function executePartyAbility(
};
} else if (effect.kind === "trigger-spell" && depth < 2) {
const triggered = abilitiesForClass(member.classId).find((candidate) => candidate.dbcSpellId === effect.spellId)
?? romTriggeredAbilityBySpellId(effect.spellId)
?? coaTriggeredAbilityBySpellId(effect.spellId);
?? triggeredAbilityBySpellId(effect.spellId);
if (triggered && triggered.id !== ability.id) {
const result = executePartyAbility(runtimeMember, triggered, targetId, now, members, depth + 1);
runtimeMember = result.member;
+10 -3
View File
@@ -14,18 +14,25 @@ function planKey(classId: ClassId, specialization: string): string {
return `${classId}:${specialization}`;
}
export function runtimePartyTalentPlan(
classId: ClassId,
specialization: string,
): readonly string[] {
const plan = snapshot.plans[planKey(classId, specialization)];
if (!plan) throw new Error(`Unsupported party specialization: ${classId}:${specialization}`);
return plan;
}
/** Runtime-only projection of the catalog-validated 71-point party plans. */
export function runtimePartyTalentRanksForLevel(
classId: ClassId,
specialization: string,
level: number,
): TalentRanks {
const plan = snapshot.plans[planKey(classId, specialization)];
if (!plan) throw new Error(`Unsupported party specialization: ${classId}:${specialization}`);
const plan = runtimePartyTalentPlan(classId, specialization);
const safeLevel = Number.isFinite(level) ? Math.max(1, Math.min(80, Math.trunc(level))) : 1;
const points = Math.max(0, safeLevel - 9);
const ranks: Record<string, number> = {};
for (const nodeId of plan.slice(0, points)) ranks[nodeId] = (ranks[nodeId] ?? 0) + 1;
return Object.freeze(ranks);
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { partyTalentAllocationPlan } from "./partyTalents";
import { partyTalentSelections } from "./partyTalentUi";
import { loadTalentUiCatalog } from "./talentUiCatalog";
describe("party talent UI projection", () => {
it("reconstructs the generated runtime plan from one class shard", async () => {
const catalog = await loadTalentUiCatalog("warrior");
const projected = partyTalentSelections(catalog, "warrior", "Protection");
const source = partyTalentAllocationPlan("warrior", "Protection");
expect(projected).toHaveLength(71);
expect(projected.map((selection) => ({
nodeId: selection.nodeId,
rank: selection.rank,
description: selection.description,
}))).toEqual(source.map((selection) => ({
nodeId: selection.nodeId,
rank: selection.rank,
description: selection.description,
})));
});
});
+73
View File
@@ -0,0 +1,73 @@
import type { ClassId } from "../app/characterCatalog";
import { clampPlayerLevel } from "./progression";
import { runtimePartyTalentPlan } from "./partyTalentRuntime";
import {
talentUiDescriptionForRank,
talentUiNodeById,
talentUiTreeById,
type TalentUiCatalog,
} from "./talentUiCatalog";
export interface PartyTalentSelection {
readonly point: number;
readonly level: number;
readonly nodeId: string;
readonly treeId: string;
readonly treeName: string;
readonly name: string;
readonly rank: number;
readonly maxRank: number;
readonly icon: string;
readonly description: string;
}
export function partyTalentSelections(
catalog: TalentUiCatalog,
classId: ClassId,
specialization: string,
): readonly PartyTalentSelection[] {
if (catalog.classId !== classId) return [];
const ranks: Record<string, number> = {};
return runtimePartyTalentPlan(classId, specialization).map((nodeId, index) => {
const node = talentUiNodeById(catalog, nodeId);
if (!node) throw new Error(`Party talent plan references missing UI node ${nodeId}.`);
const tree = talentUiTreeById(catalog, node.treeId);
if (!tree) throw new Error(`Party talent plan references missing UI tree ${node.treeId}.`);
const rank = (ranks[nodeId] ?? 0) + 1;
ranks[nodeId] = rank;
return Object.freeze({
point: index + 1,
level: index + 10,
nodeId,
treeId: tree.id,
treeName: tree.name,
name: node.name,
rank,
maxRank: node.maxRank,
icon: node.icon,
description: talentUiDescriptionForRank(node, rank),
});
});
}
export function learnedPartyTalentSelections(
catalog: TalentUiCatalog,
classId: ClassId,
specialization: string,
level: number,
): readonly PartyTalentSelection[] {
const points = Math.max(0, clampPlayerLevel(level) - 9);
return partyTalentSelections(catalog, classId, specialization).slice(0, points);
}
export function upcomingPartyTalentSelections(
catalog: TalentUiCatalog,
classId: ClassId,
specialization: string,
level: number,
limit = 5,
): readonly PartyTalentSelection[] {
const start = Math.max(0, clampPlayerLevel(level) - 9);
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : 0;
return partyTalentSelections(catalog, classId, specialization).slice(start, start + safeLimit);
}
+32
View File
@@ -204,6 +204,38 @@ function allocationFailure(
return null;
}
export function runtimeTalentAllocationFailure(
ranks: RuntimeTalentRanks,
classId: ClassId,
nodeId: string,
level: number,
): TalentAllocationFailure | null {
return allocationFailure(ranks, classId, nodeId, level);
}
export function runtimeSpentTalentPointsInTree(
ranks: RuntimeTalentRanks,
treeId: string,
): number {
return spentTalentPointsInTree(ranks, treeId);
}
export function runtimeUnspentTalentPoints(
level: number,
ranks: RuntimeTalentRanks,
classId?: ClassId,
): number {
return unspentTalentPoints(level, ranks, classId);
}
export function runtimeUnspentCoaEssence(
level: number,
ranks: RuntimeTalentRanks,
classId: CoaClassId,
): { readonly ability: number; readonly talent: number } {
return unspentCoaEssence(level, ranks, classId);
}
export function runtimeNormalizeTalentRanks(
classId: ClassId,
level: number,
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { TALENT_NODES, TALENT_TREES } from "./talentCatalog";
import { loadTalentUiCatalog, talentUiNodesForTree } from "./talentUiCatalog";
describe("talent UI catalog shards", () => {
it.each(["warrior", "barbarian"] as const)("loads only the %s class projection", async (classId) => {
const catalog = await loadTalentUiCatalog(classId);
const sourceTrees = TALENT_TREES.filter((tree) => tree.classId === classId);
const sourceTreeIds = new Set(sourceTrees.map((tree) => tree.id));
const sourceNodes = TALENT_NODES.filter((node) => sourceTreeIds.has(node.treeId));
expect(catalog.classId).toBe(classId);
expect(catalog.trees.map((tree) => tree.id)).toEqual(sourceTrees.map((tree) => tree.id));
expect(catalog.nodes.map((node) => node.id)).toEqual(sourceNodes.map((node) => node.id));
expect(catalog.nodes.every((node) => !("rankEffects" in node))).toBe(true);
for (const tree of catalog.trees) {
expect(talentUiNodesForTree(catalog, tree.id).every((node) => node.treeId === tree.id)).toBe(true);
}
});
});
+93
View File
@@ -0,0 +1,93 @@
import type { ClassId } from "../app/characterCatalog";
export interface TalentUiTree {
readonly id: string;
readonly classId: ClassId;
readonly name: string;
readonly icon: string;
readonly background: string;
readonly description: string;
readonly system?: "classic" | "coa";
}
export interface TalentUiNode {
readonly id: string;
readonly treeId: string;
readonly index: number;
readonly prerequisiteId: string | null;
readonly prerequisiteRank: number | null;
readonly prerequisites: readonly {
readonly talentId: string;
readonly requiredRank: number;
}[];
readonly column: number;
readonly row: number;
readonly icon: string;
readonly maxRank: number;
readonly name: string;
readonly description: string;
readonly rankDescriptions: readonly string[];
readonly system?: "classic" | "coa";
readonly entryType?: "Ability" | "Talent";
readonly abilityEssenceCost?: number;
readonly talentEssenceCost?: number;
readonly requiredClassPoints?: number;
readonly requiredSpecPoints?: number;
readonly requiredLevel?: number;
readonly isPassive?: boolean;
}
export interface TalentUiCatalog {
readonly schemaVersion: 1;
readonly classId: ClassId;
readonly trees: readonly TalentUiTree[];
readonly nodes: readonly TalentUiNode[];
}
interface TalentUiCatalogModule {
readonly default: TalentUiCatalog;
}
const shardLoaders = import.meta.glob<TalentUiCatalogModule>("./generated/talentUi/*.json");
const loadCache = new Map<ClassId, Promise<TalentUiCatalog>>();
export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog> {
const cached = loadCache.get(classId);
if (cached) return cached;
const key = `./generated/talentUi/${classId}.json`;
const loader = shardLoaders[key];
if (!loader) return Promise.reject(new Error(`No talent UI catalog exists for ${classId}.`));
const pending = loader().then((module) => {
if (module.default.schemaVersion !== 1 || module.default.classId !== classId) {
throw new Error(`Talent UI catalog ${classId} has incompatible metadata.`);
}
return module.default;
}).catch((error: unknown) => {
loadCache.delete(classId);
throw error;
});
loadCache.set(classId, pending);
return pending;
}
export function talentUiDescriptionForRank(node: TalentUiNode, rank: number): string {
const normalizedRank = Math.min(
node.maxRank,
Math.max(1, Math.trunc(Number.isFinite(rank) ? rank : 1)),
);
return node.rankDescriptions[normalizedRank - 1] ?? node.description;
}
export function talentUiTreeById(catalog: TalentUiCatalog, treeId: string): TalentUiTree | null {
return catalog.trees.find((tree) => tree.id === treeId) ?? null;
}
export function talentUiNodeById(catalog: TalentUiCatalog, nodeId: string): TalentUiNode | null {
return catalog.nodes.find((node) => node.id === nodeId) ?? null;
}
export function talentUiNodesForTree(catalog: TalentUiCatalog, treeId: string): readonly TalentUiNode[] {
return catalog.nodes
.filter((node) => node.treeId === treeId)
.sort((left, right) => left.row - right.row || left.index - right.index);
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
describe("adaptive graphics quality", () => {
it("maps the performance range onto the supported pixel ratio", () => {
expect(dprForPerformanceFactor(0)).toBe(1);
expect(dprForPerformanceFactor(0.5)).toBe(1.25);
expect(dprForPerformanceFactor(1)).toBe(1.5);
});
it("clamps out-of-range monitor factors", () => {
expect(dprForPerformanceFactor(-1)).toBe(1);
expect(dprForPerformanceFactor(2)).toBe(1.5);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { PerformanceMonitor } from "@react-three/drei";
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
export function AdaptiveGraphicsQuality({
onDprChange,
}: {
readonly onDprChange: (dpr: number) => void;
}) {
return (
<PerformanceMonitor
factor={1}
iterations={6}
ms={250}
step={0.25}
threshold={0.75}
bounds={(refreshRate) => refreshRate > 100 ? [60, 100] : [52, 58]}
onChange={({ factor }) => onDprChange(dprForPerformanceFactor(factor))}
/>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useFrame } from "@react-three/fiber";
import { useEffect, useRef } from "react";
const DATASET_KEY = "gamePerformance";
/** Publishes low-overhead renderer counters for local profiling only. */
export function DevelopmentPerformanceProbe() {
const elapsedRef = useRef(0);
const framesRef = useRef(0);
const slowestFrameRef = useRef(0);
useFrame(({ gl }, delta) => {
elapsedRef.current += delta;
framesRef.current += 1;
slowestFrameRef.current = Math.max(slowestFrameRef.current, delta);
if (elapsedRef.current < 1) return;
document.documentElement.dataset[DATASET_KEY] = JSON.stringify({
fps: framesRef.current / elapsedRef.current,
averageFrameMs: elapsedRef.current * 1_000 / framesRef.current,
slowestFrameMs: slowestFrameRef.current * 1_000,
drawCalls: gl.info.render.calls,
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
pixelRatio: gl.getPixelRatio(),
});
elapsedRef.current = 0;
framesRef.current = 0;
slowestFrameRef.current = 0;
});
useEffect(() => () => {
delete document.documentElement.dataset[DATASET_KEY];
}, []);
return null;
}
+20 -1
View File
@@ -28,6 +28,7 @@ import {
encounterWorldIsReady,
encounterWorldKey,
rendererCanvasKey,
sceneFrameLoop,
} from "./sceneLifecycle";
import { manastormChaoticLinkRuntimeIds } from "../game/manastormChaoticLink";
import { manastormStagePopulation, manastormStageRoamingPacks } from "../game/manastormStagePopulation";
@@ -44,6 +45,8 @@ import { GmPlacementMarkers } from "./GmPlacementMarkers";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
import { DevelopmentPerformanceProbe } from "./DevelopmentPerformanceProbe";
import { AdaptiveGraphicsQuality } from "./AdaptiveGraphicsQuality";
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -97,12 +100,21 @@ export function GameScene() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
const [safeGraphics, setSafeGraphics] = useState(false);
const [adaptiveDpr, setAdaptiveDpr] = useState(1.5);
const [canvasRevision, setCanvasRevision] = useState(0);
const [graphicsRecovery, setGraphicsRecovery] = useState<"lost" | "recovering" | null>(null);
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
const simulationBlocked = graphicsRecovery !== null
|| !worldReady
|| (!sharedOnline && (paused || mapOpen || companionOpen));
const frameloop = sceneFrameLoop({
worldReady,
graphicsRecovering: graphicsRecovery !== null,
sharedOnline,
paused,
mapOpen,
companionOpen,
});
const allowedRuntimeIds = useMemo(
() => gameMode === "manastorm" && manastormEncounter
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
@@ -153,6 +165,10 @@ export function GameScene() {
useWailingEncounterStore.getState().reset();
}, [activeDungeonId, gameMode, sessionRevision]);
useEffect(() => {
setAdaptiveDpr(1.5);
}, [activeDungeonId, sessionRevision]);
const endMouseLook = useCallback(() => {
endMouseLookDrag();
}, []);
@@ -193,7 +209,8 @@ export function GameScene() {
key={rendererCanvasKey(canvasRevision)}
ref={canvasRef}
shadows={!safeGraphics}
dpr={safeGraphics ? 1 : [1, 1.5]}
dpr={safeGraphics ? 1 : adaptiveDpr}
frameloop={frameloop}
camera={{
fov: 62,
near: 0.08,
@@ -210,6 +227,8 @@ export function GameScene() {
}}
>
<GameGltfLoaderLifecycle>
{!safeGraphics && worldReady && <AdaptiveGraphicsQuality onDprChange={setAdaptiveDpr} />}
{import.meta.env.DEV && <DevelopmentPerformanceProbe />}
<color attach="background" args={[dungeon.presentation.background]} />
<fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} />
<ambientLight intensity={dungeon.presentation.ambientLight.intensity} color={dungeon.presentation.ambientLight.color} />
+2 -19
View File
@@ -78,6 +78,7 @@ import { useGameStore } from "../game/store";
import { useManastormStore } from "../game/manastormStore";
import { registerCombatSpatialProvider } from "../game/combatSpatial";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
const PARTY_MOVE_SPEED = 5.25;
const PARTY_CATCH_UP_SPEED = 7.5;
@@ -378,25 +379,7 @@ function PartyActors({
const hasStaticLineOfSight = useCallback((
start: PartyWorldPosition,
end: PartyWorldPosition,
): boolean => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (distance <= 0.08) return true;
const ray = new rapier.Ray(
{ x: start[0], y: start[1] + 1.05, z: start[2] },
{ x: dx / distance, y: dy / distance, z: dz / distance },
);
const hit = world.castRay(
ray,
distance,
true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
);
return !hit || hit.timeOfImpact >= distance - 0.08;
}, [rapier, world]);
): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]);
const routeSurfaceSegmentAllowed = useCallback((
start: PartyWorldPosition,
end: PartyWorldPosition,
+40 -1
View File
@@ -7,7 +7,11 @@ import {
MeshBasicMaterial,
} from "three";
import { describe, expect, it } from "vitest";
import { collisionTrimeshes, prepareCollision } from "./productionDungeonCollision";
import {
collisionLineOfSightTrimeshes,
collisionTrimeshes,
prepareCollision,
} from "./productionDungeonCollision";
describe("streamed dungeon collision preparation", () => {
it("dequantizes normalized Meshopt position buffers before Rapier consumes them", () => {
@@ -54,4 +58,39 @@ describe("streamed dungeon collision preparation", () => {
expect(Array.from(trimesh.indices)).toEqual([0, 1, 2]);
});
it("builds sight collision from walls while excluding ground and ramps", () => {
const geometry = new BufferGeometry();
geometry.setAttribute(
"position",
new Float32BufferAttribute([
0, 0, 0,
2, 0, 0,
0, 0, 2,
0, 0, 3,
2, 0, 3,
0, 2, 5,
4, 0, 0,
4, 2, 0,
4, 0, 2,
], 3),
);
geometry.setIndex([
0, 1, 2,
3, 4, 5,
6, 7, 8,
]);
const root = new Group();
root.add(new Mesh(geometry, new MeshBasicMaterial()));
const prepared = prepareCollision(root);
const [blocker] = collisionLineOfSightTrimeshes(prepared);
expect(Array.from(blocker.indices)).toEqual([0, 1, 2]);
expect(Array.from(blocker.vertices)).toEqual([
4, 0, 0,
4, 2, 0,
4, 0, 2,
]);
});
});
+21 -1
View File
@@ -12,8 +12,17 @@ import {
import type { ResolvedDungeonAssets } from "../game/dungeonAssets";
import type { DungeonDefinition, DungeonMaterialRoles } from "../game/dungeonTypes";
import { useGameStore } from "../game/store";
import { collisionTrimeshes, prepareCollision } from "./productionDungeonCollision";
import {
collisionLineOfSightTrimeshes,
collisionTrimeshes,
prepareCollision,
} from "./productionDungeonCollision";
import {
DUNGEON_PHYSICAL_COLLISION_GROUPS,
DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS,
} from "./dungeonLineOfSight";
import { disposeObject3DResources } from "./threeResourceDisposal";
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
import { useGameGLTF } from "./useGameGLTF";
import {
DUNGEON_COLLISION_LOAD_BATCH_SIZE,
@@ -81,6 +90,7 @@ function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
instanced.computeBoundingSphere();
}
});
instanceRepeatedStaticMeshes(clone);
return clone;
}
@@ -141,6 +151,7 @@ function CollisionChunk({
const gltf = useGameGLTF(url);
const collision = useMemo(() => prepareCollision(gltf.scene), [gltf.scene]);
const trimeshes = useMemo(() => collisionTrimeshes(collision), [collision]);
const sightBlockers = useMemo(() => collisionLineOfSightTrimeshes(collision), [collision]);
useEffect(() => () => {
disposeObject3DResources(collision, {
materials: false,
@@ -166,6 +177,15 @@ function CollisionChunk({
<TrimeshCollider
key={trimesh.id}
args={[trimesh.vertices, trimesh.indices]}
collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS}
/>
))}
{sightBlockers.map((trimesh) => (
<TrimeshCollider
key={trimesh.id}
args={[trimesh.vertices, trimesh.indices]}
collisionGroups={DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS}
sensor
/>
))}
<primitive object={collision} />
+25 -6
View File
@@ -1,17 +1,36 @@
import { Sparkles } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier";
import type { DungeonDefinition } from "../game/dungeonTypes";
import {
DUNGEON_PHYSICAL_COLLISION_GROUPS,
DUNGEON_WALL_COLLISION_GROUPS,
} from "./dungeonLineOfSight";
interface SlabProps {
position: [number, number, number];
size: [number, number, number];
rotationY?: number;
color?: string;
blocksLineOfSight?: boolean;
}
function CaveSlab({ position, size, rotationY = 0, color = "#25352b" }: SlabProps) {
function CaveSlab({
position,
size,
rotationY = 0,
color = "#25352b",
blocksLineOfSight = false,
}: SlabProps) {
return (
<RigidBody type="fixed" colliders="cuboid" position={position} rotation={[0, rotationY, 0]}>
<RigidBody
type="fixed"
colliders="cuboid"
collisionGroups={blocksLineOfSight
? DUNGEON_WALL_COLLISION_GROUPS
: DUNGEON_PHYSICAL_COLLISION_GROUPS}
position={position}
rotation={[0, rotationY, 0]}
>
<mesh receiveShadow castShadow>
<boxGeometry args={size} />
<meshStandardMaterial color={color} roughness={0.96} metalness={0.02} />
@@ -45,11 +64,11 @@ export function PrototypeDungeon({ definition }: { readonly definition: DungeonD
rotation={[0, entrance.yaw, 0]}
>
<CaveSlab position={[0, -0.3, 11]} size={[7.4, 0.6, 28]} />
<CaveSlab position={[-4.1, 2.15, 11]} size={[1.1, 4.9, 28]} color="#1c2c24" />
<CaveSlab position={[4.1, 2.15, 11]} size={[1.1, 4.9, 28]} color="#1c2c24" />
<CaveSlab position={[-4.1, 2.15, 11]} size={[1.1, 4.9, 28]} color="#1c2c24" blocksLineOfSight />
<CaveSlab position={[4.1, 2.15, 11]} size={[1.1, 4.9, 28]} color="#1c2c24" blocksLineOfSight />
<CaveSlab position={[0, -0.36, 31]} size={[18, 0.72, 15]} color="#2b3d31" />
<CaveSlab position={[-9.25, 2, 31]} size={[1.1, 4.7, 15]} color="#1b2a23" />
<CaveSlab position={[9.25, 2, 31]} size={[1.1, 4.7, 15]} color="#1b2a23" />
<CaveSlab position={[-9.25, 2, 31]} size={[1.1, 4.7, 15]} color="#1b2a23" blocksLineOfSight />
<CaveSlab position={[9.25, 2, 31]} size={[1.1, 4.7, 15]} color="#1b2a23" blocksLineOfSight />
<CaveSlab position={[3.3, -0.3, 44]} size={[9, 0.6, 19]} rotationY={-0.34} />
{stonePositions.map((position, index) => <Stone key={index} position={position} />)}
+3
View File
@@ -0,0 +1,3 @@
export function dprForPerformanceFactor(factor: number): number {
return 1 + Math.max(0, Math.min(1, factor)) * 0.5;
}
+57
View File
@@ -0,0 +1,57 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { beforeAll, describe, expect, it } from "vitest";
import {
DUNGEON_PHYSICAL_COLLISION_GROUPS,
DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS,
hasDungeonLineOfSight,
} from "./dungeonLineOfSight";
beforeAll(async () => {
await RAPIER.init();
});
function fixedCuboid(
world: RAPIER.World,
translation: readonly [number, number, number],
collisionGroups: number,
sensor = false,
): void {
const body = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
world.createCollider(
RAPIER.ColliderDesc.cuboid(0.1, 2, 2)
.setTranslation(...translation)
.setCollisionGroups(collisionGroups)
.setSensor(sensor),
body,
);
}
function kinematicCaster(world: RAPIER.World): void {
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(0, 1.05, 0),
);
world.createCollider(RAPIER.ColliderDesc.capsule(0.5, 0.3), body);
}
describe("dungeon line of sight", () => {
it("ignores physical ground geometry even when it crosses the sight ray", () => {
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
fixedCuboid(world, [2, 1.05, 0], DUNGEON_PHYSICAL_COLLISION_GROUPS);
kinematicCaster(world);
world.step();
expect(hasDungeonLineOfSight(RAPIER, world, [0, 0, 0], [4, 0, 0])).toBe(true);
world.free();
});
it("is blocked by dedicated wall and pillar geometry", () => {
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
fixedCuboid(world, [2, 1.05, 0], DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS, true);
world.step();
expect(hasDungeonLineOfSight(RAPIER, world, [0, 0, 0], [4, 0, 0])).toBe(false);
world.free();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { interactionGroups } from "@react-three/rapier";
import type {
QueryFilterFlags,
Ray,
Vector,
World,
} from "@dimforge/rapier3d-compat";
interface RapierApi {
readonly Ray: new (origin: Vector, direction: Vector) => Ray;
readonly QueryFilterFlags: {
readonly EXCLUDE_DYNAMIC: QueryFilterFlags;
readonly EXCLUDE_KINEMATIC: QueryFilterFlags;
};
}
type WorldPosition = readonly [number, number, number];
export const DUNGEON_LINE_OF_SIGHT_GROUP = 15;
export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(0);
export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP);
export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([0, DUNGEON_LINE_OF_SIGHT_GROUP]);
export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups(
DUNGEON_LINE_OF_SIGHT_GROUP,
DUNGEON_LINE_OF_SIGHT_GROUP,
);
const ACTOR_SIGHT_HEIGHT = 1.05;
const TARGET_MARGIN = 0.08;
/**
* Checks only the dedicated wall/pillar collision layer. Walkable terrain and
* ceilings remain physical, but never participate in combat visibility.
*/
export function hasDungeonLineOfSight(
rapier: RapierApi,
world: World,
start: WorldPosition,
end: WorldPosition,
): boolean {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (distance <= TARGET_MARGIN) return true;
const ray = new rapier.Ray(
{ x: start[0], y: start[1] + ACTOR_SIGHT_HEIGHT, z: start[2] },
{ x: dx / distance, y: dy / distance, z: dz / distance },
);
const hit = world.castRay(
ray,
distance,
true,
rapier.QueryFilterFlags.EXCLUDE_DYNAMIC | rapier.QueryFilterFlags.EXCLUDE_KINEMATIC,
DUNGEON_SIGHT_QUERY_GROUPS,
);
return !hit || hit.timeOfImpact >= distance - TARGET_MARGIN;
}
+62
View File
@@ -11,6 +11,10 @@ export interface PreparedCollisionTrimesh {
readonly indices: ArrayLike<number>;
}
// A surface must be within 30 degrees of vertical to occlude combat sight.
// This deliberately rejects floors, ramps, slopes, and ceilings.
const MAX_SIGHT_BLOCKER_NORMAL_Y = 0.5;
export function prepareCollision(root: Object3D): Group {
root.updateWorldMatrix(true, true);
const inverseRootMatrix = root.matrixWorld.clone().invert();
@@ -60,3 +64,61 @@ export function collisionTrimeshes(root: Object3D): readonly PreparedCollisionTr
});
return result;
}
export function collisionLineOfSightTrimeshes(root: Object3D): readonly PreparedCollisionTrimesh[] {
const result: PreparedCollisionTrimesh[] = [];
root.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const position = mesh.geometry.getAttribute("position");
const sourceIndices = mesh.geometry.index?.array
?? Uint32Array.from({ length: position.count }, (_, vertex) => vertex);
const blockerSourceIndices: number[] = [];
for (let offset = 0; offset + 2 < sourceIndices.length; offset += 3) {
const first = Number(sourceIndices[offset]);
const second = Number(sourceIndices[offset + 1]);
const third = Number(sourceIndices[offset + 2]);
const firstX = position.getX(first);
const firstY = position.getY(first);
const firstZ = position.getZ(first);
const abX = position.getX(second) - firstX;
const abY = position.getY(second) - firstY;
const abZ = position.getZ(second) - firstZ;
const acX = position.getX(third) - firstX;
const acY = position.getY(third) - firstY;
const acZ = position.getZ(third) - firstZ;
const normalX = abY * acZ - abZ * acY;
const normalY = abZ * acX - abX * acZ;
const normalZ = abX * acY - abY * acX;
const normalLength = Math.hypot(normalX, normalY, normalZ);
if (normalLength <= Number.EPSILON) continue;
if (Math.abs(normalY) / normalLength > MAX_SIGHT_BLOCKER_NORMAL_Y) continue;
blockerSourceIndices.push(first, second, third);
}
if (!blockerSourceIndices.length) return;
const remappedVertices: number[] = [];
const remappedIndices = new Uint32Array(blockerSourceIndices.length);
const remap = new Map<number, number>();
blockerSourceIndices.forEach((sourceIndex, indexOffset) => {
let remappedIndex = remap.get(sourceIndex);
if (remappedIndex === undefined) {
remappedIndex = remap.size;
remap.set(sourceIndex, remappedIndex);
remappedVertices.push(
position.getX(sourceIndex),
position.getY(sourceIndex),
position.getZ(sourceIndex),
);
}
remappedIndices[indexOffset] = remappedIndex;
});
result.push({
id: `${mesh.uuid}-line-of-sight`,
vertices: Float32Array.from(remappedVertices),
indices: remappedIndices,
});
});
return result;
}
+19
View File
@@ -3,6 +3,7 @@ import {
encounterWorldIsReady,
encounterWorldKey,
rendererCanvasKey,
sceneFrameLoop,
} from "./sceneLifecycle";
describe("R3F scene lifecycle", () => {
@@ -18,4 +19,22 @@ describe("R3F scene lifecycle", () => {
expect(encounterWorldIsReady(4, 4)).toBe(true);
expect(encounterWorldIsReady(4, 5)).toBe(false);
});
it("renders local paused worlds on demand without stalling loading or online worlds", () => {
const readyWorld = {
worldReady: true,
graphicsRecovering: false,
sharedOnline: false,
paused: false,
mapOpen: false,
companionOpen: false,
};
expect(sceneFrameLoop({ ...readyWorld, paused: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, mapOpen: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, companionOpen: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("always");
expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("always");
expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("always");
expect(sceneFrameLoop(readyWorld)).toBe("always");
});
});
+21
View File
@@ -1,4 +1,5 @@
import type { DungeonId } from "../game/dungeonRegistry";
import type { Frameloop } from "@react-three/fiber";
/**
* Encounter transitions must never remount the Canvas: R3F intentionally loses
@@ -27,3 +28,23 @@ export function encounterWorldIsReady(
return readySessionRevision !== null
&& readySessionRevision === Math.max(0, Math.trunc(sessionRevision));
}
/**
* A paused local world only needs a frame when React or R3F invalidates it.
* Loading, recovery, and shared-online worlds keep the continuous loop so
* asynchronous scene setup and authoritative remote simulation can progress.
*/
export function sceneFrameLoop(options: {
readonly worldReady: boolean;
readonly graphicsRecovering: boolean;
readonly sharedOnline: boolean;
readonly paused: boolean;
readonly mapOpen: boolean;
readonly companionOpen: boolean;
}): Frameloop {
const locallyPaused = options.worldReady
&& !options.graphicsRecovering
&& !options.sharedOnline
&& (options.paused || options.mapOpen || options.companionOpen);
return locallyPaused ? "demand" : "always";
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
BoxGeometry,
Group,
InstancedMesh,
Matrix4,
Mesh,
MeshBasicMaterial,
Vector3,
} from "three";
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
function batchIn(root: Group): InstancedMesh {
let result: InstancedMesh | null = null;
root.traverse((node) => {
if ((node as InstancedMesh).isInstancedMesh) result = node as InstancedMesh;
});
if (!result) throw new Error("Expected an instanced mesh batch.");
return result;
}
function batchesIn(root: Group): readonly InstancedMesh[] {
const result: InstancedMesh[] = [];
root.traverse((node) => {
if ((node as InstancedMesh).isInstancedMesh) result.push(node as InstancedMesh);
});
return result;
}
describe("static scene instancing", () => {
it("batches repeated meshes while retaining their world transforms", () => {
const root = new Group();
root.position.set(10, 0, 0);
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
for (const x of [1, 3, 7]) {
const mesh = new Mesh(geometry, material);
mesh.position.x = x;
root.add(mesh);
}
expect(instanceRepeatedStaticMeshes(root)).toEqual({
batches: 1,
instances: 3,
removedMeshes: 3,
});
const batch = batchIn(root);
const matrix = new Matrix4();
const position = new Vector3();
const positions = Array.from({ length: batch.count }, (_, index) => {
batch.getMatrixAt(index, matrix);
position.setFromMatrixPosition(matrix);
return position.x;
});
expect(positions).toEqual([1, 3, 7]);
});
it("leaves small groups and existing instance batches unchanged", () => {
const root = new Group();
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
root.add(new Mesh(geometry, material), new Mesh(geometry, material));
const existing = new InstancedMesh(geometry, material, 4);
root.add(existing);
expect(instanceRepeatedStaticMeshes(root)).toEqual({
batches: 0,
instances: 0,
removedMeshes: 0,
});
expect(root.children).toHaveLength(3);
expect(root.children).toContain(existing);
});
it("does not combine meshes whose render state differs", () => {
const root = new Group();
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
for (let index = 0; index < 3; index += 1) {
const mesh = new Mesh(geometry, material);
mesh.receiveShadow = index !== 2;
root.add(mesh);
}
expect(instanceRepeatedStaticMeshes(root)).toEqual({
batches: 0,
instances: 0,
removedMeshes: 0,
});
});
it("keeps distant repetitions in separate cullable spatial batches", () => {
const root = new Group();
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
for (const x of [1, 2, 3, 385, 386, 387]) {
const mesh = new Mesh(geometry, material);
mesh.position.x = x;
root.add(mesh);
}
expect(instanceRepeatedStaticMeshes(root)).toEqual({
batches: 2,
instances: 6,
removedMeshes: 6,
});
expect(batchesIn(root).map((batch) => batch.count)).toEqual([3, 3]);
});
});
+116
View File
@@ -0,0 +1,116 @@
import {
InstancedMesh,
Matrix4,
type Material,
type Mesh,
type Object3D,
Vector3,
} from "three";
export const STATIC_INSTANCE_CELL_SIZE = 192;
export interface StaticSceneInstancingReport {
readonly batches: number;
readonly instances: number;
readonly removedMeshes: number;
}
function materialKey(material: Material | readonly Material[]): string {
return (Array.isArray(material) ? material : [material])
.map((entry) => entry.uuid)
.join(",");
}
function candidateKey(mesh: Mesh): string {
return [
mesh.geometry.uuid,
materialKey(mesh.material),
mesh.castShadow ? "cast" : "no-cast",
mesh.receiveShadow ? "receive" : "no-receive",
mesh.renderOrder,
mesh.layers.mask,
mesh.frustumCulled ? "culled" : "not-culled",
].join("|");
}
function candidateCell(mesh: Mesh, cellSize: number, center: Vector3): string {
if (!mesh.geometry.boundingSphere) mesh.geometry.computeBoundingSphere();
const geometryCenter = mesh.geometry.boundingSphere?.center;
if (geometryCenter) center.copy(geometryCenter);
else center.set(0, 0, 0);
center.applyMatrix4(mesh.matrixWorld);
return [
Math.floor(center.x / cellSize),
Math.floor(center.y / cellSize),
Math.floor(center.z / cellSize),
].join(":");
}
function isStaticInstancingCandidate(root: Object3D, node: Object3D): node is Mesh {
const mesh = node as Mesh;
return node !== root
&& mesh.isMesh === true
&& (mesh as Mesh & { readonly isInstancedMesh?: boolean }).isInstancedMesh !== true
&& (mesh as Mesh & { readonly isSkinnedMesh?: boolean }).isSkinnedMesh !== true
&& mesh.visible
&& mesh.children.length === 0
&& Object.keys(mesh.geometry.morphAttributes).length === 0
&& mesh.matrixWorld.determinant() > 0;
}
/**
* Flattens repeated static meshes into InstancedMesh batches once, immediately
* after a dungeon visual is cloned. This keeps legacy packs efficient without
* moving scene work into the render loop; newly optimized GLBs are left alone.
*/
export function instanceRepeatedStaticMeshes(
root: Object3D,
minimumInstances = 3,
cellSize = STATIC_INSTANCE_CELL_SIZE,
): StaticSceneInstancingReport {
root.updateWorldMatrix(true, true);
const safeCellSize = Number.isFinite(cellSize) && cellSize > 0
? cellSize
: STATIC_INSTANCE_CELL_SIZE;
const center = new Vector3();
const groups = new Map<string, Mesh[]>();
root.traverse((node) => {
if (!isStaticInstancingCandidate(root, node)) return;
const key = `${candidateKey(node)}|cell:${candidateCell(node, safeCellSize, center)}`;
const group = groups.get(key);
if (group) group.push(node);
else groups.set(key, [node]);
});
const inverseRootWorld = new Matrix4().copy(root.matrixWorld).invert();
let batches = 0;
let instances = 0;
let removedMeshes = 0;
for (const meshes of groups.values()) {
if (meshes.length < minimumInstances) continue;
const prototype = meshes[0];
const batch = new InstancedMesh(prototype.geometry, prototype.material, meshes.length);
batch.name = `${prototype.name || prototype.geometry.name || "mesh"}-instances`;
batch.castShadow = prototype.castShadow;
batch.receiveShadow = prototype.receiveShadow;
batch.renderOrder = prototype.renderOrder;
batch.layers.mask = prototype.layers.mask;
batch.frustumCulled = prototype.frustumCulled;
for (let index = 0; index < meshes.length; index += 1) {
const mesh = meshes[index];
batch.setMatrixAt(index, new Matrix4().multiplyMatrices(inverseRootWorld, mesh.matrixWorld));
mesh.removeFromParent();
}
batch.instanceMatrix.needsUpdate = true;
batch.computeBoundingBox();
batch.computeBoundingSphere();
root.add(batch);
batches += 1;
instances += meshes.length;
removedMeshes += meshes.length;
}
return { batches, instances, removedMeshes };
}
+58 -11
View File
@@ -4,14 +4,18 @@ import {
learnedPartyTalentSelections,
upcomingPartyTalentSelections,
type PartyTalentSelection,
} from "../game/partyTalents";
} from "../game/partyTalentUi";
import {
PARTY_ROLE_LABELS,
usePartyStore,
type PartyCommand,
type PartyMember,
} from "../game/partyStore";
import { talentTreeById } from "../game/talentCatalog";
import {
loadTalentUiCatalog,
talentUiTreeById,
type TalentUiCatalog,
} from "../game/talentUiCatalog";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
@@ -70,6 +74,8 @@ function PartyManagementDialog() {
const command = usePartyStore((state) => state.command);
const openOverlay = useGameStore((state) => state.openOverlay);
const [inspectedMemberId, setInspectedMemberId] = useState(() => members[0]?.id ?? "");
const [talentCatalog, setTalentCatalog] = useState<TalentUiCatalog | null>(null);
const [talentCatalogErrorClassId, setTalentCatalogErrorClassId] = useState<string | null>(null);
const close = useCallback(() => openOverlay("pause"), [openOverlay]);
useEffect(() => {
@@ -79,13 +85,45 @@ function PartyManagementDialog() {
}, [inspectedMemberId, members]);
const inspectedMember = members.find((member) => member.id === inspectedMemberId) ?? members[0] ?? null;
const learned = useMemo(() => inspectedMember
? learnedPartyTalentSelections(inspectedMember.classId, inspectedMember.specialization, inspectedMember.level)
: [], [inspectedMember]);
useEffect(() => {
const classId = inspectedMember?.classId;
if (!classId || classId.startsWith("rom-")) {
setTalentCatalog(null);
setTalentCatalogErrorClassId(null);
return;
}
let active = true;
setTalentCatalogErrorClassId(null);
void loadTalentUiCatalog(classId).then((catalog) => {
if (active) setTalentCatalog(catalog);
}).catch(() => {
if (!active) return;
setTalentCatalog(null);
setTalentCatalogErrorClassId(classId);
});
return () => { active = false; };
}, [inspectedMember?.classId]);
const activeTalentCatalog = inspectedMember && talentCatalog?.classId === inspectedMember.classId
? talentCatalog
: null;
const learned = useMemo(() => inspectedMember && activeTalentCatalog
? learnedPartyTalentSelections(
activeTalentCatalog,
inspectedMember.classId,
inspectedMember.specialization,
inspectedMember.level,
)
: [], [activeTalentCatalog, inspectedMember]);
const currentTalents = useMemo(() => currentTalentRanks(learned), [learned]);
const upcomingTalents = useMemo(() => inspectedMember
? upcomingPartyTalentSelections(inspectedMember.classId, inspectedMember.specialization, inspectedMember.level, 3)
: [], [inspectedMember]);
const upcomingTalents = useMemo(() => inspectedMember && activeTalentCatalog
? upcomingPartyTalentSelections(
activeTalentCatalog,
inspectedMember.classId,
inspectedMember.specialization,
inspectedMember.level,
3,
)
: [], [activeTalentCatalog, inspectedMember]);
const actions = useMemo<MenuAction[]>(() => [
...members.map((member) => ({
@@ -106,7 +144,9 @@ function PartyManagementDialog() {
const memberClass = inspectedMember ? classById(inspectedMember.classId) : null;
const memberRace = inspectedMember ? raceById(inspectedMember.raceId) : null;
const primaryTree = inspectedMember ? talentTreeById(inspectedMember.talentTreeId) : null;
const primaryTree = inspectedMember && activeTalentCatalog
? talentUiTreeById(activeTalentCatalog, inspectedMember.talentTreeId)
: null;
const primaryPoints = inspectedMember
? learned.filter((selection) => selection.treeId === inspectedMember.talentTreeId).length
: 0;
@@ -210,7 +250,14 @@ function PartyManagementDialog() {
</header>
<div className="party-management__talent-list">
{currentTalents.map((selection) => <TalentSelectionCard key={selection.nodeId} selection={selection} />)}
{!currentTalents.length && (
{!activeTalentCatalog && (
<p className="party-management__empty-talents">
{talentCatalogErrorClassId === inspectedMember.classId
? "Talent details are temporarily unavailable."
: "Preparing this class's talent catalog…"}
</p>
)}
{activeTalentCatalog && !currentTalents.length && (
<p className="party-management__empty-talents">
Talent training begins at level 10. This build will advance automatically with you.
</p>
@@ -227,7 +274,7 @@ function PartyManagementDialog() {
{upcomingTalents.map((selection) => (
<TalentSelectionCard key={`${selection.level}-${selection.nodeId}`} selection={selection} upcoming />
))}
{!upcomingTalents.length && (
{activeTalentCatalog && !upcomingTalents.length && (
<p className="party-management__empty-talents">This party member has completed the level 80 talent plan.</p>
)}
</div>
+15 -17
View File
@@ -1,14 +1,12 @@
import { useMemo } from "react";
import { classById } from "../app/characterCatalog";
import { use, useMemo } from "react";
import { classById, type CoaClassId } from "../app/characterCatalog";
import { useCombatStore } from "../game/combatStore";
import {
spentTalentPointsInTree,
talentNodesForTree,
talentTreesForClass,
unspentCoaEssence,
unspentTalentPoints,
} from "../game/talentCatalog";
import { isCoaClassId } from "../game/coaLiveCatalog";
runtimeSpentTalentPointsInTree,
runtimeUnspentCoaEssence,
runtimeUnspentTalentPoints,
} from "../game/talentRuntimeCatalog";
import { loadTalentUiCatalog, talentUiNodesForTree } from "../game/talentUiCatalog";
import { TalentsPanel } from "./TalentsPanel";
function treeSigil(name: string): string {
@@ -20,19 +18,20 @@ export default function TalentGameplayPanel() {
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
const classDefinition = classById(classId);
const talentTrees = useMemo(() => talentTreesForClass(classId).map((tree) => ({
const catalog = use(loadTalentUiCatalog(classId));
const talentTrees = useMemo(() => catalog.trees.map((tree) => ({
id: tree.id,
name: tree.name,
description: tree.description,
points: spentTalentPointsInTree(talentRanks, tree.id),
points: runtimeSpentTalentPointsInTree(talentRanks, tree.id),
color: classDefinition.color,
sigil: treeSigil(tree.name),
iconUrl: tree.icon,
backgroundUrl: tree.background,
nodes: talentNodesForTree(tree.id),
})), [classDefinition.color, classId, talentRanks]);
const coaEssence = isCoaClassId(classId)
? unspentCoaEssence(level, talentRanks, classId)
nodes: talentUiNodesForTree(catalog, tree.id),
})), [catalog, classDefinition.color, talentRanks]);
const coaEssence = classDefinition.mode === "conquest"
? runtimeUnspentCoaEssence(level, talentRanks, classId as CoaClassId)
: null;
return <TalentsPanel
@@ -40,11 +39,10 @@ export default function TalentGameplayPanel() {
ranks={talentRanks}
classId={classId}
playerLevel={level}
availablePoints={unspentTalentPoints(level, talentRanks, classId)}
availablePoints={runtimeUnspentTalentPoints(level, talentRanks, classId)}
abilityEssence={coaEssence?.ability}
talentEssence={coaEssence?.talent}
onInvest={(nodeId) => { useCombatStore.getState().allocateTalent(nodeId); }}
onReset={() => useCombatStore.getState().resetTalents()}
/>;
}
+18 -16
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import type { ClassId } from "../app/characterCatalog";
import { runtimeTalentAllocationFailure } from "../game/talentRuntimeCatalog";
import {
talentDescriptionForRank,
talentAllocationFailure,
type TalentAllocationFailure,
type TalentNodeDefinition,
type TalentRanks,
talentUiDescriptionForRank,
type TalentUiNode,
} from "../game/talentUiCatalog";
import type {
TalentAllocationFailure,
TalentRanks,
} from "../game/talentCatalog";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction, type MenuDirection } from "../input/useMenuController";
@@ -20,7 +22,7 @@ export interface TalentTreeView {
sigil: string;
iconUrl?: string;
backgroundUrl: string;
nodes: readonly TalentNodeDefinition[];
nodes: readonly TalentUiNode[];
}
export interface TalentsPanelProps {
@@ -53,10 +55,10 @@ function TalentImage({ src, fallback }: { src?: string; fallback: string }) {
}
function nearestNode(
nodes: readonly TalentNodeDefinition[],
source: TalentNodeDefinition,
nodes: readonly TalentUiNode[],
source: TalentUiNode,
direction: MenuDirection,
): TalentNodeDefinition | null {
): TalentUiNode | null {
const vertical = direction === "up" || direction === "down";
const candidates = nodes.filter((candidate) => {
if (direction === "up") return candidate.row < source.row;
@@ -77,9 +79,9 @@ function nearestNode(
function failureText(
failure: TalentAllocationFailure | null,
node: TalentNodeDefinition,
node: TalentUiNode,
tree: TalentTreeView,
prerequisite: TalentNodeDefinition | null,
prerequisite: TalentUiNode | null,
): string {
if (!failure) return "Ready to learn";
if (failure === "max-rank") return "Maximum rank learned";
@@ -125,7 +127,7 @@ function TalentsDialog({
const nodeStateById = useMemo(() => new Map(allNodes.map((node) => {
const rank = Math.max(0, Math.min(node.maxRank, Math.trunc(ranks[node.id] ?? 0)));
const tree = treeByNodeId.get(node.id);
const rawFailure = talentAllocationFailure(ranks, classId, node.id, playerLevel);
const rawFailure = runtimeTalentAllocationFailure(ranks, classId, node.id, playerLevel);
const failure: TalentAllocationFailure | null = rank >= node.maxRank
? "max-rank"
: node.system !== "coa" && tree && tree.points < (node.row - 1) * 5
@@ -215,7 +217,7 @@ function TalentsDialog({
setDetailNodeId(nodeId);
};
const activateNode = (treeId: string, node: TalentNodeDefinition) => {
const activateNode = (treeId: string, node: TalentUiNode) => {
inspectNode(treeId, node.id);
if (!nodeStateById.get(node.id)?.failure) onInvest(node.id);
};
@@ -333,7 +335,7 @@ function TalentsDialog({
aria-label={label}
aria-disabled={state.failure !== null}
aria-describedby={detailNode?.id === node.id ? "talent-node-details" : undefined}
title={`${label}${talentDescriptionForRank(node, state.rank || 1)}`}
title={`${label}${talentUiDescriptionForRank(node, state.rank || 1)}`}
onFocus={() => inspectNode(tree.id, node.id)}
onPointerEnter={() => inspectNode(tree.id, node.id)}
onClick={() => activateNode(tree.id, node)}
@@ -368,11 +370,11 @@ function TalentsDialog({
<small className="talent-details__effect-rank">
{detailState.rank > 0 ? `Rank ${detailState.rank} effect` : "Rank 1 effect"}
</small>
<p>{talentDescriptionForRank(detailNode, detailState.rank || 1)}</p>
<p>{talentUiDescriptionForRank(detailNode, detailState.rank || 1)}</p>
{detailState.rank > 0 && detailState.rank < detailNode.maxRank && (
<>
<small className="talent-details__effect-rank">Next rank</small>
<p>{talentDescriptionForRank(detailNode, detailState.rank + 1)}</p>
<p>{talentUiDescriptionForRank(detailNode, detailState.rank + 1)}</p>
</>
)}
{(detailNode.row > 1 || prerequisite || detailNode.system === "coa") && (