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
+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 />");
});
});