Release Healer Man 0.1.4

This commit is contained in:
phenom
2026-08-18 12:06:04 -04:00
parent 5bb170039a
commit f4c9cf356c
63 changed files with 2694 additions and 81 deletions
+93
View File
@@ -4,6 +4,99 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-08-17 authentic combat VFX and audio - COMPLETE
- Player, party, and enemy combat actions now publish transient presentation
phases without changing combat resolution. The scene consumes those events
through bounded pooled effects (High: 64 systems / 8,192 particles; Low: 24
systems / 2,048 particles with no effect lights), while Off skips visual
spawning. Actor-group switches clear live systems and voices immediately.
- The generated source catalog covers 6,383 runtime spell/skill IDs: 5,144
resolve to preserved WoW/Ascension/RuneWaker descriptors and the remaining
1,239 are explicit school/delivery fallbacks. All 38 referenced source sound
paths resolve to 32 deduplicated packaged clips (6,365,096 bytes); the audit
reports no missing packaged assets.
- Owner settings migrated to local profile database v3 and remain independent
of character gameplay settings. Options exposed High/Low/Off, independent
Player/Party/Enemy visual switches, and independent audio mute/volume rows.
The existing master volume also controls combat and Manastorm cues.
- Live Wailing Caverns verification entered through the offline roster as a
Human Priest healer. The entrance fight visibly presented party melee/ranged
effects and enemy actions. A High-quality Smite capture showed the player
cast system alongside party effects; Low-quality Lesser Heal showed the
reduced healing system; Off-quality Shadow Word: Pain showed combat state,
aura/combat text, and damage continuing without spawned VFX. Defaults were
restored to High after the pass.
- At a 390 x 844 mobile viewport, the Options dialog measured 362 px wide with
345 px of scroll content and no document overflow. Player frame, party list,
minimap, and action bar all remained inside the viewport. A discovered narrow
Options text/control overflow was fixed by allowing grid labels to shrink and
moving combat-audio controls onto a full-width narrow-screen row.
- Verification passed: combat-effects parser/coverage audit **4/4**, focused
persistence/event/cue/UI suites **68/68**, full Vitest **118 files / 674
tests**, TypeScript, production build, and Android web build. Both builds
retained the existing informational large-chunk advisory; the browser console
had no errors and one existing third-party initialization deprecation warning.
- Evidence: `playtest-artifacts/2026-08-17-combat-presentation/desktop-high-combat.png`,
`desktop-player-smite.png`, `desktop-low-heal.png`, `desktop-off-combat.png`,
`desktop-options.png`, `mobile-combat-controls.png`, and
`mobile-high-combat.png`.
- Coverage limit: browser automation exercised Web Audio creation, group
routing, mute, volume, and authentic clip loading but cannot judge perceived
loudness or spatial balance; that final acoustic judgment still requires a
listening pass on physical desktop/mobile speakers.
## 2026-08-17 dungeon proximity aggro and range overlay - COMPLETE
- Idle dungeon enemies now check the living player and party positions during
the low-frequency combat tick. Crossing a mob's radius engages its complete
linked pack through the normal threat path, anchors each member at its pull
position, and seeds initial threat on the nearest actor who crossed the line.
- Normal enemies default to a 16-unit aggro radius and bosses to 20 units;
creature definitions can override either value. Proximity uses full 3D
distance so vertically overlapping dungeon floors do not pull one another,
while the existing planar combat leash continues to support uneven terrain.
- Options now includes `Mob aggro ranges`, defaulting to Off and persisting with
the rest of the account gameplay settings. When enabled, idle dungeon mobs
render restrained orange world-space boundaries above uneven terrain. Rings
disappear once their mob engages and are never rendered in Manastorm.
- Focused verification passed: `src/game/combatStore.test.ts` and
`src/game/mobAi.test.ts`, **2 files / 60 tests**. `npx tsc -b --pretty false`
passed, and `npx vite build` passed with 960 transformed modules.
- Live browser verification entered Wailing Caverns as a healer through the
offline roster. With the party left in Defend and no player attack issued,
the entrance pack automatically acquired and damaged Garrick. The Options
dialog showed the new setting Off by default; matched scene captures then
confirmed visible range rings when On and their removal when toggled Off.
- The browser console contained no errors and one existing third-party
initialization deprecation warning. The local Vite host separately recorded
the known optional `/content/manifest.json` proxy refusal because the content
server on port 4173 was not running; packaged dungeon play was unaffected.
## 2026-08-17 Kresh cliff-pull aggro regression - COMPLETE
- Reproduced the reported reset with a deterministic combat-store regression:
Kresh registers at one end of his long patrol, moves to a later patrol point,
takes damage while the player is falling well above him, and advances combat
after the player reaches the lower grotto.
- Roaming mobs now anchor their leash at their runtime position when a new pull
starts instead of retaining the position where their model first mounted.
Aggro leash eligibility uses planar dungeon distance, matching the existing
mob-AI rule, while attack reach still includes vertical separation. A real
horizontal leash break continues to evade and restore the encounter.
- Focused verification passed: `src/game/combatStore.test.ts` and
`src/game/mobAi.test.ts`, **2 files / 58 tests**. `npm run build` also passed,
including TypeScript, 956 transformed Vite modules, and the KTX2 audit of
11,839 compressed textures with zero fallbacks.
- Production-preview smoke testing entered Wailing Caverns through the offline
roster, rendered the full dungeon/HUD/party view, issued the Attack order,
and observed live enemy damage on the tank. This checkout lacks the optional
local playable-character and creature model packs, so the documented
procedural fallbacks and their asset warnings were visible during this pass.
- Coverage limit: the exact cliff jump was validated by the deterministic
combat-state regression rather than by manually traversing from the entrance
to Kresh during the browser smoke test.
## 2026-08-16 responsive jump and natural teammate movement - COMPLETE
- Added the grounded single-jump path for Space and controller L3, including a
+5 -5
View File
@@ -607,12 +607,12 @@ An already-installed Android APK cannot be rolled back in place to a lower
still `com.phenomrom.healerman`, use the same signing key, and increase
`versionCode`.
- **Gitea APK upload returns an HTTP size error:** increase the Gitea/reverse
proxy release-upload limit or use Gitea's direct LAN web endpoint with the
script's `-GiteaBaseUrl` option. Do not assume `192.168.1.180:8080` is Gitea;
verify the actual Gitea web port first.
proxy release-upload limit or use Gitea's direct LAN web endpoint. The
publisher defaults to `http://192.168.1.180:30008`; port `8080` is not Gitea.
- **Release creation succeeds but attachment upload fails:** do not repeatedly
create the same tag. Remove the incomplete release/tag in Gitea or upload the
attachments to that release, then retry only after its state is understood.
create the same tag. Use **Publish Current APK Only**. The publisher resumes
the existing release, skips correctly sized attachments, and retries missing
uploads through the direct LAN API.
## Appendix: known-good TrueNAS YAML
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+6 -3
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -12,6 +12,9 @@
"content:publish": "node scripts/content/publish-content.mjs",
"android:prepare": "node scripts/content/prepare-android-bootstrap.mjs",
"content:test": "node --test scripts/content/*.test.mjs",
"effects:generate": "node scripts/combat-effects/generate-manifest.mjs --package-audio",
"effects:audit": "node scripts/combat-effects/generate-manifest.mjs --package-audio --audit",
"effects:test": "node --test scripts/combat-effects/*.test.mjs",
"wow335:abilities": "node scripts/generate-wow335-abilities.mjs",
"coa:sync": "node scripts/sync-ascension-coa.mjs",
"coa:sync:live": "node scripts/sync-ascension-coa.mjs --tooltips",
@@ -89,8 +92,8 @@
"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",
"build": "tsc -b && vite build",
"build:android:web": "npm run prebuild && npm run android:prepare && tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
"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",
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.3",
"versionCode": 1003
"versionName": "0.1.4",
"versionCode": 1004
}
@@ -0,0 +1,562 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { MpqArchive } = require("stormlib-js");
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
export function parseDbc(buffer, name = "DBC") {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error(`${name} is not WDBC data.`);
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
const recordsEnd = 20 + recordCount * recordSize;
if (recordSize < fieldCount * 4 || recordsEnd + stringSize > buffer.length) {
throw new Error(`${name} has an unsupported or truncated schema.`);
}
const stringAt = (offset) => {
if (!offset || offset >= stringSize) return "";
const start = recordsEnd + offset;
const end = buffer.indexOf(0, start);
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
};
const row = (index) => {
const base = 20 + index * recordSize;
return {
uint: (field) => field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0,
int: (field) => field < fieldCount ? buffer.readInt32LE(base + field * 4) : 0,
float: (field) => field < fieldCount ? buffer.readFloatLE(base + field * 4) : 0,
string: (field) => stringAt(field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0),
};
};
return { name, buffer, recordCount, fieldCount, recordSize, stringSize, row };
}
function decodeM2TrackHeader(buffer, offset) {
if (offset < 0 || offset + 20 > buffer.length) return null;
return {
interpolation: buffer.readUInt16LE(offset),
globalSequence: buffer.readInt16LE(offset + 2),
timeCount: buffer.readUInt32LE(offset + 4),
timeOffset: buffer.readUInt32LE(offset + 8),
keyCount: buffer.readUInt32LE(offset + 12),
keyOffset: buffer.readUInt32LE(offset + 16),
};
}
/** Decode the stable WotLK 3.x particle/ribbon fields used by the browser fallback converter. */
export function decodeWotlkM2Effects(buffer, name = "M2") {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
const magic = buffer.toString("ascii", 0, 4);
if ((magic !== "MD20" && magic !== "MD21") || buffer.length < 304) {
throw new Error(`${name} is not a supported WotLK M2.`);
}
const ribbonCount = buffer.readUInt32LE(288);
const ribbonOffset = buffer.readUInt32LE(292);
const particleCount = buffer.readUInt32LE(296);
const particleOffset = buffer.readUInt32LE(300);
const particleRecordSize = 476;
const ribbonRecordSize = 176;
if (particleCount && particleOffset + particleCount * particleRecordSize > buffer.length) {
throw new Error(`${name} has truncated particle records.`);
}
if (ribbonCount && ribbonOffset + ribbonCount * ribbonRecordSize > buffer.length) {
throw new Error(`${name} has truncated ribbon records.`);
}
const trackOffsets = {
emissionSpeed: 56,
speedVariation: 76,
verticalRange: 96,
horizontalRange: 116,
gravity: 136,
lifespan: 156,
emissionRate: 180,
emissionAreaLength: 200,
emissionAreaWidth: 220,
zSource: 240,
color: 260,
alpha: 280,
scale: 300,
};
const particles = Array.from({ length: particleCount }, (_, index) => {
const offset = particleOffset + index * particleRecordSize;
return {
id: buffer.readUInt32LE(offset),
flags: buffer.readUInt32LE(offset + 4),
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
parentBone: buffer.readUInt16LE(offset + 20),
textureIndex: buffer.readUInt16LE(offset + 22),
blendMode: buffer.readUInt8(offset + 40),
emitterType: buffer.readUInt8(offset + 41),
particleType: buffer.readUInt8(offset + 44),
headOrTail: buffer.readUInt8(offset + 45),
textureRows: buffer.readUInt16LE(offset + 48),
textureColumns: buffer.readUInt16LE(offset + 50),
tracks: Object.fromEntries(Object.entries(trackOffsets)
.map(([track, relativeOffset]) => [track, decodeM2TrackHeader(buffer, offset + relativeOffset)])),
};
});
const ribbons = Array.from({ length: ribbonCount }, (_, index) => {
const offset = ribbonOffset + index * ribbonRecordSize;
return {
id: buffer.readUInt32LE(offset),
parentBone: buffer.readUInt32LE(offset + 4),
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
textureCount: buffer.readUInt32LE(offset + 20),
textureOffset: buffer.readUInt32LE(offset + 24),
materialCount: buffer.readUInt32LE(offset + 28),
materialOffset: buffer.readUInt32LE(offset + 32),
colorTrack: decodeM2TrackHeader(buffer, offset + 36),
};
});
return { magic, particles, ribbons };
}
function tableById(table, decode) {
const result = new Map();
for (let index = 0; index < table.recordCount; index += 1) {
const row = table.row(index);
result.set(row.uint(0), decode(row));
}
return result;
}
function collectAbilitySpellIds() {
const requested = new Map();
const add = (source, id) => {
if (!Number.isInteger(id) || id <= 0) return;
requested.set(`${source}:${id}`, { source, spellId: id });
};
const abilityCatalog = fs.readFileSync(path.join(projectRoot, "src", "game", "abilityCatalog.ts"), "utf8");
for (const match of abilityCatalog.matchAll(/dbcSpellId:\s*(\d+)/g)) add("wow335", Number(match[1]));
const wow = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "wow335AbilityData.generated.json"), "utf8"));
for (const chains of Object.values(wow.classes ?? {})) {
for (const chain of chains) for (const rank of chain.ranks ?? []) add("wow335", Number(rank.spellId));
}
const coa = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json"), "utf8"));
for (const abilities of Object.values(coa.abilitiesByClass ?? {})) {
for (const ability of abilities) {
add("ascension", Number(ability.dbcSpellId));
for (const rank of ability.ranks ?? []) add("ascension", Number(rank.spellId));
}
}
const rom = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json"), "utf8"));
const runewakerImageBySpellId = new Map();
for (const skill of rom.skills ?? []) {
add("runewaker", Number(skill.sourceSkillId));
if (Number(skill.source?.imageId) > 0) runewakerImageBySpellId.set(Number(skill.sourceSkillId), Number(skill.source.imageId));
for (const rank of skill.ranks ?? []) {
add("runewaker", Number(rank.sourceSkillId));
const imageId = Number(rank.source?.imageId ?? skill.source?.imageId);
if (imageId > 0) runewakerImageBySpellId.set(Number(rank.sourceSkillId), imageId);
}
}
const generatedRoot = path.join(projectRoot, "src", "game", "generated");
for (const name of fs.readdirSync(generatedRoot).filter((entry) => entry.endsWith("Population.generated.ts"))) {
const source = fs.readFileSync(path.join(generatedRoot, name), "utf8");
for (const match of source.matchAll(/"spellId":\s*(\d+)/g)) {
const spellId = Number(match[1]);
add(spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "ascension", spellId);
}
}
return { requested: [...requested.values()], runewakerImageBySpellId };
}
function soundEntry(table, id) {
if (!id) return [];
for (let index = 0; index < table.recordCount; index += 1) {
const row = table.row(index);
if (row.uint(0) !== id) continue;
const directory = row.string(23).replaceAll("\\", "/").replace(/\/$/, "");
const volume = Number(row.float(24).toFixed(4));
const minimumDistance = Number(row.float(26).toFixed(3));
const maximumDistance = Number(row.float(27).toFixed(3));
return Array.from({ length: 10 }, (_, fileIndex) => {
const file = row.string(3 + fileIndex).replaceAll("\\", "/");
if (!file) return null;
return {
sourcePath: directory ? `${directory}/${file}` : file,
weight: Math.max(1, row.uint(13 + fileIndex)),
volume: volume > 0 ? volume : 1,
...(minimumDistance > 0 ? { minimumDistance } : {}),
...(maximumDistance > 0 ? { maximumDistance } : {}),
};
}).filter(Boolean);
}
return [];
}
function buildWowEntries(source, requestedIds, tables) {
const spell = parseDbc(tables.Spell, `${source} Spell.dbc`);
const visual = tableById(parseDbc(tables.SpellVisual, `${source} SpellVisual.dbc`), (row) => ({
precast: row.uint(1), cast: row.uint(2), impact: row.uint(3), state: row.uint(4), stateDone: row.uint(5),
channel: row.uint(6), missileModel: row.uint(8), missileSound: row.uint(11), animationSound: row.uint(12),
casterImpact: row.uint(14), targetImpact: row.uint(15), instantArea: row.uint(23), impactArea: row.uint(24), persistentArea: row.uint(25),
}));
const kitsTable = parseDbc(tables.SpellVisualKit, `${source} SpellVisualKit.dbc`);
const kits = tableById(kitsTable, (row) => ({
effects: Array.from({ length: 13 }, (_, offset) => row.uint(3 + offset)).filter(Boolean),
soundId: row.uint(16),
}));
const effectNames = tableById(parseDbc(tables.SpellVisualEffectName, `${source} SpellVisualEffectName.dbc`), (row) => ({
name: row.string(1),
model: row.string(2).replaceAll("\\", "/"),
scale: row.float(4) || 1,
}));
const attachments = new Map();
const attachTable = parseDbc(tables.SpellVisualKitModelAttach, `${source} SpellVisualKitModelAttach.dbc`);
for (let index = 0; index < attachTable.recordCount; index += 1) {
const row = attachTable.row(index);
const records = attachments.get(row.uint(1)) ?? [];
records.push({ effectId: row.uint(2), attachment: `attachment:${row.uint(3)}` });
attachments.set(row.uint(1), records);
}
const soundTable = parseDbc(tables.SoundEntries, `${source} SoundEntries.dbc`);
const kitAssets = (kitId) => {
const kit = kits.get(kitId);
if (!kit) return [];
const attached = new Map((attachments.get(kitId) ?? []).map((entry) => [entry.effectId, entry.attachment]));
return [...new Set([...kit.effects, ...(attachments.get(kitId) ?? []).map((entry) => entry.effectId)])]
.map((effectId) => {
const effect = effectNames.get(effectId);
if (!effect?.model) return null;
return {
model: effect.model,
...(attached.get(effectId) ? { attachment: attached.get(effectId) } : {}),
...(effect.scale !== 1 ? { scale: Number(effect.scale.toFixed(4)) } : {}),
};
}).filter(Boolean);
};
const soundsForKit = (kitId) => soundEntry(soundTable, kits.get(kitId)?.soundId ?? 0);
const spellRows = new Map();
const shift = spell.fieldCount >= 239 ? 6 : 0;
const shifted = (field) => field >= 77 ? field + shift : field;
const wanted = new Set(requestedIds);
for (let index = 0; index < spell.recordCount; index += 1) {
const row = spell.row(index);
const id = row.uint(0);
if (wanted.has(id)) spellRows.set(id, [row.uint(shifted(131)), row.uint(shifted(132))].filter(Boolean));
}
const entries = [];
for (const spellId of requestedIds) {
const visualIds = spellRows.get(spellId) ?? [];
const phases = {};
const sounds = [];
const addAssets = (phase, assets) => {
const usable = assets.filter(Boolean);
if (usable.length) phases[phase] = [...(phases[phase] ?? []), ...usable];
};
for (const visualId of visualIds) {
const row = visual.get(visualId);
if (!row) continue;
for (const kitId of [row.precast, row.cast]) addAssets("cast-start", kitAssets(kitId));
if (row.missileModel) {
const missile = effectNames.get(row.missileModel);
if (missile?.model) addAssets("release", [{ model: missile.model, scale: missile.scale }]);
}
for (const kitId of [row.impact, row.casterImpact, row.targetImpact]) addAssets("impact", kitAssets(kitId));
addAssets("aura-start", kitAssets(row.state));
addAssets("aura-end", kitAssets(row.stateDone));
addAssets("tick", kitAssets(row.channel));
for (const kitId of [row.instantArea, row.impactArea, row.persistentArea]) addAssets("impact", kitAssets(kitId));
for (const kitId of [row.precast, row.cast, row.impact, row.state, row.channel, row.instantArea, row.impactArea, row.persistentArea]) {
sounds.push(...soundsForKit(kitId));
}
sounds.push(...soundEntry(soundTable, row.missileSound), ...soundEntry(soundTable, row.animationSound));
}
for (const [phase, assets] of Object.entries(phases)) {
phases[phase] = [...new Map(assets.map((asset) => [`${asset.model}:${asset.attachment ?? ""}`, asset])).values()];
}
const uniqueSounds = [...new Map(sounds.map((sound) => [sound.sourcePath, sound])).values()];
if (!visualIds.length && !uniqueSounds.length) continue;
entries.push({
key: `${source}:${spellId}`,
source,
spellId,
visualIds,
...(Object.keys(phases).length ? { phases } : {}),
...(uniqueSounds.length ? { sounds: uniqueSounds } : {}),
});
}
return entries;
}
function readNullSeparatedPaths(record) {
const values = [];
let start = 0;
for (let offset = 0; offset <= record.length; offset += 1) {
if (offset < record.length && record[offset] !== 0) continue;
if (offset - start >= 4) {
const value = record.subarray(start, offset).toString("latin1").trim();
if (/^[\x20-\x7e]+$/.test(value) && /[\\/]/.test(value)) values.push(value.replaceAll("\\", "/"));
}
start = offset + 1;
}
return values;
}
export function parseRunewakerImageRecords(buffer, requestedImageIds) {
const count = buffer.readInt32LE(132);
const recordSize = 3712;
const dataStart = 140;
if (count <= 0 || dataStart + count * recordSize > buffer.length) throw new Error("ImageObject.DB has an incompatible layout.");
const wanted = new Set(requestedImageIds);
const result = new Map();
for (let index = 0; index < count; index += 1) {
const offset = dataStart + index * recordSize;
const id = buffer.readInt32LE(offset);
if (!wanted.has(id)) continue;
result.set(id, readNullSeparatedPaths(buffer.subarray(offset, offset + recordSize)));
}
return result;
}
function runewakerImageIdsFromMagicCollect(buffer, spellIds) {
const count = buffer.readInt32LE(132);
const recordSize = 1032;
const dataStart = 140;
const wanted = new Set(spellIds);
const result = new Map();
for (let index = 0; index < count; index += 1) {
const offset = dataStart + index * recordSize;
const spellId = buffer.readInt32LE(offset);
if (wanted.has(spellId)) result.set(spellId, buffer.readInt32LE(offset + 32));
}
return result;
}
function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db"));
const collectedImages = runewakerImageIdsFromMagicCollect(magicCollect, spellIds);
for (const [spellId, imageId] of collectedImages) if (!imageBySpellId.has(spellId)) imageBySpellId.set(spellId, imageId);
const requestedImages = [...new Set(spellIds.map((id) => imageBySpellId.get(id)).filter(Boolean))];
const images = parseRunewakerImageRecords(fs.readFileSync(path.join(dataRoot, "imageobject.db")), requestedImages);
const entries = [];
for (const spellId of spellIds) {
const imageId = imageBySpellId.get(spellId);
if (!imageId) continue;
const paths = images.get(imageId) ?? [];
const phases = {};
const sounds = [];
for (const sourcePath of paths) {
if (/\.(?:wav|ogg|mp3)$/i.test(sourcePath)) {
sounds.push({ sourcePath, weight: 1, volume: 1 });
continue;
}
if (!/\.(?:ros|ras)$/i.test(sourcePath)) continue;
const lower = sourcePath.toLowerCase();
const phase = /(?:^|_)t(?:_|\.)|explode|impact|hit/.test(lower)
? "impact"
: /(?:^|_)f(?:_|\.)|fly|missile/.test(lower)
? "release"
: /buff|aura|extend/.test(lower)
? "aura-start"
: "cast-start";
(phases[phase] ??= []).push({ model: sourcePath });
}
if (!Object.keys(phases).length && !sounds.length) continue;
entries.push({
key: `runewaker:${spellId}`,
source: "runewaker",
spellId,
imageId,
phases,
...(sounds.length ? { sounds } : {}),
});
}
return entries;
}
function extractWowTables(archivePath) {
const archive = MpqArchive.open(archivePath);
try {
const extract = (name) => archive.extractFile(`DBFilesClient\\${name}.dbc`);
return Object.fromEntries([
"Spell", "SpellVisual", "SpellVisualKit", "SpellVisualEffectName", "SpellVisualKitModelAttach", "SoundEntries",
].map((name) => [name, extract(name)]));
} finally {
archive.close();
}
}
function extractedAscensionTables(spellPath, supportRoot, baseSoundEntries) {
const read = (name) => fs.readFileSync(path.join(supportRoot, `${name}.dbc`));
return {
Spell: fs.readFileSync(spellPath),
SpellVisual: read("SpellVisual"),
SpellVisualKit: read("SpellVisualKit"),
SpellVisualEffectName: read("SpellVisualEffectName"),
SpellVisualKitModelAttach: read("SpellVisualKitModelAttach"),
SoundEntries: fs.existsSync(path.join(supportRoot, "SoundEntries.dbc")) ? read("SoundEntries") : baseSoundEntries,
};
}
function openedWowAudioArchives(dataRoot) {
const names = [
"patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "Patch-B.MPQ", "patch-A.MPQ",
"lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ",
];
return names
.map((name) => path.join(dataRoot, name))
.filter((archivePath) => fs.existsSync(archivePath) && fs.statSync(archivePath).isFile())
.map((archivePath) => ({ archivePath, archive: MpqArchive.open(archivePath) }));
}
function packageAudioAssets(entries, options) {
const outputRoot = path.resolve(options.audioOutput
?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "audio"));
const publicPrefix = "/assets/game/combat-effects/audio";
const wowDataRoot = path.resolve(options.wowDataRoot
?? path.join(projectRoot, "..", "wow335a", "Data"));
const runewakerResourceRoot = path.resolve(options.runewakerResourceRoot
?? path.join(projectRoot, "..", "Runewaker", "Resource"));
const archives = openedWowAudioArchives(wowDataRoot);
const sourceCache = new Map();
const missing = [];
const packagedFiles = new Set();
fs.mkdirSync(outputRoot, { recursive: true });
const extractSource = (sourcePath, source) => {
const cacheKey = `${source}:${sourcePath.toLowerCase()}`;
if (sourceCache.has(cacheKey)) return sourceCache.get(cacheKey);
let buffer = null;
if (source === "runewaker") {
const candidate = path.join(runewakerResourceRoot, ...sourcePath.split("/"));
if (fs.existsSync(candidate)) buffer = fs.readFileSync(candidate);
} else {
const archiveName = sourcePath.replaceAll("/", "\\");
for (const { archive } of archives) {
try {
buffer = archive.extractFile(archiveName);
if (buffer?.length) break;
} catch {
// The source archive stack is sparse by design; continue to the base archives.
}
}
}
sourceCache.set(cacheKey, buffer);
return buffer;
};
try {
for (const entry of entries) {
for (const sound of entry.sounds ?? []) {
const buffer = extractSource(sound.sourcePath, entry.source);
if (!buffer?.length) {
missing.push(`${entry.source}:${sound.sourcePath}`);
continue;
}
const extension = path.extname(sound.sourcePath).toLowerCase() || ".wav";
const fileName = `${sha256(buffer).slice(0, 20)}${extension}`;
const outputPath = path.join(outputRoot, fileName);
if (!fs.existsSync(outputPath)) fs.writeFileSync(outputPath, buffer);
packagedFiles.add(fileName);
sound.url = `${publicPrefix}/${fileName}`;
}
}
} finally {
for (const { archive } of archives) archive.close();
}
return {
referenced: sourceCache.size,
packaged: packagedFiles.size,
missing: [...new Set(missing)].sort(),
};
}
export function generateCombatEffectManifest(options = {}) {
const wowArchive = path.resolve(options.wowArchive ?? path.join(projectRoot, "..", "wow335a", "Data", "enUS", "patch-enUS.MPQ"));
const ascensionRoot = path.resolve(options.ascensionRoot ?? path.join(projectRoot, "..", "LadiksMPQEditor", "client-current"));
const ascensionSpell = path.resolve(options.ascensionSpell ?? path.join(ascensionRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"));
const ascensionSupport = path.resolve(options.ascensionSupport ?? path.join(ascensionRoot, "patch-S", "DBFilesClient"));
const runewakerData = path.resolve(options.runewakerData ?? path.join(projectRoot, "..", "Runewaker", "Resource", "data"));
const { requested, runewakerImageBySpellId } = collectAbilitySpellIds();
const requestedBySource = requested.reduce((groups, entry) => {
(groups[entry.source] ??= []).push(entry);
return groups;
}, {});
const wowTables = extractWowTables(wowArchive);
const wowEntries = buildWowEntries("wow335", (requestedBySource.wow335 ?? []).map((entry) => entry.spellId), wowTables);
const ascensionTables = extractedAscensionTables(ascensionSpell, ascensionSupport, wowTables.SoundEntries);
const ascensionEntries = buildWowEntries("ascension", (requestedBySource.ascension ?? []).map((entry) => entry.spellId), ascensionTables);
const runewakerEntries = buildRunewakerEntries(
(requestedBySource.runewaker ?? []).map((entry) => entry.spellId),
runewakerImageBySpellId,
runewakerData,
);
const entries = [...wowEntries, ...ascensionEntries, ...runewakerEntries]
.sort((left, right) => left.source.localeCompare(right.source) || left.spellId - right.spellId);
const audio = options.packageAudio
? packageAudioAssets(entries, { ...options, runewakerResourceRoot: path.dirname(runewakerData) })
: { referenced: new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => `${entry.source}:${sound.sourcePath}`))).size, packaged: 0, missing: [] };
const mapped = new Set(entries.map((entry) => entry.key));
const fallbackKeys = requested.map((entry) => `${entry.source}:${entry.spellId}`).filter((key) => !mapped.has(key));
return {
schemaVersion: 1,
generatedAt: null,
sources: [
{ kind: "wow335", path: path.relative(projectRoot, wowArchive).replaceAll("\\", "/"), sha256: sha256(wowTables.Spell) },
{ kind: "ascension", path: path.relative(projectRoot, ascensionSpell).replaceAll("\\", "/"), sha256: sha256(ascensionTables.Spell) },
{ kind: "runewaker", path: path.relative(projectRoot, runewakerData).replaceAll("\\", "/"), sha256: sha256(fs.readFileSync(path.join(runewakerData, "imageobject.db"))) },
],
coverage: {
requested: requested.length,
authentic: entries.length,
fallback: fallbackKeys.length,
fallbackKeys,
audio,
unsupportedConstructs: [
"WotLK event-driven child emitters use source-styled pooled fallbacks",
"RuneWaker native shader programs outside decoded blend/billboard descriptors use source-styled pooled fallbacks",
],
},
entries,
};
}
function parseArguments(arguments_) {
const options = {};
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--audit") options.audit = true;
else if (argument === "--package-audio") options.packageAudio = true;
else if (argument === "--output") options.output = arguments_[++index];
else if (argument === "--wow-archive") options.wowArchive = arguments_[++index];
else if (argument === "--ascension-root") options.ascensionRoot = arguments_[++index];
else if (argument === "--runewaker-data") options.runewakerData = arguments_[++index];
else throw new Error(`Unknown argument: ${argument}`);
}
return options;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const options = parseArguments(process.argv.slice(2));
const output = path.resolve(options.output ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json"));
const manifest = generateCombatEffectManifest(options);
if (options.audit && manifest.coverage.audio.missing.length) {
throw new Error(`Combat effects audit failed: ${manifest.coverage.audio.missing.length} referenced audio files could not be packaged.`);
}
if (!options.audit) {
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(manifest)}\n`);
}
console.log(`Combat effects: ${manifest.coverage.authentic}/${manifest.coverage.requested} source mappings; ${manifest.coverage.fallback} intentional fallbacks.`);
if (!options.audit) console.log(`Wrote ${output}`);
}
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decodeWotlkM2Effects, parseDbc, parseRunewakerImageRecords } from "./generate-manifest.mjs";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
function tinyDbc() {
const strings = Buffer.from("\0hello\0", "utf8");
const buffer = Buffer.alloc(20 + 8 + strings.length);
buffer.write("WDBC", 0, "ascii");
buffer.writeUInt32LE(1, 4);
buffer.writeUInt32LE(2, 8);
buffer.writeUInt32LE(8, 12);
buffer.writeUInt32LE(strings.length, 16);
buffer.writeUInt32LE(42, 20);
buffer.writeUInt32LE(1, 24);
strings.copy(buffer, 28);
return buffer;
}
test("parses numeric and string DBC fields", () => {
const table = parseDbc(tinyDbc(), "fixture");
assert.equal(table.row(0).uint(0), 42);
assert.equal(table.row(0).string(1), "hello");
});
test("classifies paths from fixed RuneWaker ImageObject records", () => {
const buffer = Buffer.alloc(140 + 3712);
buffer.writeInt32LE(1, 132);
buffer.writeInt32LE(573727, 140);
Buffer.from("model\\fx\\skill\\fire\\act_fireball01_f_i.ros\0", "ascii").copy(buffer, 140 + 300);
const records = parseRunewakerImageRecords(buffer, [573727]);
assert.deepEqual(records.get(573727), ["model/fx/skill/fire/act_fireball01_f_i.ros"]);
});
test("decodes supported WotLK M2 particle and ribbon records", () => {
const particleOffset = 304;
const buffer = Buffer.alloc(particleOffset + 476);
buffer.write("MD20", 0, "ascii");
buffer.writeUInt32LE(1, 296);
buffer.writeUInt32LE(particleOffset, 300);
buffer.writeUInt32LE(17, particleOffset);
buffer.writeUInt16LE(4, particleOffset + 20);
buffer.writeUInt16LE(9, particleOffset + 22);
buffer.writeUInt8(2, particleOffset + 40);
buffer.writeUInt8(1, particleOffset + 41);
buffer.writeUInt16LE(4, particleOffset + 48);
buffer.writeUInt16LE(8, particleOffset + 50);
buffer.writeUInt16LE(1, particleOffset + 56);
buffer.writeUInt32LE(3, particleOffset + 56 + 12);
const decoded = decodeWotlkM2Effects(buffer, "fixture");
assert.equal(decoded.particles[0].id, 17);
assert.equal(decoded.particles[0].textureIndex, 9);
assert.equal(decoded.particles[0].blendMode, 2);
assert.equal(decoded.particles[0].textureColumns, 8);
assert.equal(decoded.particles[0].tracks.emissionSpeed.interpolation, 1);
assert.equal(decoded.particles[0].tracks.emissionSpeed.keyCount, 3);
});
test("generated coverage packages every referenced source sound", () => {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const manifest = JSON.parse(fs.readFileSync(path.join(root, "public", "assets", "game", "combat-effects", "manifest.json"), "utf8"));
assert.equal(manifest.coverage.requested, manifest.coverage.authentic + manifest.coverage.fallback);
assert.deepEqual(manifest.coverage.audio.missing, []);
const sounds = manifest.entries.flatMap((entry) => entry.sounds ?? []);
assert.ok(sounds.length > 0);
for (const sound of sounds) {
assert.ok(sound.url?.startsWith("/assets/game/combat-effects/audio/"));
assert.ok(fs.existsSync(path.join(root, "public", sound.url)));
}
});
+63 -15
View File
@@ -8,7 +8,8 @@ param(
[int]$VersionCode,
[string]$ReleaseNotes = 'Healer Man Android update.',
[string]$GiteaBaseUrl = 'https://git.whoagland.com',
[string]$GiteaBaseUrl = 'http://192.168.1.180:30008',
[string]$GiteaPublicBaseUrl = 'https://git.whoagland.com',
[string]$Owner = 'phenom',
[string]$Repository = 'healer-man'
)
@@ -67,7 +68,7 @@ try {
[IO.File]::WriteAllText($checksumPath, "$checksum $apkName`n", [Text.UTF8Encoding]::new($false))
$apiRoot = $GiteaBaseUrl.TrimEnd('/') + '/api/v1'
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
$headers = @{ Authorization = "token $($env:GITEA_TOKEN.Trim())" }
$tag = "v$VersionName"
$releaseBody = @{
tag_name = $tag
@@ -77,26 +78,73 @@ try {
draft = $false
prerelease = $false
} | ConvertTo-Json
$release = Invoke-RestMethod `
-Method Post `
-Uri "$apiRoot/repos/$Owner/$Repository/releases" `
-Headers $headers `
-ContentType 'application/json' `
-Body $releaseBody
$encodedTag = [Uri]::EscapeDataString($tag)
try {
$release = Invoke-RestMethod `
-Method Get `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/tags/$encodedTag" `
-Headers $headers
Write-Host "Resuming existing Gitea release $tag."
} catch {
$statusCode = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
if ($statusCode -ne 404) { throw }
$release = Invoke-RestMethod `
-Method Post `
-Uri "$apiRoot/repos/$Owner/$Repository/releases" `
-Headers $headers `
-ContentType 'application/json' `
-Body $releaseBody
}
foreach ($attachment in @($apkPath, $checksumPath)) {
$attachmentName = Split-Path -Leaf $attachment
$attachmentSize = (Get-Item -LiteralPath $attachment).Length
$encodedName = [Uri]::EscapeDataString($attachmentName)
Invoke-RestMethod `
-Method Post `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)/assets?name=$encodedName" `
-Headers $headers `
-ContentType 'application/octet-stream' `
-InFile $attachment | Out-Null
$release = Invoke-RestMethod `
-Method Get `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)" `
-Headers $headers
$existingAsset = @($release.assets | Where-Object { $_.name -eq $attachmentName }) |
Select-Object -First 1
if ($existingAsset) {
if ([long]$existingAsset.size -ne $attachmentSize) {
throw "Release asset $attachmentName exists with the wrong size ($($existingAsset.size), expected $attachmentSize)."
}
Write-Host "Release asset $attachmentName already exists with the expected size; skipping upload."
continue
}
$uploaded = $false
for ($attempt = 1; $attempt -le 3 -and -not $uploaded; $attempt++) {
try {
Invoke-RestMethod `
-Method Post `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)/assets?name=$encodedName" `
-Headers $headers `
-ContentType 'application/octet-stream' `
-InFile $attachment | Out-Null
$uploaded = $true
} catch {
$refreshedRelease = Invoke-RestMethod `
-Method Get `
-Uri "$apiRoot/repos/$Owner/$Repository/releases/$($release.id)" `
-Headers $headers
$uploadedAsset = @($refreshedRelease.assets | Where-Object {
$_.name -eq $attachmentName -and [long]$_.size -eq $attachmentSize
}) | Select-Object -First 1
if ($uploadedAsset) {
$uploaded = $true
continue
}
if ($attempt -eq 3) { throw }
Write-Warning "Upload attempt $attempt for $attachmentName failed; retrying through the LAN API."
Start-Sleep -Seconds ([math]::Pow(2, $attempt))
}
}
}
Write-Host "Published $tag with $apkName and its SHA-256 checksum."
Write-Host "$GiteaBaseUrl/$Owner/$Repository/releases/tag/$tag"
Write-Host "$($GiteaPublicBaseUrl.TrimEnd('/'))/$Owner/$Repository/releases/tag/$tag"
} finally {
Pop-Location
}
+2 -2
View File
@@ -319,7 +319,7 @@ def read_saved_signing_password() -> str:
def read_saved_gitea_token() -> str:
return read_windows_encrypted_secret(SAVED_GITEA_TOKEN_FILE, "Gitea token")
return read_windows_encrypted_secret(SAVED_GITEA_TOKEN_FILE, "Gitea token").strip()
def android_toolchain_status() -> tuple[bool, str]:
@@ -843,7 +843,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
"ANDROID_KEY_ALIAS": self.alias_var.get().strip(),
"ANDROID_KEYSTORE_PASSWORD": self.keystore_password_var.get(),
"ANDROID_KEY_PASSWORD": self.key_password_var.get(),
"GITEA_TOKEN": self.token_var.get(),
"GITEA_TOKEN": self.token_var.get().strip(),
}
for key, value in list(fields.items()):
if not value:
+33
View File
@@ -5,6 +5,7 @@ import {
createLocalAccount,
createOfflineSession,
listCharacters,
loadCombatPresentationSettings,
loginLocalAccount,
updateCharacterActionBindings,
updateCharacterEquipment,
@@ -12,9 +13,11 @@ import {
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterSettings,
updateCombatPresentationSettings,
type StorageLike,
} from "./accountRepository";
import { DEFAULT_GAMEPLAY_SETTINGS } from "../game/combatStore";
import { DEFAULT_COMBAT_PRESENTATION_SETTINGS } from "../game/combatPresentation";
import { EMPTY_ITEM_STATS } from "../game/itemStats";
import {
MANASTORM_PROGRESS_VERSION,
@@ -124,6 +127,36 @@ describe("local profile repository", () => {
});
});
it("migrates v1/v2 databases and stores combat presentation settings by owner", async () => {
for (const version of [1, 2]) {
const storage = new MemoryStorage();
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
version,
accounts: [],
rosters: { "offline-roster": [] },
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
}
const storage = new MemoryStorage();
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId;
const customized = updateCombatPresentationSettings(firstOwner, {
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
vfxQuality: "low",
vfxEnabled: { player: true, party: false, enemy: true },
audioEnabled: { player: true, party: true, enemy: false },
audioVolume: { player: 0.75, party: 0.4, enemy: 0.2 },
}, storage);
expect(loadCombatPresentationSettings(firstOwner, storage)).toEqual(customized);
expect(loadCombatPresentationSettings(secondOwner, storage)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(loadCombatPresentationSettings(createOfflineSession().ownerId, storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
});
it("migrates custom-class profiles into the CoA category", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
+45 -5
View File
@@ -12,6 +12,11 @@ import {
import { normalizeInventory, type InventoryItem } from "../game/lootTypes";
import { normalizeEquipment, type EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import {
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
normalizeCombatPresentationSettings,
type CombatPresentationSettings,
} from "../game/combatPresentation";
import {
createEmptyManastormProgress,
normalizeManastormProgress,
@@ -46,9 +51,10 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 2;
version: 3;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
}
export interface AccountResult {
@@ -87,7 +93,7 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 2, accounts: [], rosters: {} };
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -178,8 +184,9 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
version?: number;
accounts?: unknown;
rosters?: unknown;
presentationSettingsByOwner?: unknown;
}) | null;
if (!parsed || (parsed.version !== 1 && parsed.version !== 2) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -197,8 +204,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS)
: [],
]));
const database: ProfileDatabase = { version: 2, accounts, rosters };
if (parsed.version === 1) writeDatabase(database, storage);
const rawPresentationSettings = parsed.presentationSettingsByOwner
&& typeof parsed.presentationSettingsByOwner === "object"
&& !Array.isArray(parsed.presentationSettingsByOwner)
? parsed.presentationSettingsByOwner as Record<string, unknown>
: {};
const presentationSettingsByOwner = Object.fromEntries(
Object.entries(rawPresentationSettings).map(([ownerId, settings]) => [
ownerId,
normalizeCombatPresentationSettings(settings),
]),
);
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 3) writeDatabase(database, storage);
return database;
} catch {
return emptyDatabase();
@@ -626,6 +644,28 @@ export function updateCharacterSettings(
return updated;
}
export function loadCombatPresentationSettings(
ownerId: string,
storage: StorageLike | null = browserLocalStorage(),
): CombatPresentationSettings {
if (!storage) return DEFAULT_COMBAT_PRESENTATION_SETTINGS;
return readDatabase(storage).presentationSettingsByOwner[ownerId]
?? DEFAULT_COMBAT_PRESENTATION_SETTINGS;
}
export function updateCombatPresentationSettings(
ownerId: string,
settings: CombatPresentationSettings,
storage: StorageLike | null = browserLocalStorage(),
): CombatPresentationSettings {
const normalized = normalizeCombatPresentationSettings(settings);
if (!storage) return normalized;
const database = readDatabase(storage);
database.presentationSettingsByOwner[ownerId] = normalized;
writeDatabase(database, storage);
return normalized;
}
export function updateCharacterManastormProgress(
ownerId: string,
characterId: string,
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(2);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+40 -2
View File
@@ -5,6 +5,7 @@ import {
createCharacter as persistCharacter,
deleteCharacter as removeCharacter,
listCharacters,
loadCombatPresentationSettings,
restoreSession as restoreStoredSession,
saveSession,
updateCharacterActionBindings,
@@ -13,6 +14,7 @@ import {
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterSettings,
updateCombatPresentationSettings as persistCombatPresentationSettings,
swapRomClasses,
} from "./accountRepository";
import {
@@ -36,6 +38,11 @@ import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes";
import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import {
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
normalizeCombatPresentationSettings,
type CombatPresentationSettings,
} from "../game/combatPresentation";
import type { PartyRole } from "../game/partyRoles";
import type { ManastormPartySize, ManastormProgress } from "../game/manastormProgress";
import { dungeonShellRuntime } from "./dungeonShellRuntimeBridge";
@@ -56,6 +63,7 @@ export interface ShellState {
notice: string;
activeDungeonId: DungeonId;
activeDungeonRole: PartyRole;
combatPresentationSettings: CombatPresentationSettings;
beginSession: (session: PlayerSession) => void;
signOut: () => void;
selectCharacter: (characterId: string) => void;
@@ -89,6 +97,8 @@ export interface ShellState {
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
saveActiveSettings: (settings: GameplaySettings) => void;
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
resetCombatPresentationSettings: () => void;
saveActiveManastormProgress: (progress: ManastormProgress) => void;
saveSelectedManastormLoadout: (loadout: readonly string[]) => boolean;
setNotice: (notice: string) => void;
@@ -108,15 +118,22 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
};
}
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId"> {
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId" | "combatPresentationSettings"> {
const session = restoreStoredSession();
if (!session) return { phase: "login", session: null, characters: [], selectedCharacterId: null };
if (!session) return {
phase: "login",
session: null,
characters: [],
selectedCharacterId: null,
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
};
const characters = listCharacters(session.ownerId);
return {
phase: "characters",
session,
characters,
selectedCharacterId: characters[0]?.id ?? null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
};
}
@@ -139,6 +156,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters,
selectedCharacterId: characters[0]?.id ?? null,
activeCharacter: null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
notice: session.kind === "offline" ? "Offline roster loaded." : `Welcome, ${session.displayName}.`,
});
},
@@ -154,6 +172,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
activeCharacter: null,
draft: defaultDraft(),
notice: "Signed out.",
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
});
},
selectCharacter: (selectedCharacterId) => set({ selectedCharacterId, notice: "" }),
@@ -361,6 +380,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
: characters[0]?.id ?? null,
activeCharacter: null,
notice: "",
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
});
},
saveActiveProgression: (level, experience, talentRanks) => {
@@ -433,6 +453,24 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
updateCombatPresentationSettings: (patch) => {
const { session, combatPresentationSettings } = get();
const next = normalizeCombatPresentationSettings({
...combatPresentationSettings,
...patch,
}, combatPresentationSettings);
const persisted = session
? persistCombatPresentationSettings(session.ownerId, next)
: next;
set({ combatPresentationSettings: persisted });
},
resetCombatPresentationSettings: () => {
const { session } = get();
const settings = session
? persistCombatPresentationSettings(session.ownerId, DEFAULT_COMBAT_PRESENTATION_SETTINGS)
: DEFAULT_COMBAT_PRESENTATION_SETTINGS;
set({ combatPresentationSettings: settings });
},
saveActiveManastormProgress: (progress) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
+3
View File
@@ -115,6 +115,9 @@ export function CombatBridge() {
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (game.gameMode === "dungeon") {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
advanceDungeonPartyCombat(epochNow);
if (game.gameMode === "dungeon" && game.activeDungeonId === "wailing-caverns") {
+295
View File
@@ -0,0 +1,295 @@
import {
combatEffectManifestEntry,
type CombatActorGroup,
type CombatEffectSoundAsset,
type CombatPresentationEvent,
type CombatPresentationSettings,
type CombatPresentationSchool,
} from "./combatPresentation";
const MAX_COMBAT_VOICES = 32;
interface ActiveVoice {
readonly group: CombatActorGroup;
readonly stop: () => void;
}
let context: AudioContext | null = null;
let masterGain: GainNode | null = null;
let groupGains: Record<CombatActorGroup, GainNode> | null = null;
let configuredSettings: CombatPresentationSettings | null = null;
let configuredMasterVolume = 0.8;
const activeVoices: ActiveVoice[] = [];
const activeMasterVoices = new Set<() => void>();
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
function audioContextConstructor(): typeof AudioContext | null {
if (typeof window === "undefined") return null;
return window.AudioContext
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
?? null;
}
function ensureContext(): AudioContext | null {
if (context) return context;
const Constructor = audioContextConstructor();
if (!Constructor) return null;
context = new Constructor();
masterGain = context.createGain();
masterGain.connect(context.destination);
groupGains = {
player: context.createGain(),
party: context.createGain(),
enemy: context.createGain(),
};
for (const gain of Object.values(groupGains)) gain.connect(masterGain);
applyCombatAudioSettings();
return context;
}
function applyCombatAudioSettings(): void {
if (!context || !masterGain || !groupGains || !configuredSettings) return;
masterGain.gain.setTargetAtTime(configuredMasterVolume, context.currentTime, 0.02);
for (const group of ["player", "party", "enemy"] as const) {
const volume = configuredSettings.audioEnabled[group]
? configuredSettings.audioVolume[group]
: 0;
groupGains[group].gain.setTargetAtTime(volume, context.currentTime, 0.02);
if (volume <= 0) stopCombatAudioGroup(group);
}
}
export function configureCombatAudio(
settings: CombatPresentationSettings,
masterVolume: number,
): void {
configuredSettings = settings;
configuredMasterVolume = Number.isFinite(masterVolume)
? Math.max(0, Math.min(1, masterVolume))
: 0.8;
applyCombatAudioSettings();
}
function positionPanner(
audioContext: AudioContext,
event: CombatPresentationEvent,
): PannerNode | StereoPannerNode {
if (typeof audioContext.createPanner === "function") {
const panner = audioContext.createPanner();
panner.panningModel = "HRTF";
panner.distanceModel = "inverse";
panner.refDistance = event.actorGroup === "player" ? 8 : 5;
panner.maxDistance = 55;
panner.rolloffFactor = event.actorGroup === "player" ? 0.15 : 0.8;
panner.positionX.value = event.origin[0];
panner.positionY.value = event.origin[1];
panner.positionZ.value = event.origin[2];
return panner;
}
return audioContext.createStereoPanner();
}
function trimVoices(): void {
while (activeVoices.length >= MAX_COMBAT_VOICES) activeVoices.shift()?.stop();
}
function registerVoice(voice: ActiveVoice): () => void {
trimVoices();
activeVoices.push(voice);
return () => {
const index = activeVoices.indexOf(voice);
if (index >= 0) activeVoices.splice(index, 1);
};
}
const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
physical: 115,
arcane: 520,
fire: 190,
frost: 720,
nature: 360,
shadow: 145,
holy: 660,
};
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
const audioContext = ensureContext();
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
void audioContext.resume().catch(() => undefined);
const oscillator = audioContext.createOscillator();
const envelope = audioContext.createGain();
const panner = positionPanner(audioContext, event);
const start = audioContext.currentTime;
const isImpact = event.phase === "impact" || event.delivery === "melee";
const duration = event.phase === "cast-start" ? 0.22 : isImpact ? 0.12 : event.phase === "tick" ? 0.1 : 0.18;
const base = SCHOOL_FREQUENCY[event.school];
oscillator.type = event.school === "physical" ? "sawtooth" : event.school === "holy" ? "sine" : "triangle";
oscillator.frequency.setValueAtTime(base * (event.phase === "cast-start" ? 0.7 : 1), start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(35, base * (isImpact ? 0.42 : 1.55)), start + duration);
envelope.gain.setValueAtTime(0.0001, start);
envelope.gain.exponentialRampToValueAtTime(event.actorGroup === "player" ? 0.12 : 0.085, start + 0.015);
envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(envelope);
envelope.connect(panner);
panner.connect(groupGains[event.actorGroup]);
let stopped = false;
const voice: ActiveVoice = {
group: event.actorGroup,
stop: () => {
if (stopped) return;
stopped = true;
try { oscillator.stop(); } catch { /* already stopped */ }
},
};
const unregister = registerVoice(voice);
oscillator.onended = () => {
unregister();
oscillator.disconnect();
envelope.disconnect();
panner.disconnect();
};
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
}
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
const playable = sounds.filter((sound) => sound.url);
if (!playable.length) return null;
const total = playable.reduce((sum, sound) => sum + Math.max(1, sound.weight ?? 1), 0);
let cursor = (event.id * 0.61803398875 % 1) * total;
for (const sound of playable) {
cursor -= Math.max(1, sound.weight ?? 1);
if (cursor <= 0) return sound;
}
return playable[playable.length - 1] ?? null;
}
async function decodeAudio(url: string): Promise<AudioBuffer | null> {
const audioContext = ensureContext();
if (!audioContext) return null;
if (!decodedBuffers.has(url)) {
decodedBuffers.set(url, fetch(url)
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
.then((data) => audioContext.decodeAudioData(data))
.catch(() => null));
}
return decodedBuffers.get(url)!;
}
export async function playMasterAudioCue(
url: string,
volume = 1,
): Promise<(() => void) | null> {
const audioContext = ensureContext();
const buffer = await decodeAudio(url);
if (!audioContext || !buffer || !masterGain) return null;
void audioContext.resume().catch(() => undefined);
const source = audioContext.createBufferSource();
const envelope = audioContext.createGain();
source.buffer = buffer;
envelope.gain.value = Math.max(0, Math.min(1, volume));
source.connect(envelope);
envelope.connect(masterGain);
let stopped = false;
const stop = () => {
if (stopped) return;
stopped = true;
activeMasterVoices.delete(stop);
try { source.stop(); } catch { /* already stopped */ }
};
activeMasterVoices.add(stop);
source.onended = () => {
stopped = true;
activeMasterVoices.delete(stop);
source.disconnect();
envelope.disconnect();
};
source.start();
return stop;
}
async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise<boolean> {
if (!sound.url) return false;
const audioContext = ensureContext();
const buffer = await decodeAudio(sound.url);
if (!audioContext || !buffer || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return false;
void audioContext.resume().catch(() => undefined);
const source = audioContext.createBufferSource();
const envelope = audioContext.createGain();
const panner = positionPanner(audioContext, event);
source.buffer = buffer;
envelope.gain.value = Math.max(0, Math.min(1, sound.volume ?? 1));
source.connect(envelope);
envelope.connect(panner);
panner.connect(groupGains[event.actorGroup]);
let stopped = false;
const voice: ActiveVoice = {
group: event.actorGroup,
stop: () => {
if (stopped) return;
stopped = true;
try { source.stop(); } catch { /* already stopped */ }
},
};
const unregister = registerVoice(voice);
source.onended = () => {
unregister();
source.disconnect();
envelope.disconnect();
panner.disconnect();
};
source.start();
return true;
}
export function playCombatAudio(event: CombatPresentationEvent): void {
if (event.phase === "cast-cancel") {
stopCombatAudioGroup(event.actorGroup);
return;
}
if (!configuredSettings?.audioEnabled[event.actorGroup] || configuredSettings.audioVolume[event.actorGroup] <= 0) return;
const sound = weightedSound(event);
if (!sound) {
playSynthesizedCombatCue(event);
return;
}
void playBufferedCombatCue(event, sound).then((played) => {
if (!played) playSynthesizedCombatCue(event);
});
}
export function stopCombatAudioGroup(group: CombatActorGroup): void {
for (const voice of [...activeVoices]) {
if (voice.group === group) voice.stop();
}
}
export function resetCombatAudio(): void {
for (const voice of [...activeVoices]) voice.stop();
for (const stop of [...activeMasterVoices]) stop();
activeVoices.length = 0;
}
export function setCombatAudioListener(
position: readonly [number, number, number],
forward: readonly [number, number, number],
up: readonly [number, number, number] = [0, 1, 0],
): void {
if (!context) return;
const listener = context.listener;
const now = context.currentTime;
listener.positionX?.setTargetAtTime(position[0], now, 0.01);
listener.positionY?.setTargetAtTime(position[1], now, 0.01);
listener.positionZ?.setTargetAtTime(position[2], now, 0.01);
listener.forwardX?.setTargetAtTime(forward[0], now, 0.01);
listener.forwardY?.setTargetAtTime(forward[1], now, 0.01);
listener.forwardZ?.setTargetAtTime(forward[2], now, 0.01);
listener.upX?.setTargetAtTime(up[0], now, 0.01);
listener.upY?.setTargetAtTime(up[1], now, 0.01);
listener.upZ?.setTargetAtTime(up[2], now, 0.01);
}
export function activeCombatAudioVoiceCount(): number {
return activeVoices.length;
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import {
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
combatEffectManifestEntry,
combatEffectStyle,
combatPresentationSessionId,
emitCombatPresentation,
normalizeCombatPresentationSettings,
presentationDeliveryForAbility,
presentationSchoolForAbility,
resetCombatPresentationSession,
subscribeCombatPresentation,
type CombatPresentationMessage,
} from "./combatPresentation";
describe("combat presentation", () => {
it("normalizes owner settings without leaking partial or invalid values", () => {
expect(normalizeCombatPresentationSettings({
vfxQuality: "low",
vfxEnabled: { player: false },
audioEnabled: { enemy: false },
audioVolume: { player: 2, party: -1, enemy: 0.35 },
})).toEqual({
vfxQuality: "low",
vfxEnabled: { player: false, party: true, enemy: true },
audioEnabled: { player: true, party: true, enemy: false },
audioVolume: { player: 1, party: 0, enemy: 0.35 },
});
expect(normalizeCombatPresentationSettings(null)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
});
it("delivers immutable, exactly-once events and a session reset", () => {
const messages: CombatPresentationMessage[] = [];
const unsubscribe = subscribeCombatPresentation((message) => messages.push(message));
const origin = [1, 2, 3] as [number, number, number];
const event = emitCombatPresentation({
occurredAt: 1_000,
phase: "release",
actorGroup: "player",
sourceActorId: "player",
targetActorId: "mob",
abilityId: "fixture",
sourceSpellId: 116,
name: "Frostbolt",
source: "wow335",
school: "frost",
delivery: "projectile",
origin,
targetPosition: [8, 0, 0],
});
origin[0] = 99;
const nextSession = resetCombatPresentationSession();
unsubscribe();
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual({ kind: "event", event });
expect(event.origin).toEqual([1, 2, 3]);
expect(messages[1]).toEqual({ kind: "reset", sessionId: nextSession });
expect(combatPresentationSessionId()).toBe(nextSession);
});
it("uses authentic source descriptors and school/delivery fallbacks", () => {
const frostbolt = abilityById("mage-frostbolt");
expect(frostbolt).not.toBeNull();
expect(presentationSchoolForAbility(frostbolt!)).toBe("frost");
expect(presentationDeliveryForAbility(frostbolt!)).toBe("projectile");
expect(combatEffectManifestEntry(999_999, "wow335")).toBeNull();
const style = combatEffectStyle({
id: 1,
sessionId: 1,
occurredAt: 1,
phase: "release",
actorGroup: "player",
sourceActorId: "player",
abilityId: "mage-frostbolt",
sourceSpellId: 999_999,
name: "Frostbolt",
source: "wow335",
school: "frost",
delivery: "projectile",
origin: [0, 0, 0],
});
expect(style.authentic).toBe(false);
expect(style.sourceModelNames).toEqual([]);
});
});
+295
View File
@@ -0,0 +1,295 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export type CombatActorGroup = "player" | "party" | "enemy";
export type CombatVfxQuality = "off" | "low" | "high";
export type CombatPresentationPhase =
| "cast-start"
| "cast-cancel"
| "release"
| "impact"
| "tick"
| "aura-start"
| "aura-end";
export type CombatPresentationDelivery = "melee" | "projectile" | "area" | "channel" | "aura";
export type CombatPresentationSchool =
| "physical"
| "arcane"
| "fire"
| "frost"
| "nature"
| "shadow"
| "holy";
export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "fallback";
export type CombatPresentationPosition = readonly [number, number, number];
export interface CombatPresentationSettings {
readonly vfxQuality: CombatVfxQuality;
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
readonly audioEnabled: Readonly<Record<CombatActorGroup, boolean>>;
readonly audioVolume: Readonly<Record<CombatActorGroup, number>>;
}
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
vfxQuality: "high",
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
audioEnabled: Object.freeze({ player: true, party: true, enemy: true }),
audioVolume: Object.freeze({ player: 1, party: 1, enemy: 1 }),
});
function finiteVolume(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.min(1, value))
: fallback;
}
function booleanGroups(
value: unknown,
fallback: Readonly<Record<CombatActorGroup, boolean>>,
): Readonly<Record<CombatActorGroup, boolean>> {
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Partial<Record<CombatActorGroup, unknown>>
: {};
return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [
group,
typeof record[group] === "boolean" ? record[group] : fallback[group],
])) as Record<CombatActorGroup, boolean>);
}
function volumeGroups(
value: unknown,
fallback: Readonly<Record<CombatActorGroup, number>>,
): Readonly<Record<CombatActorGroup, number>> {
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Partial<Record<CombatActorGroup, unknown>>
: {};
return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [
group,
finiteVolume(record[group], fallback[group]),
])) as Record<CombatActorGroup, number>);
}
export function normalizeCombatPresentationSettings(
value: unknown,
fallback: CombatPresentationSettings = DEFAULT_COMBAT_PRESENTATION_SETTINGS,
): CombatPresentationSettings {
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Partial<Record<keyof CombatPresentationSettings, unknown>>
: {};
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
? record.vfxQuality
: fallback.vfxQuality;
return Object.freeze({
vfxQuality,
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
audioEnabled: booleanGroups(record.audioEnabled, fallback.audioEnabled),
audioVolume: volumeGroups(record.audioVolume, fallback.audioVolume),
});
}
export interface CombatPresentationEvent {
readonly id: number;
readonly sessionId: number;
readonly occurredAt: number;
readonly phase: CombatPresentationPhase;
readonly actorGroup: CombatActorGroup;
readonly sourceActorId: string;
readonly targetActorId?: string;
readonly abilityId: string;
readonly sourceSpellId?: number;
readonly name: string;
readonly source: CombatPresentationSource;
readonly school: CombatPresentationSchool;
readonly delivery: CombatPresentationDelivery;
readonly origin: CombatPresentationPosition;
readonly targetPosition?: CombatPresentationPosition;
readonly radius?: number;
readonly durationMs?: number;
}
export type CombatPresentationEventInput = Omit<CombatPresentationEvent, "id" | "sessionId">;
export type CombatPresentationMessage =
| { readonly kind: "event"; readonly event: CombatPresentationEvent }
| { readonly kind: "reset"; readonly sessionId: number };
type CombatPresentationListener = (message: CombatPresentationMessage) => void;
const presentationListeners = new Set<CombatPresentationListener>();
let presentationSequence = 0;
let presentationSessionId = 1;
export function subscribeCombatPresentation(listener: CombatPresentationListener): () => void {
presentationListeners.add(listener);
return () => presentationListeners.delete(listener);
}
export function emitCombatPresentation(input: CombatPresentationEventInput): CombatPresentationEvent {
presentationSequence += 1;
const event: CombatPresentationEvent = Object.freeze({
...input,
id: presentationSequence,
sessionId: presentationSessionId,
origin: [...input.origin] as CombatPresentationPosition,
...(input.targetPosition
? { targetPosition: [...input.targetPosition] as CombatPresentationPosition }
: {}),
});
for (const listener of presentationListeners) listener({ kind: "event", event });
return event;
}
export function resetCombatPresentationSession(): number {
presentationSessionId += 1;
for (const listener of presentationListeners) {
listener({ kind: "reset", sessionId: presentationSessionId });
}
return presentationSessionId;
}
export function combatPresentationSessionId(): number {
return presentationSessionId;
}
export interface CombatEffectPhaseAsset {
readonly model?: string;
readonly attachment?: string;
readonly scale?: number;
}
export interface CombatEffectSoundAsset {
readonly url?: string;
readonly sourcePath?: string;
readonly weight?: number;
readonly volume?: number;
readonly minimumDistance?: number;
readonly maximumDistance?: number;
}
export interface CombatEffectManifestEntry {
readonly key: string;
readonly source: Exclude<CombatPresentationSource, "fallback">;
readonly spellId: number;
readonly visualIds?: readonly number[];
readonly phases?: Readonly<Partial<Record<CombatPresentationPhase, readonly CombatEffectPhaseAsset[]>>>;
readonly sounds?: readonly CombatEffectSoundAsset[];
}
interface GeneratedCombatEffectManifest {
readonly schemaVersion: number;
readonly entries: readonly CombatEffectManifestEntry[];
}
const COMBAT_EFFECT_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
const manifestByKey = new Map<string, CombatEffectManifestEntry>();
const manifestBySpellId = new Map<number, CombatEffectManifestEntry>();
let manifestRequest: Promise<void> | null = null;
function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): void {
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.entries)) return;
for (const entry of manifest.entries) {
manifestByKey.set(entry.key, entry);
if (!manifestBySpellId.has(entry.spellId)) manifestBySpellId.set(entry.spellId, entry);
}
}
export function preloadCombatEffectManifest(): Promise<void> {
if (manifestRequest) return manifestRequest;
if (typeof fetch === "undefined") return Promise.resolve();
manifestRequest = fetch(COMBAT_EFFECT_MANIFEST_URL)
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
.catch(() => undefined);
return manifestRequest;
}
export function combatEffectManifestEntry(
spellId: number | undefined,
source?: CombatPresentationSource,
): CombatEffectManifestEntry | null {
if (!spellId) return null;
if (source && source !== "fallback") {
const exact = manifestByKey.get(`${source}:${spellId}`);
if (exact) return exact;
}
return manifestBySpellId.get(spellId) ?? null;
}
function abilityHasEffect(ability: AbilityDefinition, kind: AbilityEffect["kind"]): boolean {
return ability.effects.some((effect) => effect.kind === kind);
}
export function presentationSourceForAbility(ability: AbilityDefinition): CombatPresentationSource {
if (ability.source === "runewaker") return "runewaker";
if (ability.source?.startsWith("ascension") || ability.source?.startsWith("coa")) return "ascension";
return "wow335";
}
export function presentationSchoolForAbility(ability: AbilityDefinition): CombatPresentationSchool {
const name = `${ability.name} ${ability.description}`.toLowerCase();
if (/fire|flame|burn|inferno|pyro|molten/.test(name)) return "fire";
if (/frost|ice|rime|chill|snow/.test(name)) return "frost";
if (/shadow|void|curse|corrupt|death|plague|fel|blood/.test(name)) return "shadow";
if (/arcane|mana|time|chron|runic|astral/.test(name)) return "arcane";
if (/holy|light|smite|divine|sun|heal|renew|rejuven|salvation|blessing/.test(name)) return "holy";
if (/nature|earth|storm|lightning|thunder|wind|wrath|root|poison|venom/.test(name)) return "nature";
if (abilityHasEffect(ability, "heal") || abilityHasEffect(ability, "hot") || abilityHasEffect(ability, "resurrection")) {
return ["druid", "shaman", "rom-druid", "rom-warden"].includes(ability.classId) ? "nature" : "holy";
}
return "physical";
}
export function presentationDeliveryForAbility(ability: AbilityDefinition): CombatPresentationDelivery {
if (ability.castMode === "channel") return "channel";
if (ability.radius !== undefined && ability.radius > 0) return "area";
if (ability.target === "hostile") return ability.range.max <= 4.5 ? "melee" : "projectile";
if (abilityHasEffect(ability, "apply-aura") || abilityHasEffect(ability, "shield") || abilityHasEffect(ability, "hot")) {
return "aura";
}
return "projectile";
}
export function presentationSourceForSpellId(spellId: number | undefined): CombatPresentationSource {
if (!spellId) return "fallback";
const mapped = manifestBySpellId.get(spellId);
if (mapped) return mapped.source;
return spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "wow335";
}
export interface CombatEffectStyle {
readonly primary: string;
readonly secondary: string;
readonly emissive: number;
readonly authentic: boolean;
readonly sourceModelNames: readonly string[];
}
const SCHOOL_STYLE: Readonly<Record<CombatPresentationSchool, Omit<CombatEffectStyle, "authentic" | "sourceModelNames">>> = {
physical: { primary: "#f3d18a", secondary: "#ffffff", emissive: 1.2 },
arcane: { primary: "#b774ff", secondary: "#76d8ff", emissive: 1.8 },
fire: { primary: "#ff6a20", secondary: "#ffd24a", emissive: 2.2 },
frost: { primary: "#72d8ff", secondary: "#e8fbff", emissive: 1.8 },
nature: { primary: "#64e06e", secondary: "#d4ff73", emissive: 1.7 },
shadow: { primary: "#8a4de8", secondary: "#ed5cff", emissive: 1.9 },
holy: { primary: "#ffe67a", secondary: "#fff7d0", emissive: 2 },
};
export function combatEffectStyle(event: CombatPresentationEvent): CombatEffectStyle {
const entry = combatEffectManifestEntry(event.sourceSpellId, event.source);
const sourceModelNames = Object.values(entry?.phases ?? {})
.flatMap((assets) => assets ?? [])
.flatMap((asset) => asset.model ? [asset.model] : []);
const inferred = sourceModelNames.join(" ").toLowerCase();
const school = /fire|flame|molten/.test(inferred)
? "fire"
: /frost|ice|snow/.test(inferred)
? "frost"
: /shadow|void|dark/.test(inferred)
? "shadow"
: /holy|light|heal/.test(inferred)
? "holy"
: /nature|earth|lightning|wind/.test(inferred)
? "nature"
: event.school;
return { ...SCHOOL_STYLE[school], authentic: Boolean(entry), sourceModelNames };
}
+112
View File
@@ -20,6 +20,7 @@ import { EMPTY_ITEM_STATS } from "./itemStats";
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
import { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
function allocateTalentRanks(name: string, ranks: number): void {
const talent = TALENT_NODES.find((node) => node.name === name);
@@ -94,6 +95,26 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
});
it("emits exactly one cast and release event and cancels presentation with the cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
registerTarget("presentation-target", [10, 0, 0]);
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event") events.push(message.event);
});
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.getState().tick(0.1, 2_500);
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
.toEqual(["cast-start", "release", "impact"]);
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 4_000).ok).toBe(true);
expect(useCombatStore.getState().cancelCast()).toBe(true);
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
.toEqual(["cast-start", "release", "impact", "cast-start", "cast-cancel"]);
unsubscribe();
});
it("does not cast or bind Priest Holy Concentration", () => {
const abilityId = "wow335-priest-holy-concentration";
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({
@@ -385,6 +406,94 @@ describe("combat store", () => {
expect(useCombatStore.getState().health).toBe(healthBefore - 12);
});
it("automatically engages a dungeon pack when a living party actor enters its aggro range", () => {
const partyMemberId = preparePartyMember(0, [40, 0, 0]);
for (const [id, position] of [["proximity-pack:a", [20, 0, 0]], ["proximity-pack:b", [22, 0, 0]]] as const) {
setMobPosition(id, position);
useCombatStore.getState().registerMob(id, {
name: id,
maxHealth: 100,
});
}
setMobPosition("proximity-boss", [100, 0, 0]);
useCombatStore.getState().registerMob("proximity-boss", {
name: "Proximity Boss",
boss: true,
maxHealth: 100,
});
expect(useCombatStore.getState().mobs["proximity-pack:a"].aggroRange).toBe(16);
expect(useCombatStore.getState().mobs["proximity-boss"].aggroRange).toBe(20);
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().mobs["proximity-pack:a"].engaged).toBe(false);
updatePartyRuntimePosition(partyMemberId, 27, 0, 0);
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 0, 0])).toBe(1);
expect(useCombatStore.getState().mobs["proximity-pack:a"]).toMatchObject({
engaged: true,
targetActorId: partyMemberId,
homePosition: [20, 0, 0],
});
expect(useCombatStore.getState().mobs["proximity-pack:b"]).toMatchObject({
engaged: true,
targetActorId: partyMemberId,
homePosition: [22, 0, 0],
});
});
it("uses 3D distance for proximity aggro so stacked dungeon floors do not pull", () => {
setMobPosition("upper-floor-mob", [0, 20, 0]);
useCombatStore.getState().registerMob("upper-floor-mob", {
name: "Upper Floor Mob",
maxHealth: 100,
});
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(false);
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 8, 0])).toBe(1);
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(true);
});
it("anchors a roaming mob at the pull location and keeps aggro across vertical terrain", () => {
const id = "kresh-patrol:kresh-roaming";
setMobPosition(id, [0, -70, 0]);
useCombatStore.getState().registerMob(id, {
name: "Kresh",
boss: true,
maxHealth: 100,
leashRange: 55,
xpReward: 0,
});
// Kresh can travel far from the position where his model first mounted.
// His leash must begin at the patrol position where combat actually starts.
setMobPosition(id, [100, -106, 0]);
useCombatStore.getState().damageMob(id, 10, 1_000);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 90,
engaged: true,
homePosition: [100, -106, 0],
});
// A player falling from the ledge can be vertically farther than the leash
// while remaining beside the encounter in the dungeon's horizontal plane.
useCombatStore.getState().advanceMobCombat(1_450, [102, -30, 0]);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 90,
engaged: true,
combatPhase: "chasing",
});
// Horizontal distance still enforces the normal evade/reset boundary.
useCombatStore.getState().advanceMobCombat(1_500, [156, -106, 0]);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 100,
engaged: false,
combatPhase: "returning",
});
});
it("tracks damage and effective-healing threat independently for every engaged mob", () => {
for (const id of ["threat-pack:a", "threat-pack:b"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 500, xpReward: 0 });
@@ -728,11 +837,13 @@ describe("combat store", () => {
expect(useCombatStore.getState().talentRanks).toEqual({});
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
expect(useCombatStore.getState().settings.showMobAggroRanges).toBe(false);
useCombatStore.getState().updateSettings({
masterVolume: 9,
uiScale: 0.1,
minimapRotation: "north-up",
showThreatMeter: true,
showMobAggroRanges: true,
threatMeterPosition: { x: -2, y: 4 },
});
expect(useCombatStore.getState().settings).toMatchObject({
@@ -740,6 +851,7 @@ describe("combat store", () => {
uiScale: 0.75,
minimapRotation: "north-up",
showThreatMeter: true,
showMobAggroRanges: true,
threatMeterPosition: { x: 0, y: 1 },
});
useCombatStore.getState().initializeCharacter({ classId: "priest" });
+310 -27
View File
@@ -153,6 +153,16 @@ import {
type ThreatSource,
type ThreatTable,
} from "./aggro";
import { distanceSquared, planarDistanceSquared } from "./mobAi";
import {
emitCombatPresentation,
presentationDeliveryForAbility,
presentationSchoolForAbility,
presentationSourceForAbility,
presentationSourceForSpellId,
resetCombatPresentationSession,
type CombatPresentationPhase,
} from "./combatPresentation";
/** Stable target id used by named player auras and HUD consumers. */
export const PLAYER_AURA_ENTITY_ID = PLAYER_AGGRO_ID;
@@ -166,6 +176,7 @@ export type MinimapRotation = "north-up" | "player-up";
export interface GameplaySettings {
readonly showMobHealthBars: boolean;
readonly showMobAggroRanges: boolean;
readonly showTargetFrame: boolean;
readonly showThreatMeter: boolean;
readonly threatMeterPosition: NormalizedHudPosition;
@@ -179,6 +190,7 @@ export interface GameplaySettings {
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
showMobHealthBars: true,
showMobAggroRanges: false,
showTargetFrame: true,
showThreatMeter: false,
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
@@ -196,6 +208,9 @@ export const MOB_WOUND_ANIMATION_COOLDOWN_MS = 650;
export const EMPTY_CORPSE_DESPAWN_MS = 10_000;
/** Looting cannot hide a corpse before its one-shot death animation has had time to land. */
export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
export const DEFAULT_MOB_AGGRO_RANGE = 16;
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
export interface MobStatus {
readonly kind: MobStatusKind;
@@ -276,6 +291,7 @@ export interface MobCombatState {
readonly attackRange: number;
readonly attackDamage: number;
readonly swingMs: number;
readonly aggroRange: number;
readonly leashRange: number;
readonly moveSpeed: number;
readonly attackRevision: number;
@@ -397,6 +413,7 @@ export interface MobRegistration {
readonly damageMultiplier?: number;
readonly bonusLootChance?: number;
readonly moveSpeed?: number;
readonly aggroRange?: number;
readonly leashRange?: number;
readonly attacks?: readonly EnemyAttackDefinition[];
readonly mechanicImmunities?: readonly AzerothCoreMechanic[];
@@ -504,6 +521,7 @@ export interface CombatState {
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
healPlayer: (amount: number) => number;
cancelCast: (message?: string) => boolean;
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: () => void;
@@ -576,6 +594,85 @@ function nextAnimationEvent(
return { revision: animationSequence, kind, ...(abilityId ? { abilityId } : {}) };
}
function emitPlayerAbilityPresentation(
ability: AbilityDefinition,
phase: CombatPresentationPhase,
occurredAt: number,
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
): void {
emitCombatPresentation({
occurredAt,
phase,
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
...(targetId ? { targetActorId: targetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
origin,
targetPosition: targetPosition ?? origin,
...(ability.radius === undefined ? {} : { radius: ability.radius }),
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
});
}
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
const ability = abilityById(abilityId);
if (!ability) return;
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
}
function emitPlayerAbilityResolutionPresentation(
ability: AbilityDefinition,
occurredAt: number,
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
): void {
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition);
emitPlayerAbilityPresentation(
ability,
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
occurredAt,
origin,
targetId,
targetPosition,
);
}
function emitEnemyAttackPresentation(
mob: MobCombatState,
attack: EnemyAttackDefinition,
phase: CombatPresentationPhase,
occurredAt: number,
origin: CombatPosition,
targetActorId: AggroActorId | null,
targetPosition: CombatPosition | null,
): void {
emitCombatPresentation({
occurredAt,
phase,
actorGroup: "enemy",
sourceActorId: mob.id,
...(targetActorId ? { targetActorId } : {}),
abilityId: attack.id,
...(attack.spellId === undefined ? {} : { sourceSpellId: attack.spellId }),
name: attack.name,
source: presentationSourceForSpellId(attack.spellId),
school: attack.school,
delivery: attack.delivery,
origin,
targetPosition: targetPosition ?? origin,
...(attack.radius === undefined ? {} : { radius: attack.radius }),
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
});
}
function emptyCastResult(reason?: CastFailureReason, abilityId?: string): CastResult {
return { ok: !reason, abilityId, reason, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 };
}
@@ -721,10 +818,7 @@ function eligibleAggroActorIds(
const withinLeash = (position: CombatPosition | null) => {
if (!position) return false;
if (!home) return true;
const dx = position[0] - home[0];
const dy = position[1] - home[1];
const dz = position[2] - home[2];
return dx * dx + dy * dy + dz * dz <= mob.leashRange * mob.leashRange;
return planarDistanceSquared(position, home) <= mob.leashRange * mob.leashRange;
};
if (state.health > 0 && withinLeash(state.playerPosition)) eligible.add(PLAYER_AGGRO_ID);
for (const member of usePartyStore.getState().members) {
@@ -1266,6 +1360,7 @@ function normalizedSettings(
): GameplaySettings {
return {
showMobHealthBars: patch.showMobHealthBars ?? current.showMobHealthBars,
showMobAggroRanges: patch.showMobAggroRanges ?? current.showMobAggroRanges,
showTargetFrame: patch.showTargetFrame ?? current.showTargetFrame,
showThreatMeter: patch.showThreatMeter ?? current.showThreatMeter,
threatMeterPosition: normalizeHudPosition(
@@ -1444,6 +1539,7 @@ function summonedMobCombatState(
attackRange: attack.range,
attackDamage: stats.attackDamage,
swingMs: stats.swingMs,
aggroRange: 0,
leashRange: summoner.leashRange,
moveSpeed: summoner.moveSpeed,
attackRevision: 0,
@@ -1749,34 +1845,76 @@ function mobGroupId(id: string): string {
return separator > 0 ? id.slice(0, separator) : id;
}
function engageMobGroup(
mobs: Record<string, MobCombatState>,
sourceId: string,
now: number,
sourceActorId: AggroActorId,
): boolean {
const groupId = mobGroupId(sourceId);
const groupWasEngaged = Object.entries(mobs).some(([id, mob]) => (
mobGroupId(id) === groupId && mob.engaged && !mob.dead
));
let changed = false;
for (const [id, mob] of Object.entries(mobs)) {
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
const threatByActor = groupWasEngaged
? mob.threatByActor
: seedActorThreat(mob.threatByActor, sourceActorId);
mobs[id] = {
...mob,
threatByActor,
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
engaged: true,
combatPhase: "chasing",
homePosition: groupWasEngaged
? (mob.homePosition ?? getMobPosition(id))
: (getMobPosition(id) ?? mob.homePosition),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
};
changed = true;
}
return changed;
}
function engageMobGroupInWork(
work: MutableCastWork,
sourceId: string,
now: number,
sourceActorId: AggroActorId,
): void {
const groupId = mobGroupId(sourceId);
const groupWasEngaged = Object.entries(work.mobs).some(([id, mob]) => (
mobGroupId(id) === groupId && mob.engaged && !mob.dead
));
for (const [id, mob] of Object.entries(work.mobs)) {
if (mob.dead || mobGroupId(id) !== groupId) continue;
const threatByActor = groupWasEngaged
? mob.threatByActor
: seedActorThreat(mob.threatByActor, sourceActorId);
work.mobs[id] = {
...mob,
threatByActor,
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
engaged: true,
combatPhase: "chasing",
homePosition: mob.homePosition ?? getMobPosition(id),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
};
engageMobGroup(work.mobs, sourceId, now, sourceActorId);
}
function nearestAggroActorId(
state: Pick<CombatState, "health" | "playerPosition">,
mobPosition: CombatPosition,
aggroRange: number,
): AggroActorId | null {
if (!Number.isFinite(aggroRange) || aggroRange <= 0) return null;
let nearestId: AggroActorId | null = null;
let nearestDistanceSquared = aggroRange * aggroRange;
if (state.health > 0) {
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
if (playerDistanceSquared <= nearestDistanceSquared) {
nearestId = PLAYER_AGGRO_ID;
nearestDistanceSquared = playerDistanceSquared;
}
}
for (const member of usePartyStore.getState().members) {
if (member.health <= 0) continue;
const position = getPartyRuntimePosition(member.id);
if (!position) continue;
const memberDistanceSquared = distanceSquared(mobPosition, position);
if (memberDistanceSquared < nearestDistanceSquared) {
nearestId = member.id;
nearestDistanceSquared = memberDistanceSquared;
}
}
return nearestId;
}
function mobResistanceForSchool(
@@ -1893,7 +2031,7 @@ function damageMobInWork(
forcedTarget: killed ? null : engagedMob.forcedTarget,
engaged: !killed,
combatPhase: killed ? "idle" : "chasing",
homePosition: mob.homePosition ?? getMobPosition(id),
homePosition: engagedMob.homePosition ?? getMobPosition(id),
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
activeCast: killed ? null : engagedMob.activeCast,
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
@@ -2266,6 +2404,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
...initialState(),
initializeCharacter: (profile) => {
resetCombatPresentationSession();
const classId = profile.classId;
const secondaryClassId = profile.secondaryClassId ?? null;
const raceId = profile.raceId ?? "human";
@@ -2371,6 +2510,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attackRange + 1,
registration.leashRange ?? existing?.leashRange ?? (boss ? 55 : 42),
);
const aggroRange = Math.max(
0,
Math.min(
leashRange,
registration.aggroRange
?? existing?.aggroRange
?? (boss ? DEFAULT_BOSS_AGGRO_RANGE : DEFAULT_MOB_AGGRO_RANGE),
),
);
const moveSpeed = Math.max(
0,
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
@@ -2403,6 +2551,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attacks,
attackRange,
swingMs,
aggroRange,
leashRange,
moveSpeed,
attackDamage,
@@ -2454,6 +2603,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attackRange,
attackDamage,
swingMs,
aggroRange,
leashRange,
moveSpeed,
attackRevision: 0,
@@ -2690,6 +2840,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const movedWhileCasting = Boolean(cast && (
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
));
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
return {
playerPosition: next,
activeCast: movedWhileCasting ? null : cast,
@@ -2814,6 +2965,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? { playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) }
: {}),
});
if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin);
return emptyCastResult(reason, abilityId);
}
@@ -2885,6 +3037,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
{ abilityId: ability.id, targetId: targetId ?? undefined },
),
});
emitPlayerAbilityPresentation(
ability,
"cast-start",
now,
playerOrigin,
targetId,
targetPosition,
);
return { ...emptyCastResult(), ok: true, abilityId: ability.id };
}
@@ -3452,6 +3612,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
{ abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing },
),
});
emitPlayerAbilityResolutionPresentation(
ability,
now,
playerOrigin,
targetId,
targetPosition,
);
return {
ok: true,
@@ -3611,7 +3778,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: mob.forcedTarget,
engaged: true,
combatPhase: "chasing",
homePosition: mob.homePosition ?? getMobPosition(mobId),
homePosition: groupWasEngaged
? (mob.homePosition ?? getMobPosition(mobId))
: (getMobPosition(mobId) ?? mob.homePosition),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
@@ -3706,9 +3875,30 @@ export const useCombatStore = create<CombatState>((set, get) => ({
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
});
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
return true;
},
engageNearbyMobs: (now = Date.now(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
? { ...state, playerPosition }
: state;
const mobs: Record<string, MobCombatState> = { ...state.mobs };
let engagedGroups = 0;
for (const id of Object.keys(mobs)) {
const mob = mobs[id];
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
const position = getMobPosition(id);
if (!position) continue;
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
if (!actorId) continue;
if (engageMobGroup(mobs, id, now, actorId)) engagedGroups += 1;
}
if (engagedGroups > 0) set({ mobs });
return engagedGroups;
},
advanceMobCombat: (now = Date.now(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
@@ -3852,6 +4042,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attackAnimation = "cast";
lastAttackId = attack.id;
nextAttackAt = enemyActiveCast.completesAt;
emitEnemyAttackPresentation(
mob,
attack,
"cast-start",
now,
position,
resolution.targetActorId,
targetPosition,
);
} else if (selectedAttack && !cannotAttack) {
const attack = selectedAttack!;
enemyActiveCast = null;
@@ -3881,6 +4080,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: attack.target === "primary"
? [resolution.targetActorId]
: [];
const presentationTargetId = targets[0] ?? resolution.targetActorId;
emitEnemyAttackPresentation(
mob,
attack,
"release",
now,
position,
presentationTargetId,
actorPosition(runtimeState, presentationTargetId),
);
if (effect.kind === "damage") {
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
for (const actorId of targets) {
@@ -4102,6 +4311,19 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
let appliedDamage = 0;
for (const hit of incomingHits) {
const impactOrigin = getMobPosition(hit.mob.id);
const impactTarget = actorPosition(get(), hit.actorId);
if (impactOrigin && impactTarget) {
emitEnemyAttackPresentation(
hit.mob,
hit.attack,
"impact",
now,
impactOrigin,
hit.actorId,
impactTarget,
);
}
if (hit.actorId === PLAYER_AGGRO_ID) {
const current = get();
const defender = applyAuraStats(
@@ -4437,6 +4659,23 @@ export const useCombatStore = create<CombatState>((set, get) => ({
work.resourcePools = rage.resourcePools;
}
if (result.amount > 0) autoAttackEvent = nextAnimationEvent("attack");
if (result.amount > 0) {
const targetPosition = getMobPosition(autoAttackTargetId!);
emitCombatPresentation({
occurredAt: now,
phase: "release",
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: autoAttackTargetId!,
abilityId: "player-auto-attack",
name: "Auto Attack",
source: "fallback",
school: autoAttackSchool,
delivery: "melee",
origin: state.playerPosition,
targetPosition: targetPosition ?? state.playerPosition,
});
}
nextAutoAttackAt = now + profile.intervalMs;
if (result.killed) {
autoAttackTargetId = null;
@@ -4530,6 +4769,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
amount,
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}, nextTickAt);
if (ability) {
const sourceActorId = effect.sourceActorId;
const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party";
const origin = sourceActorId === PLAYER_AGGRO_ID
? state.playerPosition
: getPartyRuntimePosition(sourceActorId) ?? state.playerPosition;
const targetPosition = effect.targetId
? getMobPosition(effect.targetId) ?? getPartyRuntimePosition(effect.targetId)
: state.playerPosition;
emitCombatPresentation({
occurredAt: nextTickAt,
phase: "tick",
actorGroup,
sourceActorId,
...(effect.targetId ? { targetActorId: effect.targetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
origin,
targetPosition: targetPosition ?? origin,
});
}
remainingTicks -= 1;
nextTickAt += effect.intervalMs;
}
@@ -4711,6 +4975,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed);
}
}
emitPlayerAbilityPresentation(
ability,
"tick",
nextTickAt,
get().playerPosition,
cast.targetId,
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
);
ticksCompleted += 1;
nextTickAt += (cast.completesAt - cast.startedAt) / cast.totalTicks;
}
@@ -4724,6 +4996,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: cast.targetId ?? undefined,
}),
});
if (ability) {
emitPlayerAbilityPresentation(
ability,
"release",
now,
get().playerPosition,
cast.targetId,
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
);
}
} else if (ticksCompleted !== cast.ticksCompleted) {
set({ activeCast: { ...cast, ticksCompleted, nextTickAt } });
}
@@ -4736,6 +5018,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
},
resetEncounter: () => set((state) => {
resetCombatPresentationSession();
for (const mob of Object.values(state.mobs)) {
if (mob.summonedBy) removeMobPosition(mob.id);
}
+1
View File
@@ -94,6 +94,7 @@ export interface EnemyCombatDefinition {
readonly healthMultiplier?: number;
readonly damageMultiplier?: number;
readonly moveSpeed?: number;
readonly aggroRange?: number;
readonly leashRange?: number;
readonly attacks: readonly EnemyAttackDefinition[];
}
+21 -2
View File
@@ -121,14 +121,32 @@ export function manastormCueForStatusTransition(
}
function browserAudioFactory(url: string): ManastormAudioHandle {
return new Audio(url);
let stopPlayback: (() => void) | null = null;
let stopped = false;
const handle: ManastormAudioHandle = {
currentTime: 0,
preload: "auto",
volume: 1,
pause: () => {
stopped = true;
stopPlayback?.();
stopPlayback = null;
},
play: async () => {
stopped = false;
const stop = await playMasterAudioCue(url, handle.volume);
if (!stop) throw new Error("Web Audio is unavailable.");
if (stopped) stop();
else stopPlayback = stop;
},
};
return handle;
}
export async function playManastormClientCue(
cueId: ManastormPortalCueId,
audioFactory: ManastormAudioFactory = browserAudioFactory,
): Promise<boolean> {
if (audioFactory === browserAudioFactory && typeof Audio === "undefined") return false;
const cue = manastormPortalAudioCue(cueId);
try {
activeAudio?.pause();
@@ -149,3 +167,4 @@ export function stopManastormClientCue(): void {
activeAudio?.pause();
activeAudio = null;
}
import { playMasterAudioCue } from "./combatAudio";
+99
View File
@@ -20,6 +20,11 @@ import {
type WotlkAttackKind,
} from "./wotlkStats";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import {
emitCombatPresentation,
type CombatPresentationDelivery,
type CombatPresentationSchool,
} from "./combatPresentation";
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
const ATTACK_ACQUISITION_RANGE = 30;
@@ -30,6 +35,43 @@ const TANK_TAUNT_DURATION_MS = 3_000;
let lastAdvanceAt = 0;
let nextRegenAt = 0;
interface PartyPresentationIdentity {
readonly spellId: number;
readonly name: string;
readonly school: CombatPresentationSchool;
readonly delivery: CombatPresentationDelivery;
}
function partyAttackPresentation(member: PartyMember): PartyPresentationIdentity {
if (!partyUsesRangedAttacks(member)) {
const spellId = member.classId === "rogue" ? 1752
: member.classId === "death-knight" ? 45462
: member.classId === "paladin" ? 20271
: 78;
return { spellId, name: "Weapon Attack", school: "physical", delivery: "melee" };
}
if (member.classId === "hunter") return { spellId: 56641, name: "Steady Shot", school: "physical", delivery: "projectile" };
if (member.classId === "mage") return { spellId: 116, name: "Frostbolt", school: "frost", delivery: "projectile" };
if (member.classId === "warlock") return { spellId: 686, name: "Shadow Bolt", school: "shadow", delivery: "projectile" };
if (member.classId === "priest") return { spellId: 585, name: "Smite", school: "holy", delivery: "projectile" };
if (member.classId === "druid") return { spellId: 5176, name: "Wrath", school: "nature", delivery: "projectile" };
return { spellId: 403, name: "Lightning Bolt", school: "nature", delivery: "projectile" };
}
function partyHealPresentation(member: PartyMember): PartyPresentationIdentity {
if (member.classId === "paladin") return { spellId: 635, name: "Holy Light", school: "holy", delivery: "projectile" };
if (member.classId === "shaman") return { spellId: 331, name: "Healing Wave", school: "nature", delivery: "projectile" };
if (member.classId === "druid") return { spellId: 5185, name: "Healing Touch", school: "nature", delivery: "projectile" };
return { spellId: 2061, name: "Flash Heal", school: "holy", delivery: "projectile" };
}
function partyTauntSpellId(member: PartyMember): number {
if (member.classId === "paladin") return 62124;
if (member.classId === "death-knight") return 56222;
if (member.classId === "druid") return 6795;
return 355;
}
export interface PartyBossObjective {
readonly id: string;
readonly name: string;
@@ -455,6 +497,22 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const tauntSource: ThreatSource = { actorId: member.id, role: "tank" };
if (currentCombat.tauntMob(memberTargetId, tauntSource, TANK_TAUNT_DURATION_MS, now)) {
runtimeMember = { ...runtimeMember, tauntReadyAt: now + TANK_TAUNT_COOLDOWN_MS };
const tauntPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: member.id,
targetActorId: memberTargetId,
abilityId: `party-taunt:${member.id}`,
sourceSpellId: partyTauntSpellId(member),
name: "Taunt",
source: "wow335",
school: "physical",
delivery: "aura",
origin: memberPosition,
targetPosition,
} as const;
emitCombatPresentation({ ...tauntPresentation, phase: "release" });
emitCombatPresentation({ ...tauntPresentation, phase: "aura-start" });
}
}
if (now < member.nextActionAt) {
@@ -483,6 +541,23 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
now,
{ actorId: member.id, role: member.role, abilityId: `party:${member.id}` },
);
const presentation = partyAttackPresentation(member);
const attackPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: member.id,
targetActorId: memberTargetId,
abilityId: `party:${member.id}`,
sourceSpellId: presentation.spellId,
name: presentation.name,
source: "wow335",
school: presentation.school,
delivery: presentation.delivery,
origin: memberPosition,
targetPosition: targetPosition!,
} as const;
emitCombatPresentation({ ...attackPresentation, phase: "release" });
emitCombatPresentation({ ...attackPresentation, phase: "impact" });
nextMembers[index] = {
...runtimeMember,
status: desiredStatus,
@@ -531,6 +606,30 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
{ actorId: healer.id, role: healer.role },
support.effectiveHealing,
);
const presentation = partyHealPresentation(healer);
const healerPosition = getPartyRuntimePosition(healer.id) ?? currentPlayer.playerPosition;
const presentationTargetId = support.targetId === "__player__" ? PLAYER_AGGRO_ID : support.targetId;
const targetPosition = presentationTargetId === PLAYER_AGGRO_ID
? currentPlayer.playerPosition
: presentationTargetId
? getPartyRuntimePosition(presentationTargetId)
: null;
const healPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: healer.id,
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
abilityId: `party-heal:${healer.id}`,
sourceSpellId: presentation.spellId,
name: presentation.name,
source: "wow335",
school: presentation.school,
delivery: presentation.delivery,
origin: healerPosition,
targetPosition: targetPosition ?? healerPosition,
} as const;
emitCombatPresentation({ ...healPresentation, phase: "release" });
emitCombatPresentation({ ...healPresentation, phase: "impact" });
}
const targetMapChanged = (
+1 -1
View File
@@ -147,7 +147,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
}
}
}
}, 60_000);
}, 120_000);
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
const recipes = await Promise.all(
+330
View File
@@ -0,0 +1,330 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
AdditiveBlending,
Color,
DoubleSide,
Group,
MeshBasicMaterial,
PointsMaterial,
Vector3,
} from "three";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
import {
combatEffectStyle,
preloadCombatEffectManifest,
subscribeCombatPresentation,
type CombatPresentationEvent,
type CombatVfxQuality,
} from "../game/combatPresentation";
import {
configureCombatAudio,
playCombatAudio,
resetCombatAudio,
setCombatAudioListener,
stopCombatAudioGroup,
} from "../game/combatAudio";
interface ActiveCombatEffect {
readonly key: string;
readonly event: CombatPresentationEvent;
}
export interface CombatEffectsProps {
readonly active: boolean;
readonly safeGraphics?: boolean;
}
const QUALITY_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
off: 0,
low: 24,
high: 64,
};
const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">, number>> = {
low: 48,
high: 128,
};
function effectDurationMs(event: CombatPresentationEvent): number {
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
if (event.phase === "tick") return 320;
if (event.phase === "impact") return 420;
if (event.delivery === "melee") return 280;
if (event.delivery === "area") return 850;
if (event.delivery === "aura") return 950;
if (event.delivery === "channel") return 620;
if (event.delivery === "projectile" && event.targetPosition) {
const dx = event.targetPosition[0] - event.origin[0];
const dy = event.targetPosition[1] - event.origin[1];
const dz = event.targetPosition[2] - event.origin[2];
return Math.max(180, Math.min(550, Math.hypot(dx, dy, dz) / 28 * 1_000));
}
return 460;
}
function deterministicParticles(seed: number, count: number): Float32Array {
const result = new Float32Array(count * 3);
for (let index = 0; index < count; index += 1) {
const angle = index * 2.399963229728653 + seed * 0.17;
const normalized = (index + 0.5) / count;
const radius = 0.12 + Math.sqrt(normalized) * 0.72;
result[index * 3] = Math.cos(angle) * radius;
result[index * 3 + 1] = (normalized - 0.45) * 1.4 + Math.sin(seed + index * 1.73) * 0.16;
result[index * 3 + 2] = Math.sin(angle) * radius;
}
return result;
}
function CombatEffectPrimitive({
event,
quality,
active,
onDone,
}: {
readonly event: CombatPresentationEvent;
readonly quality: Exclude<CombatVfxQuality, "off">;
readonly active: boolean;
readonly onDone: () => void;
}) {
const groupRef = useRef<Group>(null);
const pointsMaterialRef = useRef<PointsMaterial>(null);
const primaryMaterialRef = useRef<MeshBasicMaterial>(null);
const secondaryMaterialRef = useRef<MeshBasicMaterial>(null);
const elapsedRef = useRef(0);
const doneRef = useRef(false);
const style = useMemo(() => combatEffectStyle(event), [event]);
const particleCount = QUALITY_PARTICLE_COUNT[quality];
const positions = useMemo(
() => deterministicParticles(event.id, particleCount),
[event.id, particleCount],
);
const origin = useMemo(() => new Vector3(...event.origin), [event.origin]);
const target = useMemo(
() => new Vector3(...(event.targetPosition ?? event.origin)),
[event.origin, event.targetPosition],
);
const durationMs = effectDurationMs(event);
const projectile = event.phase === "release"
&& event.delivery === "projectile"
&& event.targetPosition !== undefined;
const effectPosition = projectile
? origin
: event.phase === "cast-start"
? origin
: target;
const isGround = event.delivery === "area";
const initialY = effectPosition.y + (isGround ? 0.08 : event.delivery === "melee" ? 1 : 1.15);
useFrame((_state, delta) => {
if (!active || doneRef.current || !groupRef.current) return;
elapsedRef.current += Math.min(0.05, delta) * 1_000;
const progress = Math.min(1, elapsedRef.current / durationMs);
const eased = 1 - (1 - progress) ** 3;
if (projectile) {
groupRef.current.position.lerpVectors(origin, target, eased);
groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28;
groupRef.current.scale.setScalar(0.72 + Math.sin(progress * Math.PI) * 0.32);
} else {
groupRef.current.rotation.y += delta * (event.school === "physical" ? 4 : 1.8);
const scale = event.phase === "cast-start"
? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08
: 0.35 + eased * (event.delivery === "area" ? Math.max(1.2, event.radius ?? 2.4) : 1.75);
groupRef.current.scale.setScalar(scale);
if (!isGround && event.delivery !== "melee") groupRef.current.position.y = initialY + progress * 0.55;
}
const opacity = event.phase === "cast-start"
? Math.min(1, (1 - progress) * 1.8)
: Math.max(0, 1 - progress);
if (pointsMaterialRef.current) opacity >= 0 && (pointsMaterialRef.current.opacity = opacity * 0.9);
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72;
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55;
if (progress >= 1) {
doneRef.current = true;
onDone();
}
});
return (
<group
ref={groupRef}
position={[effectPosition.x, initialY, effectPosition.z]}
rotation={isGround ? [-Math.PI / 2, 0, 0] : [0, 0, event.delivery === "melee" ? -0.55 : 0]}
>
<points>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
</bufferGeometry>
<pointsMaterial
ref={pointsMaterialRef}
color={style.primary}
size={quality === "high" ? 0.15 : 0.19}
sizeAttenuation
transparent
opacity={0.9}
depthWrite={false}
blending={AdditiveBlending}
/>
</points>
{event.delivery === "melee" ? (
<mesh rotation={[0, 0.2, Math.PI / 2]}>
<torusGeometry args={[0.7, 0.075, 8, 32, Math.PI * 1.35]} />
<meshBasicMaterial
ref={primaryMaterialRef}
color={style.primary}
transparent
opacity={0.75}
depthWrite={false}
side={DoubleSide}
blending={AdditiveBlending}
/>
</mesh>
) : event.delivery === "area" ? (
<>
<mesh>
<ringGeometry args={[0.72, 1, 48]} />
<meshBasicMaterial
ref={primaryMaterialRef}
color={style.primary}
transparent
opacity={0.62}
depthWrite={false}
side={DoubleSide}
blending={AdditiveBlending}
/>
</mesh>
<mesh scale={0.68}>
<ringGeometry args={[0.82, 1, 32]} />
<meshBasicMaterial
ref={secondaryMaterialRef}
color={style.secondary}
transparent
opacity={0.5}
depthWrite={false}
side={DoubleSide}
blending={AdditiveBlending}
/>
</mesh>
</>
) : (
<>
<mesh>
<sphereGeometry args={[projectile ? 0.28 : 0.44, 12, 8]} />
<meshBasicMaterial
ref={primaryMaterialRef}
color={style.primary}
transparent
opacity={0.7}
depthWrite={false}
blending={AdditiveBlending}
/>
</mesh>
<mesh scale={projectile ? 0.52 : 1.18}>
<sphereGeometry args={[0.32, 10, 7]} />
<meshBasicMaterial
ref={secondaryMaterialRef}
color={style.secondary}
transparent
opacity={0.45}
wireframe={!projectile}
depthWrite={false}
blending={AdditiveBlending}
/>
</mesh>
</>
)}
{quality === "high" && event.school !== "physical" ? (
<pointLight color={new Color(style.primary)} intensity={style.emissive * 1.5} distance={5} decay={2} />
) : null}
</group>
);
}
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
const settings = useShellStore((state) => state.combatPresentationSettings);
const masterVolume = useCombatStore((state) => state.settings.masterVolume);
const [effects, setEffects] = useState<readonly ActiveCombatEffect[]>([]);
const settingsRef = useRef(settings);
const activeRef = useRef(active);
const effectiveQuality: CombatVfxQuality = safeGraphics && settings.vfxQuality === "high"
? "low"
: settings.vfxQuality;
const qualityRef = useRef(effectiveQuality);
const camera = useThree((state) => state.camera);
const forward = useMemo(() => new Vector3(), []);
useEffect(() => {
settingsRef.current = settings;
qualityRef.current = safeGraphics && settings.vfxQuality === "high" ? "low" : settings.vfxQuality;
configureCombatAudio(settings, masterVolume);
if (
qualityRef.current !== "off"
|| Object.values(settings.audioEnabled).some(Boolean)
) void preloadCombatEffectManifest();
setEffects((current) => current.filter(({ event }) => (
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
)));
for (const group of ["player", "party", "enemy"] as const) {
if (!settings.audioEnabled[group] || settings.audioVolume[group] <= 0) stopCombatAudioGroup(group);
}
}, [masterVolume, safeGraphics, settings]);
useEffect(() => {
activeRef.current = active;
}, [active]);
useEffect(() => subscribeCombatPresentation((message) => {
if (message.kind === "reset") {
setEffects([]);
resetCombatAudio();
return;
}
const event = message.event;
if (activeRef.current) playCombatAudio(event);
setEffects((current) => {
const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
? current.filter((effect) => !(
effect.event.phase === "cast-start"
&& effect.event.sourceActorId === event.sourceActorId
&& effect.event.abilityId === event.abilityId
))
: current;
const presentationSettings = settingsRef.current;
const quality = qualityRef.current;
if (
event.phase === "cast-cancel"
|| !activeRef.current
|| quality === "off"
|| !presentationSettings.vfxEnabled[event.actorGroup]
) return withoutCast;
const limit = QUALITY_SYSTEM_LIMIT[quality];
return [...withoutCast, { key: `${event.sessionId}:${event.id}`, event }].slice(-limit);
});
}), []);
useEffect(() => () => resetCombatAudio(), []);
useFrame(() => {
camera.getWorldDirection(forward);
setCombatAudioListener(
[camera.position.x, camera.position.y, camera.position.z],
[forward.x, forward.y, forward.z],
);
});
if (effectiveQuality === "off") return null;
return (
<group name="combat-effects">
{effects.map((effect) => (
<CombatEffectPrimitive
key={effect.key}
event={effect.event}
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
/>
))}
</group>
);
}
+2
View File
@@ -37,6 +37,7 @@ import {
} from "../game/wailingCavernsEncounter";
import { WailingNaralexEvent } from "./WailingNaralexEvent";
import { GameGltfLoaderLifecycle } from "./useGameGLTF";
import { CombatEffects } from "./CombatEffects";
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -240,6 +241,7 @@ export function GameScene() {
/>
<WailingNaralexEvent />
<ManastormPortal />
<CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} />
</>
)}
</Physics>
+20
View File
@@ -819,6 +819,7 @@ function ProxyMob({
}: ProxyMobProps) {
const mob = useCombatStore((state) => state.mobs[instanceId]);
const selected = useCombatStore((state) => state.selectedTargetId === instanceId);
const showAggroRanges = useCombatStore((state) => state.settings.showMobAggroRanges);
const gameMode = useGameStore((state) => state.gameMode);
const characterLevel = useCombatStore((state) => state.level);
const manastormLevel = useManastormStore((state) => state.level);
@@ -872,6 +873,7 @@ function ProxyMob({
damageMultiplier: baseDamageMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1),
bonusLootChance: manastormModifiers?.bonusLootChance,
moveSpeed: definition.combat?.moveSpeed,
aggroRange: definition.combat?.aggroRange,
leashRange: definition.combat?.leashRange,
attacks: definition.combat?.attacks,
xpReward: gameMode === "manastorm"
@@ -952,6 +954,24 @@ function ProxyMob({
)}
</AnimatedMobBody>
{showAggroRanges && gameMode === "dungeon" && mob && !dead && !mob.engaged ? (
<mesh
name={`mob-aggro-range-${instanceId}`}
position={[0, 0.045, 0]}
rotation={[Math.PI / 2, 0, 0]}
renderOrder={2}
>
<torusGeometry args={[mob.aggroRange, boss ? 0.075 : 0.05, 4, 56]} />
<meshBasicMaterial
color={boss ? "#e59a55" : "#c96b55"}
transparent
opacity={boss ? 0.62 : 0.5}
depthTest={false}
depthWrite={false}
/>
</mesh>
) : null}
{boss && !dead ? (
<>
<mesh position={[0, 0.08, 0]} rotation={[Math.PI / 2, 0, 0]}>
+28 -6
View File
@@ -70,10 +70,13 @@ button { font: inherit; }
.manastorm-affixes img { width: 10px; height: 10px; border-radius: 2px; object-fit: cover; }
.manastorm-objective {
position: absolute;
top: max(72px, calc(env(safe-area-inset-top) + 60px));
left: 50%;
top: max(184px, calc(env(safe-area-inset-top) + 172px));
right: max(12px, env(safe-area-inset-right));
left: auto;
display: flex;
width: min(260px, calc(100vw - 24px));
align-items: center;
justify-content: flex-start;
gap: 7px;
padding: 5px 10px;
border: 1px solid rgba(174,203,211,.2);
@@ -82,8 +85,9 @@ button { font: inherit; }
color: #d8e7e8;
font-size: 7px;
letter-spacing: .08em;
text-align: left;
text-transform: uppercase;
transform: translateX(-50%);
transform: none;
backdrop-filter: blur(7px);
}
.manastorm-objective > span { color: #ac73ef; font-size: 12px; }
@@ -1021,6 +1025,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.spellbook-panel__ranks li > p { grid-column: 1 / -1; margin: 2px 0 0; color: #b8c0b5; font-size: 8px; line-height: 1.35; }
.talents-panel { width: min(1180px, 100%); max-height: calc(100vh - 28px); }
.talents-panel--loading .game-panel__empty { display: grid; min-height: min(360px, 55vh); place-items: center; margin: 0; }
.talents-panel__points { display: grid; grid-template-columns: auto auto; align-items: center; gap: 7px; }
.talents-panel__points strong { color: #f0d477; font: 700 27px Georgia, serif; }
.talents-panel__points span { max-width: 55px; color: var(--muted); font-size: 8px; line-height: 1.1; text-transform: uppercase; }
@@ -1096,7 +1101,8 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.options-panel { width: min(670px, 100%); }
.options-panel__list { margin-top: 10px; }
.option-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; min-height: 42px; padding: 6px 2px; border-bottom: 1px solid rgba(198,214,170,.09); }
.option-row > span strong, .option-row > span small { display: block; }
.option-row > span { min-width: 0; }
.option-row > span strong, .option-row > span small { display: block; overflow-wrap: anywhere; }
.option-row > span strong { font-size: 11px; }
.option-row > span small { margin-top: 2px; color: var(--muted); font-size: 8px; }
.option-row button { min-width: 68px; min-height: 29px; border: 1px solid rgba(191,209,159,.18); border-radius: 7px; background: rgba(255,255,255,.04); color: var(--ink); cursor: pointer; }
@@ -1105,6 +1111,9 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.option-stepper button { min-width: 30px; }
.option-stepper output { font-size: 10px; text-align: center; }
.options-panel__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.options-panel__section-title { margin: 13px 0 3px; color: #f0d477; font: 700 13px Georgia, serif; letter-spacing: .04em; }
.options-panel__combat-group { display: contents; }
.option-audio-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.ability-bindings-panel { width: min(980px, 100%); max-height: calc(100vh - 28px); }
.ability-bindings__layers { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 10px 0; }
@@ -1282,6 +1291,9 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.action-bar__detail { width: 94px; margin-bottom: 18px; }
.target-frame { top: 91px; width: 190px; }
.spellbook-panel__list { grid-template-columns: 1fr; }
.options-panel .option-row { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.options-panel .option-audio-controls { grid-column: 1 / -1; width: 100%; justify-content: space-between; }
.options-panel__actions { position: sticky; bottom: -1px; padding-top: 8px; background: linear-gradient(transparent, rgba(5,14,10,.99) 28%); }
.talents-panel__workspace { grid-template-columns: minmax(0, 1fr); }
.talent-tree-board { max-width: 250px; }
.talent-tree-board__canvas { max-width: 202px; }
@@ -2370,17 +2382,27 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
.manastorm-status > div strong { font-size: 14px; }
.manastorm-status > p b { font-size: 9px; }
.manastorm-status > p small { font-size: 5px; }
.manastorm-objective { top: 49px; padding-block: 3px; }
.manastorm-objective { top: max(156px, calc(env(safe-area-inset-top) + 144px)); width: min(220px, calc(100vw - 24px)); padding-block: 3px; }
.manastorm-client-overlay { top: 76px; }
.manastorm-countdown { min-width: 66px; padding-block: 4px; }
.manastorm-safety-bubble { width: 156px; padding-block: 4px 5px; }
.hud:has(.manastorm-client-overlay) .manastorm-cache-open { top: 120px; }
}
@media (max-width: 520px) {
.manastorm-objective {
top: max(104px, calc(env(safe-area-inset-top) + 94px));
right: 7px;
width: 92px;
padding-inline: 6px;
line-height: 1.35;
}
}
@media (max-width: 390px) {
.manastorm-status { left: 8px; min-width: 0; max-width: calc(100vw - 116px); transform: none; }
.manastorm-status > p small { overflow: hidden; max-width: 145px; text-overflow: ellipsis; white-space: nowrap; }
.manastorm-objective { top: 49px; left: 8px; max-width: calc(100vw - 116px); transform: none; }
.manastorm-objective { left: auto; max-width: 92px; }
.manastorm-client-overlay { right: 8px; left: auto; max-width: calc(100vw - 16px); transform: none; }
}
+46 -3
View File
@@ -1,5 +1,6 @@
import { lazy, Suspense, useMemo } from "react";
import { lazy, Suspense, useEffect, useMemo } from "react";
import { classById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
import {
abilityAtLevel,
@@ -13,7 +14,41 @@ import { EquipmentPanel } from "./EquipmentPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
const PartyManagementPanel = lazy(() => import("./PartyManagementPanel").then((module) => ({ default: module.PartyManagementPanel })));
const TalentGameplayPanel = lazy(() => import("./TalentGameplayPanel"));
let talentGameplayPanelModule: Promise<typeof import("./TalentGameplayPanel")> | null = null;
function loadTalentGameplayPanel() {
if (!talentGameplayPanelModule) {
talentGameplayPanelModule = import("./TalentGameplayPanel").catch((error: unknown) => {
talentGameplayPanelModule = null;
throw error;
});
}
return talentGameplayPanelModule;
}
function preloadTalentGameplayPanel() {
void loadTalentGameplayPanel().catch(() => undefined);
}
const TalentGameplayPanel = lazy(loadTalentGameplayPanel);
function TalentsPanelLoading() {
const openOverlay = useGameStore((state) => state.openOverlay);
return (
<div className="modal-backdrop game-panel-backdrop" role="presentation">
<section className="game-panel talents-panel talents-panel--loading" role="dialog" aria-modal="true" aria-labelledby="talents-loading-title">
<header className="game-panel__header">
<div>
<p className="eyebrow">Specialization</p>
<h2 id="talents-loading-title">Talents</h2>
</div>
<button type="button" className="icon-button" onClick={() => openOverlay("pause")} aria-label="Return to pause menu">×</button>
</header>
<p className="game-panel__empty" role="status">Preparing talent trees</p>
</section>
</div>
);
}
/** Maps the data-driven combat catalogs into the full-screen game panels. */
export function GameplayPanels() {
@@ -22,9 +57,14 @@ export function GameplayPanels() {
const abilities = useCombatStore((state) => state.abilities);
const talentRanks = useCombatStore((state) => state.talentRanks);
const settings = useCombatStore((state) => state.settings);
const presentationSettings = useShellStore((state) => state.combatPresentationSettings);
const overlay = useGameStore((state) => state.overlay);
const classDefinition = classById(classId);
useEffect(() => {
if (overlay === "pause" && !classId.startsWith("rom-")) preloadTalentGameplayPanel();
}, [classId, overlay]);
const spellbookAbilities = useMemo(() => [...abilities]
.sort((left, right) => left.unlockLevel - right.unlockLevel)
.map((catalogAbility) => {
@@ -76,12 +116,15 @@ export function GameplayPanels() {
{overlay === "talents"
? classId.startsWith("rom-")
? <RomSkillsPanel />
: <Suspense fallback={null}><TalentGameplayPanel /></Suspense>
: <Suspense fallback={<TalentsPanelLoading />}><TalentGameplayPanel /></Suspense>
: null}
<OptionsPanel
settings={settings}
onChange={(patch) => useCombatStore.getState().updateSettings(patch)}
onReset={() => useCombatStore.getState().resetSettings()}
presentationSettings={presentationSettings}
onPresentationChange={(patch) => useShellStore.getState().updateCombatPresentationSettings(patch)}
onPresentationReset={() => useShellStore.getState().resetCombatPresentationSettings()}
/>
</>
);
+101 -5
View File
@@ -6,9 +6,15 @@ import {
} from "../game/hudLayout";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import {
type CombatActorGroup,
type CombatPresentationSettings,
type CombatVfxQuality,
} from "../game/combatPresentation";
export interface OptionsPanelSettings {
showMobHealthBars: boolean;
showMobAggroRanges: boolean;
showTargetFrame: boolean;
showThreatMeter: boolean;
threatMeterPosition: NormalizedHudPosition;
@@ -24,19 +30,23 @@ export interface OptionsPanelProps {
settings: OptionsPanelSettings;
onChange: (patch: Partial<OptionsPanelSettings>) => void;
onReset: () => void;
presentationSettings: CombatPresentationSettings;
onPresentationChange: (patch: Partial<CombatPresentationSettings>) => void;
onPresentationReset: () => void;
open?: boolean;
onClose?: () => void;
}
interface ToggleOption {
id: keyof Pick<OptionsPanelSettings,
"showMobHealthBars" | "showTargetFrame" | "showThreatMeter" | "showFloatingCombatText" | "autoTargetNearest" | "enableScreenShake">;
"showMobHealthBars" | "showMobAggroRanges" | "showTargetFrame" | "showThreatMeter" | "showFloatingCombatText" | "autoTargetNearest" | "enableScreenShake">;
label: string;
description: string;
}
const TOGGLES: readonly ToggleOption[] = [
{ id: "showMobHealthBars", label: "Mob health bars", description: "Show health above nearby enemies." },
{ id: "showMobAggroRanges", label: "Mob aggro ranges", description: "Show the boundary where idle dungeon enemies automatically attack your party." },
{ id: "showTargetFrame", label: "Target frame", description: "Show detailed status for your current target." },
{ id: "showThreatMeter", label: "Threat meter", description: "Show the compact movable threat table for your selected enemy." },
{ id: "showFloatingCombatText", label: "Combat numbers", description: "Show damage, healing, and absorb feedback." },
@@ -44,12 +54,39 @@ const TOGGLES: readonly ToggleOption[] = [
{ id: "enableScreenShake", label: "Screen shake", description: "Use restrained camera impact for major hits." },
] as const;
const COMBAT_ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
const VFX_QUALITY_ORDER: readonly CombatVfxQuality[] = ["off", "low", "high"];
function actorGroupLabel(group: CombatActorGroup): string {
return group === "enemy" ? "Enemies" : `${group[0].toUpperCase()}${group.slice(1)}`;
}
function quantize(value: number, step: number, minimum: number, maximum: number): number {
const next = Math.round(value / step) * step;
return Math.max(minimum, Math.min(maximum, Number(next.toFixed(2))));
}
function OptionsDialog({ settings, onChange, onReset, onClose }: Required<Omit<OptionsPanelProps, "open">>) {
function OptionsDialog({
settings,
onChange,
onReset,
presentationSettings,
onPresentationChange,
onPresentationReset,
onClose,
}: Required<Omit<OptionsPanelProps, "open">>) {
const patchGroupBoolean = (
field: "vfxEnabled" | "audioEnabled",
group: CombatActorGroup,
value: boolean,
) => onPresentationChange({ [field]: { ...presentationSettings[field], [group]: value } });
const patchGroupVolume = (group: CombatActorGroup, value: number) => onPresentationChange({
audioVolume: { ...presentationSettings.audioVolume, [group]: quantize(value, 0.1, 0, 1) },
});
const cycleVfxQuality = () => {
const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality);
onPresentationChange({ vfxQuality: VFX_QUALITY_ORDER[(index + 1) % VFX_QUALITY_ORDER.length] });
};
const actions = useMemo<MenuAction[]>(() => [
...TOGGLES.map((option) => ({
id: `option-${option.id}`,
@@ -61,8 +98,27 @@ function OptionsDialog({ settings, onChange, onReset, onClose }: Required<Omit<O
{ id: "option-scale-up", run: () => onChange({ uiScale: quantize(settings.uiScale + 0.05, 0.05, 0.75, 1.25) }) },
{ id: "option-minimap", run: () => onChange({ minimapRotation: settings.minimapRotation === "north-up" ? "player-up" : "north-up" }) },
{ id: "option-threat-position", run: () => onChange({ threatMeterPosition: DEFAULT_THREAT_METER_POSITION }) },
{ id: "option-reset", run: onReset },
], [onChange, onReset, settings]);
{ id: "option-vfx-quality", run: cycleVfxQuality },
...COMBAT_ACTOR_GROUPS.flatMap((group) => [
{
id: `option-vfx-${group}`,
run: () => patchGroupBoolean("vfxEnabled", group, !presentationSettings.vfxEnabled[group]),
},
{
id: `option-audio-${group}`,
run: () => patchGroupBoolean("audioEnabled", group, !presentationSettings.audioEnabled[group]),
},
{
id: `option-audio-${group}-down`,
run: () => patchGroupVolume(group, presentationSettings.audioVolume[group] - 0.1),
},
{
id: `option-audio-${group}-up`,
run: () => patchGroupVolume(group, presentationSettings.audioVolume[group] + 0.1),
},
]),
{ id: "option-reset", run: () => { onReset(); onPresentationReset(); } },
], [onChange, onPresentationChange, onPresentationReset, onReset, presentationSettings, settings]);
const controller = useMenuController(actions, { columns: 1, onBack: onClose });
const optionButton = (id: string, label: string, run: () => void, disabled = false) => (
@@ -127,10 +183,50 @@ function OptionsDialog({ settings, onChange, onReset, onClose }: Required<Omit<O
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
}))}
</div>
<h3 className="options-panel__section-title">Combat presentation</h3>
<div className="option-row">
<span><strong>VFX quality</strong><small>High uses the full particle budget; Low reduces particles and lights; Off skips combat VFX.</small></span>
{optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}
</div>
{COMBAT_ACTOR_GROUPS.map((group) => (
<div className="options-panel__combat-group" key={group}>
<div className="option-row">
<span><strong>{actorGroupLabel(group)} VFX</strong><small>Show attacks, spells, trails, impacts, and auras from this group.</small></span>
<ControllerButton
controlId={`option-vfx-${group}`}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={presentationSettings.vfxEnabled[group] ? "is-on" : ""}
aria-pressed={presentationSettings.vfxEnabled[group]}
onClick={() => patchGroupBoolean("vfxEnabled", group, !presentationSettings.vfxEnabled[group])}
>{presentationSettings.vfxEnabled[group] ? "On" : "Off"}</ControllerButton>
</div>
<div className="option-row">
<span><strong>{actorGroupLabel(group)} combat audio</strong><small>Mute or mix ability and weapon sounds from this group.</small></span>
<div className="option-audio-controls">
<ControllerButton
controlId={`option-audio-${group}`}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={presentationSettings.audioEnabled[group] ? "is-on" : ""}
aria-pressed={presentationSettings.audioEnabled[group]}
onClick={() => patchGroupBoolean("audioEnabled", group, !presentationSettings.audioEnabled[group])}
>{presentationSettings.audioEnabled[group] ? "On" : "Muted"}</ControllerButton>
<div className="option-stepper">
{optionButton(`option-audio-${group}-down`, "", () => patchGroupVolume(group, presentationSettings.audioVolume[group] - 0.1), presentationSettings.audioVolume[group] <= 0)}
<output>{Math.round(presentationSettings.audioVolume[group] * 100)}%</output>
{optionButton(`option-audio-${group}-up`, "+", () => patchGroupVolume(group, presentationSettings.audioVolume[group] + 0.1), presentationSettings.audioVolume[group] >= 1)}
</div>
</div>
</div>
</div>
))}
</div>
<div className="options-panel__actions">
<ControllerButton controlId="option-reset" selectedId={controller.selectedId} select={controller.select} type="button" className="button" onClick={onReset}>Restore defaults</ControllerButton>
<ControllerButton controlId="option-reset" selectedId={controller.selectedId} select={controller.select} type="button" className="button" onClick={() => { onReset(); onPresentationReset(); }}>Restore defaults</ControllerButton>
<button type="button" className="button button--primary" onClick={onClose}>Done</button>
</div>
<footer className="game-panel__footer"><span><kbd>A</kbd> Change</span><span><kbd>B</kbd> Pause menu</span></footer>
+16
View File
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const gameplayPanelsSource = readFileSync(
new URL("./GameplayPanels.tsx", import.meta.url),
"utf8",
);
describe("gameplay panel loading integration", () => {
it("starts loading talents from the pause menu and keeps a visible first-open surface", () => {
expect(gameplayPanelsSource).toContain('overlay === "pause"');
expect(gameplayPanelsSource).toContain("preloadTalentGameplayPanel()");
expect(gameplayPanelsSource).toContain("fallback={<TalentsPanelLoading />}");
expect(gameplayPanelsSource).not.toContain("<Suspense fallback={null}><TalentGameplayPanel />");
});
});