Release Healer Man 0.1.4
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 />");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user