Release Healer Man 0.1.4

This commit is contained in:
phenom committed 2026-08-18 12:06:04 -04:00
1 parent 5bb170039a
commit f4c9cf356c
63 files changed
+2694 -81

No files matched your search

+3
View File
@@ -115,6 +115,9 @@ export function CombatBridge() {
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (game.gameMode === "dungeon") {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
advanceDungeonPartyCombat(epochNow);
if (game.gameMode === "dungeon" && game.activeDungeonId === "wailing-caverns") {
+295
View File
@@ -0,0 +1,295 @@
import {
combatEffectManifestEntry,
type CombatActorGroup,
type CombatEffectSoundAsset,
type CombatPresentationEvent,
type CombatPresentationSettings,
type CombatPresentationSchool,
} from "./combatPresentation";
const MAX_COMBAT_VOICES = 32;
interface ActiveVoice {
readonly group: CombatActorGroup;
readonly stop: () => void;
}
let context: AudioContext | null = null;
let masterGain: GainNode | null = null;
let groupGains: Record<CombatActorGroup, GainNode> | null = null;
let configuredSettings: CombatPresentationSettings | null = null;
let configuredMasterVolume = 0.8;
const activeVoices: ActiveVoice[] = [];
const activeMasterVoices = new Set<() => void>();
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
function audioContextConstructor(): typeof AudioContext | null {
if (typeof window === "undefined") return null;
return window.AudioContext
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
?? null;
}
function ensureContext(): AudioContext | null {
if (context) return context;
const Constructor = audioContextConstructor();
if (!Constructor) return null;
context = new Constructor();
masterGain = context.createGain();
masterGain.connect(context.destination);
groupGains = {
player: context.createGain(),
party: context.createGain(),
enemy: context.createGain(),
};
for (const gain of Object.values(groupGains)) gain.connect(masterGain);
applyCombatAudioSettings();
return context;
}
function applyCombatAudioSettings(): void {
if (!context || !masterGain || !groupGains || !configuredSettings) return;
masterGain.gain.setTargetAtTime(configuredMasterVolume, context.currentTime, 0.02);
for (const group of ["player", "party", "enemy"] as const) {
const volume = configuredSettings.audioEnabled[group]
? configuredSettings.audioVolume[group]
: 0;
groupGains[group].gain.setTargetAtTime(volume, context.currentTime, 0.02);
if (volume <= 0) stopCombatAudioGroup(group);
}
}
export function configureCombatAudio(
settings: CombatPresentationSettings,
masterVolume: number,
): void {
configuredSettings = settings;
configuredMasterVolume = Number.isFinite(masterVolume)
? Math.max(0, Math.min(1, masterVolume))
: 0.8;
applyCombatAudioSettings();
}
function positionPanner(
audioContext: AudioContext,
event: CombatPresentationEvent,
): PannerNode | StereoPannerNode {
if (typeof audioContext.createPanner === "function") {
const panner = audioContext.createPanner();
panner.panningModel = "HRTF";
panner.distanceModel = "inverse";
panner.refDistance = event.actorGroup === "player" ? 8 : 5;
panner.maxDistance = 55;
panner.rolloffFactor = event.actorGroup === "player" ? 0.15 : 0.8;
panner.positionX.value = event.origin[0];
panner.positionY.value = event.origin[1];
panner.positionZ.value = event.origin[2];
return panner;
}
return audioContext.createStereoPanner();
}
function trimVoices(): void {
while (activeVoices.length >= MAX_COMBAT_VOICES) activeVoices.shift()?.stop();
}
function registerVoice(voice: ActiveVoice): () => void {
trimVoices();
activeVoices.push(voice);
return () => {
const index = activeVoices.indexOf(voice);
if (index >= 0) activeVoices.splice(index, 1);
};
}
const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
physical: 115,
arcane: 520,
fire: 190,
frost: 720,
nature: 360,
shadow: 145,
holy: 660,
};
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
const audioContext = ensureContext();
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
void audioContext.resume().catch(() => undefined);
const oscillator = audioContext.createOscillator();
const envelope = audioContext.createGain();
const panner = positionPanner(audioContext, event);
const start = audioContext.currentTime;
const isImpact = event.phase === "impact" || event.delivery === "melee";
const duration = event.phase === "cast-start" ? 0.22 : isImpact ? 0.12 : event.phase === "tick" ? 0.1 : 0.18;
const base = SCHOOL_FREQUENCY[event.school];
oscillator.type = event.school === "physical" ? "sawtooth" : event.school === "holy" ? "sine" : "triangle";
oscillator.frequency.setValueAtTime(base * (event.phase === "cast-start" ? 0.7 : 1), start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(35, base * (isImpact ? 0.42 : 1.55)), start + duration);
envelope.gain.setValueAtTime(0.0001, start);
envelope.gain.exponentialRampToValueAtTime(event.actorGroup === "player" ? 0.12 : 0.085, start + 0.015);
envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(envelope);
envelope.connect(panner);
panner.connect(groupGains[event.actorGroup]);
let stopped = false;
const voice: ActiveVoice = {
group: event.actorGroup,
stop: () => {
if (stopped) return;
stopped = true;
try { oscillator.stop(); } catch { /* already stopped */ }
},
};
const unregister = registerVoice(voice);
oscillator.onended = () => {
unregister();
oscillator.disconnect();
envelope.disconnect();
panner.disconnect();
};
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
}
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
const playable = sounds.filter((sound) => sound.url);
if (!playable.length) return null;
const total = playable.reduce((sum, sound) => sum + Math.max(1, sound.weight ?? 1), 0);
let cursor = (event.id * 0.61803398875 % 1) * total;
for (const sound of playable) {
cursor -= Math.max(1, sound.weight ?? 1);
if (cursor <= 0) return sound;
}
return playable[playable.length - 1] ?? null;
}
async function decodeAudio(url: string): Promise<AudioBuffer | null> {
const audioContext = ensureContext();
if (!audioContext) return null;
if (!decodedBuffers.has(url)) {
decodedBuffers.set(url, fetch(url)
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
.then((data) => audioContext.decodeAudioData(data))
.catch(() => null));
}
return decodedBuffers.get(url)!;
}
export async function playMasterAudioCue(
url: string,
volume = 1,
): Promise<(() => void) | null> {
const audioContext = ensureContext();
const buffer = await decodeAudio(url);
if (!audioContext || !buffer || !masterGain) return null;
void audioContext.resume().catch(() => undefined);
const source = audioContext.createBufferSource();
const envelope = audioContext.createGain();
source.buffer = buffer;
envelope.gain.value = Math.max(0, Math.min(1, volume));
source.connect(envelope);
envelope.connect(masterGain);
let stopped = false;
const stop = () => {
if (stopped) return;
stopped = true;
activeMasterVoices.delete(stop);
try { source.stop(); } catch { /* already stopped */ }
};
activeMasterVoices.add(stop);
source.onended = () => {
stopped = true;
activeMasterVoices.delete(stop);
source.disconnect();
envelope.disconnect();
};
source.start();
return stop;
}
async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise<boolean> {
if (!sound.url) return false;
const audioContext = ensureContext();
const buffer = await decodeAudio(sound.url);
if (!audioContext || !buffer || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return false;
void audioContext.resume().catch(() => undefined);
const source = audioContext.createBufferSource();
const envelope = audioContext.createGain();
const panner = positionPanner(audioContext, event);
source.buffer = buffer;
envelope.gain.value = Math.max(0, Math.min(1, sound.volume ?? 1));
source.connect(envelope);
envelope.connect(panner);
panner.connect(groupGains[event.actorGroup]);
let stopped = false;
const voice: ActiveVoice = {
group: event.actorGroup,
stop: () => {
if (stopped) return;
stopped = true;
try { source.stop(); } catch { /* already stopped */ }
},
};
const unregister = registerVoice(voice);
source.onended = () => {
unregister();
source.disconnect();
envelope.disconnect();
panner.disconnect();
};
source.start();
return true;
}
export function playCombatAudio(event: CombatPresentationEvent): void {
if (event.phase === "cast-cancel") {
stopCombatAudioGroup(event.actorGroup);
return;
}
if (!configuredSettings?.audioEnabled[event.actorGroup] || configuredSettings.audioVolume[event.actorGroup] <= 0) return;
const sound = weightedSound(event);
if (!sound) {
playSynthesizedCombatCue(event);
return;
}
void playBufferedCombatCue(event, sound).then((played) => {
if (!played) playSynthesizedCombatCue(event);
});
}
export function stopCombatAudioGroup(group: CombatActorGroup): void {
for (const voice of [...activeVoices]) {
if (voice.group === group) voice.stop();
}
}
export function resetCombatAudio(): void {
for (const voice of [...activeVoices]) voice.stop();
for (const stop of [...activeMasterVoices]) stop();
activeVoices.length = 0;
}
export function setCombatAudioListener(
position: readonly [number, number, number],
forward: readonly [number, number, number],
up: readonly [number, number, number] = [0, 1, 0],
): void {
if (!context) return;
const listener = context.listener;
const now = context.currentTime;
listener.positionX?.setTargetAtTime(position[0], now, 0.01);
listener.positionY?.setTargetAtTime(position[1], now, 0.01);
listener.positionZ?.setTargetAtTime(position[2], now, 0.01);
listener.forwardX?.setTargetAtTime(forward[0], now, 0.01);
listener.forwardY?.setTargetAtTime(forward[1], now, 0.01);
listener.forwardZ?.setTargetAtTime(forward[2], now, 0.01);
listener.upX?.setTargetAtTime(up[0], now, 0.01);
listener.upY?.setTargetAtTime(up[1], now, 0.01);
listener.upZ?.setTargetAtTime(up[2], now, 0.01);
}
export function activeCombatAudioVoiceCount(): number {
return activeVoices.length;
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import {
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
combatEffectManifestEntry,
combatEffectStyle,
combatPresentationSessionId,
emitCombatPresentation,
normalizeCombatPresentationSettings,
presentationDeliveryForAbility,
presentationSchoolForAbility,
resetCombatPresentationSession,
subscribeCombatPresentation,
type CombatPresentationMessage,
} from "./combatPresentation";
describe("combat presentation", () => {
it("normalizes owner settings without leaking partial or invalid values", () => {
expect(normalizeCombatPresentationSettings({
vfxQuality: "low",
vfxEnabled: { player: false },
audioEnabled: { enemy: false },
audioVolume: { player: 2, party: -1, enemy: 0.35 },
})).toEqual({
vfxQuality: "low",
vfxEnabled: { player: false, party: true, enemy: true },
audioEnabled: { player: true, party: true, enemy: false },
audioVolume: { player: 1, party: 0, enemy: 0.35 },
});
expect(normalizeCombatPresentationSettings(null)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
});
it("delivers immutable, exactly-once events and a session reset", () => {
const messages: CombatPresentationMessage[] = [];
const unsubscribe = subscribeCombatPresentation((message) => messages.push(message));
const origin = [1, 2, 3] as [number, number, number];
const event = emitCombatPresentation({
occurredAt: 1_000,
phase: "release",
actorGroup: "player",
sourceActorId: "player",
targetActorId: "mob",
abilityId: "fixture",
sourceSpellId: 116,
name: "Frostbolt",
source: "wow335",
school: "frost",
delivery: "projectile",
origin,
targetPosition: [8, 0, 0],
});
origin[0] = 99;
const nextSession = resetCombatPresentationSession();
unsubscribe();
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual({ kind: "event", event });
expect(event.origin).toEqual([1, 2, 3]);
expect(messages[1]).toEqual({ kind: "reset", sessionId: nextSession });
expect(combatPresentationSessionId()).toBe(nextSession);
});
it("uses authentic source descriptors and school/delivery fallbacks", () => {
const frostbolt = abilityById("mage-frostbolt");
expect(frostbolt).not.toBeNull();
expect(presentationSchoolForAbility(frostbolt!)).toBe("frost");
expect(presentationDeliveryForAbility(frostbolt!)).toBe("projectile");
expect(combatEffectManifestEntry(999_999, "wow335")).toBeNull();
const style = combatEffectStyle({
id: 1,
sessionId: 1,
occurredAt: 1,
phase: "release",
actorGroup: "player",
sourceActorId: "player",
abilityId: "mage-frostbolt",
sourceSpellId: 999_999,
name: "Frostbolt",
source: "wow335",
school: "frost",
delivery: "projectile",
origin: [0, 0, 0],
});
expect(style.authentic).toBe(false);
expect(style.sourceModelNames).toEqual([]);
});
});
+295
View File
@@ -0,0 +1,295 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export type CombatActorGroup = "player" | "party" | "enemy";
export type CombatVfxQuality = "off" | "low" | "high";
export type CombatPresentationPhase =
| "cast-start"
| "cast-cancel"
| "release"
| "impact"
| "tick"
| "aura-start"
| "aura-end";
export type CombatPresentationDelivery = "melee" | "projectile" | "area" | "channel" | "aura";
export type CombatPresentationSchool =
| "physical"
| "arcane"
| "fire"
| "frost"
| "nature"
| "shadow"
| "holy";
export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "fallback";
export type CombatPresentationPosition = readonly [number, number, number];
export interface CombatPresentationSettings {
readonly vfxQuality: CombatVfxQuality;
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
readonly audioEnabled: Readonly<Record<CombatActorGroup, boolean>>;
readonly audioVolume: Readonly<Record<CombatActorGroup, number>>;
}
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
vfxQuality: "high",
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
audioEnabled: Object.freeze({ player: true, party: true, enemy: true }),
audioVolume: Object.freeze({ player: 1, party: 1, enemy: 1 }),
});
function finiteVolume(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.min(1, value))
: fallback;
}
function booleanGroups(
value: unknown,
fallback: Readonly<Record<CombatActorGroup, boolean>>,
): Readonly<Record<CombatActorGroup, boolean>> {
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Partial<Record<CombatActorGroup, unknown>>
: {};
return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [
group,
typeof record[group] === "boolean" ? record[group] : fallback[group],
])) as Record<CombatActorGroup, boolean>);
}
function volumeGroups(
value: unknown,
fallback: Readonly<Record<CombatActorGroup, number>>,
): Readonly<Record<CombatActorGroup, number>> {
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Partial<Record<CombatActorGroup, unknown>>
: {};
return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [
group,
finiteVolume(record[group], fallback[group]),
])) as Record<CombatActorGroup, number>);
}
export function normalizeCombatPresentationSettings(
value: unknown,
fallback: CombatPresentationSettings = DEFAULT_COMBAT_PRESENTATION_SETTINGS,
): CombatPresentationSettings {
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Partial<Record<keyof CombatPresentationSettings, unknown>>
: {};
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
? record.vfxQuality
: fallback.vfxQuality;
return Object.freeze({
vfxQuality,
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
audioEnabled: booleanGroups(record.audioEnabled, fallback.audioEnabled),
audioVolume: volumeGroups(record.audioVolume, fallback.audioVolume),
});
}
export interface CombatPresentationEvent {
readonly id: number;
readonly sessionId: number;
readonly occurredAt: number;
readonly phase: CombatPresentationPhase;
readonly actorGroup: CombatActorGroup;
readonly sourceActorId: string;
readonly targetActorId?: string;
readonly abilityId: string;
readonly sourceSpellId?: number;
readonly name: string;
readonly source: CombatPresentationSource;
readonly school: CombatPresentationSchool;
readonly delivery: CombatPresentationDelivery;
readonly origin: CombatPresentationPosition;
readonly targetPosition?: CombatPresentationPosition;
readonly radius?: number;
readonly durationMs?: number;
}
export type CombatPresentationEventInput = Omit<CombatPresentationEvent, "id" | "sessionId">;
export type CombatPresentationMessage =
| { readonly kind: "event"; readonly event: CombatPresentationEvent }
| { readonly kind: "reset"; readonly sessionId: number };
type CombatPresentationListener = (message: CombatPresentationMessage) => void;
const presentationListeners = new Set<CombatPresentationListener>();
let presentationSequence = 0;
let presentationSessionId = 1;
export function subscribeCombatPresentation(listener: CombatPresentationListener): () => void {
presentationListeners.add(listener);
return () => presentationListeners.delete(listener);
}
export function emitCombatPresentation(input: CombatPresentationEventInput): CombatPresentationEvent {
presentationSequence += 1;
const event: CombatPresentationEvent = Object.freeze({
...input,
id: presentationSequence,
sessionId: presentationSessionId,
origin: [...input.origin] as CombatPresentationPosition,
...(input.targetPosition
? { targetPosition: [...input.targetPosition] as CombatPresentationPosition }
: {}),
});
for (const listener of presentationListeners) listener({ kind: "event", event });
return event;
}
export function resetCombatPresentationSession(): number {
presentationSessionId += 1;
for (const listener of presentationListeners) {
listener({ kind: "reset", sessionId: presentationSessionId });
}
return presentationSessionId;
}
export function combatPresentationSessionId(): number {
return presentationSessionId;
}
export interface CombatEffectPhaseAsset {
readonly model?: string;
readonly attachment?: string;
readonly scale?: number;
}
export interface CombatEffectSoundAsset {
readonly url?: string;
readonly sourcePath?: string;
readonly weight?: number;
readonly volume?: number;
readonly minimumDistance?: number;
readonly maximumDistance?: number;
}
export interface CombatEffectManifestEntry {
readonly key: string;
readonly source: Exclude<CombatPresentationSource, "fallback">;
readonly spellId: number;
readonly visualIds?: readonly number[];
readonly phases?: Readonly<Partial<Record<CombatPresentationPhase, readonly CombatEffectPhaseAsset[]>>>;
readonly sounds?: readonly CombatEffectSoundAsset[];
}
interface GeneratedCombatEffectManifest {
readonly schemaVersion: number;
readonly entries: readonly CombatEffectManifestEntry[];
}
const COMBAT_EFFECT_MANIFEST_URL = "/assets/game/combat-effects/manifest.json";
const manifestByKey = new Map<string, CombatEffectManifestEntry>();
const manifestBySpellId = new Map<number, CombatEffectManifestEntry>();
let manifestRequest: Promise<void> | null = null;
function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): void {
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.entries)) return;
for (const entry of manifest.entries) {
manifestByKey.set(entry.key, entry);
if (!manifestBySpellId.has(entry.spellId)) manifestBySpellId.set(entry.spellId, entry);
}
}
export function preloadCombatEffectManifest(): Promise<void> {
if (manifestRequest) return manifestRequest;
if (typeof fetch === "undefined") return Promise.resolve();
manifestRequest = fetch(COMBAT_EFFECT_MANIFEST_URL)
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
.catch(() => undefined);
return manifestRequest;
}
export function combatEffectManifestEntry(
spellId: number | undefined,
source?: CombatPresentationSource,
): CombatEffectManifestEntry | null {
if (!spellId) return null;
if (source && source !== "fallback") {
const exact = manifestByKey.get(`${source}:${spellId}`);
if (exact) return exact;
}
return manifestBySpellId.get(spellId) ?? null;
}
function abilityHasEffect(ability: AbilityDefinition, kind: AbilityEffect["kind"]): boolean {
return ability.effects.some((effect) => effect.kind === kind);
}
export function presentationSourceForAbility(ability: AbilityDefinition): CombatPresentationSource {
if (ability.source === "runewaker") return "runewaker";
if (ability.source?.startsWith("ascension") || ability.source?.startsWith("coa")) return "ascension";
return "wow335";
}
export function presentationSchoolForAbility(ability: AbilityDefinition): CombatPresentationSchool {
const name = `${ability.name} ${ability.description}`.toLowerCase();
if (/fire|flame|burn|inferno|pyro|molten/.test(name)) return "fire";
if (/frost|ice|rime|chill|snow/.test(name)) return "frost";
if (/shadow|void|curse|corrupt|death|plague|fel|blood/.test(name)) return "shadow";
if (/arcane|mana|time|chron|runic|astral/.test(name)) return "arcane";
if (/holy|light|smite|divine|sun|heal|renew|rejuven|salvation|blessing/.test(name)) return "holy";
if (/nature|earth|storm|lightning|thunder|wind|wrath|root|poison|venom/.test(name)) return "nature";
if (abilityHasEffect(ability, "heal") || abilityHasEffect(ability, "hot") || abilityHasEffect(ability, "resurrection")) {
return ["druid", "shaman", "rom-druid", "rom-warden"].includes(ability.classId) ? "nature" : "holy";
}
return "physical";
}
export function presentationDeliveryForAbility(ability: AbilityDefinition): CombatPresentationDelivery {
if (ability.castMode === "channel") return "channel";
if (ability.radius !== undefined && ability.radius > 0) return "area";
if (ability.target === "hostile") return ability.range.max <= 4.5 ? "melee" : "projectile";
if (abilityHasEffect(ability, "apply-aura") || abilityHasEffect(ability, "shield") || abilityHasEffect(ability, "hot")) {
return "aura";
}
return "projectile";
}
export function presentationSourceForSpellId(spellId: number | undefined): CombatPresentationSource {
if (!spellId) return "fallback";
const mapped = manifestBySpellId.get(spellId);
if (mapped) return mapped.source;
return spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "wow335";
}
export interface CombatEffectStyle {
readonly primary: string;
readonly secondary: string;
readonly emissive: number;
readonly authentic: boolean;
readonly sourceModelNames: readonly string[];
}
const SCHOOL_STYLE: Readonly<Record<CombatPresentationSchool, Omit<CombatEffectStyle, "authentic" | "sourceModelNames">>> = {
physical: { primary: "#f3d18a", secondary: "#ffffff", emissive: 1.2 },
arcane: { primary: "#b774ff", secondary: "#76d8ff", emissive: 1.8 },
fire: { primary: "#ff6a20", secondary: "#ffd24a", emissive: 2.2 },
frost: { primary: "#72d8ff", secondary: "#e8fbff", emissive: 1.8 },
nature: { primary: "#64e06e", secondary: "#d4ff73", emissive: 1.7 },
shadow: { primary: "#8a4de8", secondary: "#ed5cff", emissive: 1.9 },
holy: { primary: "#ffe67a", secondary: "#fff7d0", emissive: 2 },
};
export function combatEffectStyle(event: CombatPresentationEvent): CombatEffectStyle {
const entry = combatEffectManifestEntry(event.sourceSpellId, event.source);
const sourceModelNames = Object.values(entry?.phases ?? {})
.flatMap((assets) => assets ?? [])
.flatMap((asset) => asset.model ? [asset.model] : []);
const inferred = sourceModelNames.join(" ").toLowerCase();
const school = /fire|flame|molten/.test(inferred)
? "fire"
: /frost|ice|snow/.test(inferred)
? "frost"
: /shadow|void|dark/.test(inferred)
? "shadow"
: /holy|light|heal/.test(inferred)
? "holy"
: /nature|earth|lightning|wind/.test(inferred)
? "nature"
: event.school;
return { ...SCHOOL_STYLE[school], authentic: Boolean(entry), sourceModelNames };
}
+112
View File
@@ -20,6 +20,7 @@ import { EMPTY_ITEM_STATS } from "./itemStats";
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
import { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
function allocateTalentRanks(name: string, ranks: number): void {
const talent = TALENT_NODES.find((node) => node.name === name);
@@ -94,6 +95,26 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
});
it("emits exactly one cast and release event and cancels presentation with the cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
registerTarget("presentation-target", [10, 0, 0]);
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event") events.push(message.event);
});
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.getState().tick(0.1, 2_500);
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
.toEqual(["cast-start", "release", "impact"]);
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 4_000).ok).toBe(true);
expect(useCombatStore.getState().cancelCast()).toBe(true);
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
.toEqual(["cast-start", "release", "impact", "cast-start", "cast-cancel"]);
unsubscribe();
});
it("does not cast or bind Priest Holy Concentration", () => {
const abilityId = "wow335-priest-holy-concentration";
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({
@@ -385,6 +406,94 @@ describe("combat store", () => {
expect(useCombatStore.getState().health).toBe(healthBefore - 12);
});
it("automatically engages a dungeon pack when a living party actor enters its aggro range", () => {
const partyMemberId = preparePartyMember(0, [40, 0, 0]);
for (const [id, position] of [["proximity-pack:a", [20, 0, 0]], ["proximity-pack:b", [22, 0, 0]]] as const) {
setMobPosition(id, position);
useCombatStore.getState().registerMob(id, {
name: id,
maxHealth: 100,
});
}
setMobPosition("proximity-boss", [100, 0, 0]);
useCombatStore.getState().registerMob("proximity-boss", {
name: "Proximity Boss",
boss: true,
maxHealth: 100,
});
expect(useCombatStore.getState().mobs["proximity-pack:a"].aggroRange).toBe(16);
expect(useCombatStore.getState().mobs["proximity-boss"].aggroRange).toBe(20);
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().mobs["proximity-pack:a"].engaged).toBe(false);
updatePartyRuntimePosition(partyMemberId, 27, 0, 0);
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 0, 0])).toBe(1);
expect(useCombatStore.getState().mobs["proximity-pack:a"]).toMatchObject({
engaged: true,
targetActorId: partyMemberId,
homePosition: [20, 0, 0],
});
expect(useCombatStore.getState().mobs["proximity-pack:b"]).toMatchObject({
engaged: true,
targetActorId: partyMemberId,
homePosition: [22, 0, 0],
});
});
it("uses 3D distance for proximity aggro so stacked dungeon floors do not pull", () => {
setMobPosition("upper-floor-mob", [0, 20, 0]);
useCombatStore.getState().registerMob("upper-floor-mob", {
name: "Upper Floor Mob",
maxHealth: 100,
});
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(false);
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 8, 0])).toBe(1);
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(true);
});
it("anchors a roaming mob at the pull location and keeps aggro across vertical terrain", () => {
const id = "kresh-patrol:kresh-roaming";
setMobPosition(id, [0, -70, 0]);
useCombatStore.getState().registerMob(id, {
name: "Kresh",
boss: true,
maxHealth: 100,
leashRange: 55,
xpReward: 0,
});
// Kresh can travel far from the position where his model first mounted.
// His leash must begin at the patrol position where combat actually starts.
setMobPosition(id, [100, -106, 0]);
useCombatStore.getState().damageMob(id, 10, 1_000);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 90,
engaged: true,
homePosition: [100, -106, 0],
});
// A player falling from the ledge can be vertically farther than the leash
// while remaining beside the encounter in the dungeon's horizontal plane.
useCombatStore.getState().advanceMobCombat(1_450, [102, -30, 0]);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 90,
engaged: true,
combatPhase: "chasing",
});
// Horizontal distance still enforces the normal evade/reset boundary.
useCombatStore.getState().advanceMobCombat(1_500, [156, -106, 0]);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 100,
engaged: false,
combatPhase: "returning",
});
});
it("tracks damage and effective-healing threat independently for every engaged mob", () => {
for (const id of ["threat-pack:a", "threat-pack:b"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 500, xpReward: 0 });
@@ -728,11 +837,13 @@ describe("combat store", () => {
expect(useCombatStore.getState().talentRanks).toEqual({});
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
expect(useCombatStore.getState().settings.showMobAggroRanges).toBe(false);
useCombatStore.getState().updateSettings({
masterVolume: 9,
uiScale: 0.1,
minimapRotation: "north-up",
showThreatMeter: true,
showMobAggroRanges: true,
threatMeterPosition: { x: -2, y: 4 },
});
expect(useCombatStore.getState().settings).toMatchObject({
@@ -740,6 +851,7 @@ describe("combat store", () => {
uiScale: 0.75,
minimapRotation: "north-up",
showThreatMeter: true,
showMobAggroRanges: true,
threatMeterPosition: { x: 0, y: 1 },
});
useCombatStore.getState().initializeCharacter({ classId: "priest" });
+310 -27
View File
@@ -153,6 +153,16 @@ import {
type ThreatSource,
type ThreatTable,
} from "./aggro";
import { distanceSquared, planarDistanceSquared } from "./mobAi";
import {
emitCombatPresentation,
presentationDeliveryForAbility,
presentationSchoolForAbility,
presentationSourceForAbility,
presentationSourceForSpellId,
resetCombatPresentationSession,
type CombatPresentationPhase,
} from "./combatPresentation";
/** Stable target id used by named player auras and HUD consumers. */
export const PLAYER_AURA_ENTITY_ID = PLAYER_AGGRO_ID;
@@ -166,6 +176,7 @@ export type MinimapRotation = "north-up" | "player-up";
export interface GameplaySettings {
readonly showMobHealthBars: boolean;
readonly showMobAggroRanges: boolean;
readonly showTargetFrame: boolean;
readonly showThreatMeter: boolean;
readonly threatMeterPosition: NormalizedHudPosition;
@@ -179,6 +190,7 @@ export interface GameplaySettings {
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
showMobHealthBars: true,
showMobAggroRanges: false,
showTargetFrame: true,
showThreatMeter: false,
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
@@ -196,6 +208,9 @@ export const MOB_WOUND_ANIMATION_COOLDOWN_MS = 650;
export const EMPTY_CORPSE_DESPAWN_MS = 10_000;
/** Looting cannot hide a corpse before its one-shot death animation has had time to land. */
export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
export const DEFAULT_MOB_AGGRO_RANGE = 16;
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
export interface MobStatus {
readonly kind: MobStatusKind;
@@ -276,6 +291,7 @@ export interface MobCombatState {
readonly attackRange: number;
readonly attackDamage: number;
readonly swingMs: number;
readonly aggroRange: number;
readonly leashRange: number;
readonly moveSpeed: number;
readonly attackRevision: number;
@@ -397,6 +413,7 @@ export interface MobRegistration {
readonly damageMultiplier?: number;
readonly bonusLootChance?: number;
readonly moveSpeed?: number;
readonly aggroRange?: number;
readonly leashRange?: number;
readonly attacks?: readonly EnemyAttackDefinition[];
readonly mechanicImmunities?: readonly AzerothCoreMechanic[];
@@ -504,6 +521,7 @@ export interface CombatState {
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
healPlayer: (amount: number) => number;
cancelCast: (message?: string) => boolean;
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: () => void;
@@ -576,6 +594,85 @@ function nextAnimationEvent(
return { revision: animationSequence, kind, ...(abilityId ? { abilityId } : {}) };
}
function emitPlayerAbilityPresentation(
ability: AbilityDefinition,
phase: CombatPresentationPhase,
occurredAt: number,
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
): void {
emitCombatPresentation({
occurredAt,
phase,
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
...(targetId ? { targetActorId: targetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
origin,
targetPosition: targetPosition ?? origin,
...(ability.radius === undefined ? {} : { radius: ability.radius }),
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
});
}
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
const ability = abilityById(abilityId);
if (!ability) return;
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
}
function emitPlayerAbilityResolutionPresentation(
ability: AbilityDefinition,
occurredAt: number,
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
): void {
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition);
emitPlayerAbilityPresentation(
ability,
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
occurredAt,
origin,
targetId,
targetPosition,
);
}
function emitEnemyAttackPresentation(
mob: MobCombatState,
attack: EnemyAttackDefinition,
phase: CombatPresentationPhase,
occurredAt: number,
origin: CombatPosition,
targetActorId: AggroActorId | null,
targetPosition: CombatPosition | null,
): void {
emitCombatPresentation({
occurredAt,
phase,
actorGroup: "enemy",
sourceActorId: mob.id,
...(targetActorId ? { targetActorId } : {}),
abilityId: attack.id,
...(attack.spellId === undefined ? {} : { sourceSpellId: attack.spellId }),
name: attack.name,
source: presentationSourceForSpellId(attack.spellId),
school: attack.school,
delivery: attack.delivery,
origin,
targetPosition: targetPosition ?? origin,
...(attack.radius === undefined ? {} : { radius: attack.radius }),
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
});
}
function emptyCastResult(reason?: CastFailureReason, abilityId?: string): CastResult {
return { ok: !reason, abilityId, reason, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 };
}
@@ -721,10 +818,7 @@ function eligibleAggroActorIds(
const withinLeash = (position: CombatPosition | null) => {
if (!position) return false;
if (!home) return true;
const dx = position[0] - home[0];
const dy = position[1] - home[1];
const dz = position[2] - home[2];
return dx * dx + dy * dy + dz * dz <= mob.leashRange * mob.leashRange;
return planarDistanceSquared(position, home) <= mob.leashRange * mob.leashRange;
};
if (state.health > 0 && withinLeash(state.playerPosition)) eligible.add(PLAYER_AGGRO_ID);
for (const member of usePartyStore.getState().members) {
@@ -1266,6 +1360,7 @@ function normalizedSettings(
): GameplaySettings {
return {
showMobHealthBars: patch.showMobHealthBars ?? current.showMobHealthBars,
showMobAggroRanges: patch.showMobAggroRanges ?? current.showMobAggroRanges,
showTargetFrame: patch.showTargetFrame ?? current.showTargetFrame,
showThreatMeter: patch.showThreatMeter ?? current.showThreatMeter,
threatMeterPosition: normalizeHudPosition(
@@ -1444,6 +1539,7 @@ function summonedMobCombatState(
attackRange: attack.range,
attackDamage: stats.attackDamage,
swingMs: stats.swingMs,
aggroRange: 0,
leashRange: summoner.leashRange,
moveSpeed: summoner.moveSpeed,
attackRevision: 0,
@@ -1749,34 +1845,76 @@ function mobGroupId(id: string): string {
return separator > 0 ? id.slice(0, separator) : id;
}
function engageMobGroup(
mobs: Record<string, MobCombatState>,
sourceId: string,
now: number,
sourceActorId: AggroActorId,
): boolean {
const groupId = mobGroupId(sourceId);
const groupWasEngaged = Object.entries(mobs).some(([id, mob]) => (
mobGroupId(id) === groupId && mob.engaged && !mob.dead
));
let changed = false;
for (const [id, mob] of Object.entries(mobs)) {
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
const threatByActor = groupWasEngaged
? mob.threatByActor
: seedActorThreat(mob.threatByActor, sourceActorId);
mobs[id] = {
...mob,
threatByActor,
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
engaged: true,
combatPhase: "chasing",
homePosition: groupWasEngaged
? (mob.homePosition ?? getMobPosition(id))
: (getMobPosition(id) ?? mob.homePosition),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
};
changed = true;
}
return changed;
}
function engageMobGroupInWork(
work: MutableCastWork,
sourceId: string,
now: number,
sourceActorId: AggroActorId,
): void {
const groupId = mobGroupId(sourceId);
const groupWasEngaged = Object.entries(work.mobs).some(([id, mob]) => (
mobGroupId(id) === groupId && mob.engaged && !mob.dead
));
for (const [id, mob] of Object.entries(work.mobs)) {
if (mob.dead || mobGroupId(id) !== groupId) continue;
const threatByActor = groupWasEngaged
? mob.threatByActor
: seedActorThreat(mob.threatByActor, sourceActorId);
work.mobs[id] = {
...mob,
threatByActor,
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
engaged: true,
combatPhase: "chasing",
homePosition: mob.homePosition ?? getMobPosition(id),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
};
engageMobGroup(work.mobs, sourceId, now, sourceActorId);
}
function nearestAggroActorId(
state: Pick<CombatState, "health" | "playerPosition">,
mobPosition: CombatPosition,
aggroRange: number,
): AggroActorId | null {
if (!Number.isFinite(aggroRange) || aggroRange <= 0) return null;
let nearestId: AggroActorId | null = null;
let nearestDistanceSquared = aggroRange * aggroRange;
if (state.health > 0) {
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
if (playerDistanceSquared <= nearestDistanceSquared) {
nearestId = PLAYER_AGGRO_ID;
nearestDistanceSquared = playerDistanceSquared;
}
}
for (const member of usePartyStore.getState().members) {
if (member.health <= 0) continue;
const position = getPartyRuntimePosition(member.id);
if (!position) continue;
const memberDistanceSquared = distanceSquared(mobPosition, position);
if (memberDistanceSquared < nearestDistanceSquared) {
nearestId = member.id;
nearestDistanceSquared = memberDistanceSquared;
}
}
return nearestId;
}
function mobResistanceForSchool(
@@ -1893,7 +2031,7 @@ function damageMobInWork(
forcedTarget: killed ? null : engagedMob.forcedTarget,
engaged: !killed,
combatPhase: killed ? "idle" : "chasing",
homePosition: mob.homePosition ?? getMobPosition(id),
homePosition: engagedMob.homePosition ?? getMobPosition(id),
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
activeCast: killed ? null : engagedMob.activeCast,
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
@@ -2266,6 +2404,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
...initialState(),
initializeCharacter: (profile) => {
resetCombatPresentationSession();
const classId = profile.classId;
const secondaryClassId = profile.secondaryClassId ?? null;
const raceId = profile.raceId ?? "human";
@@ -2371,6 +2510,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attackRange + 1,
registration.leashRange ?? existing?.leashRange ?? (boss ? 55 : 42),
);
const aggroRange = Math.max(
0,
Math.min(
leashRange,
registration.aggroRange
?? existing?.aggroRange
?? (boss ? DEFAULT_BOSS_AGGRO_RANGE : DEFAULT_MOB_AGGRO_RANGE),
),
);
const moveSpeed = Math.max(
0,
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
@@ -2403,6 +2551,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attacks,
attackRange,
swingMs,
aggroRange,
leashRange,
moveSpeed,
attackDamage,
@@ -2454,6 +2603,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attackRange,
attackDamage,
swingMs,
aggroRange,
leashRange,
moveSpeed,
attackRevision: 0,
@@ -2690,6 +2840,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const movedWhileCasting = Boolean(cast && (
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
));
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
return {
playerPosition: next,
activeCast: movedWhileCasting ? null : cast,
@@ -2814,6 +2965,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? { playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) }
: {}),
});
if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin);
return emptyCastResult(reason, abilityId);
}
@@ -2885,6 +3037,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
{ abilityId: ability.id, targetId: targetId ?? undefined },
),
});
emitPlayerAbilityPresentation(
ability,
"cast-start",
now,
playerOrigin,
targetId,
targetPosition,
);
return { ...emptyCastResult(), ok: true, abilityId: ability.id };
}
@@ -3452,6 +3612,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
{ abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing },
),
});
emitPlayerAbilityResolutionPresentation(
ability,
now,
playerOrigin,
targetId,
targetPosition,
);
return {
ok: true,
@@ -3611,7 +3778,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: mob.forcedTarget,
engaged: true,
combatPhase: "chasing",
homePosition: mob.homePosition ?? getMobPosition(mobId),
homePosition: groupWasEngaged
? (mob.homePosition ?? getMobPosition(mobId))
: (getMobPosition(mobId) ?? mob.homePosition),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
@@ -3706,9 +3875,30 @@ export const useCombatStore = create<CombatState>((set, get) => ({
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
});
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
return true;
},
engageNearbyMobs: (now = Date.now(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
? { ...state, playerPosition }
: state;
const mobs: Record<string, MobCombatState> = { ...state.mobs };
let engagedGroups = 0;
for (const id of Object.keys(mobs)) {
const mob = mobs[id];
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
const position = getMobPosition(id);
if (!position) continue;
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
if (!actorId) continue;
if (engageMobGroup(mobs, id, now, actorId)) engagedGroups += 1;
}
if (engagedGroups > 0) set({ mobs });
return engagedGroups;
},
advanceMobCombat: (now = Date.now(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
@@ -3852,6 +4042,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
attackAnimation = "cast";
lastAttackId = attack.id;
nextAttackAt = enemyActiveCast.completesAt;
emitEnemyAttackPresentation(
mob,
attack,
"cast-start",
now,
position,
resolution.targetActorId,
targetPosition,
);
} else if (selectedAttack && !cannotAttack) {
const attack = selectedAttack!;
enemyActiveCast = null;
@@ -3881,6 +4080,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: attack.target === "primary"
? [resolution.targetActorId]
: [];
const presentationTargetId = targets[0] ?? resolution.targetActorId;
emitEnemyAttackPresentation(
mob,
attack,
"release",
now,
position,
presentationTargetId,
actorPosition(runtimeState, presentationTargetId),
);
if (effect.kind === "damage") {
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
for (const actorId of targets) {
@@ -4102,6 +4311,19 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
let appliedDamage = 0;
for (const hit of incomingHits) {
const impactOrigin = getMobPosition(hit.mob.id);
const impactTarget = actorPosition(get(), hit.actorId);
if (impactOrigin && impactTarget) {
emitEnemyAttackPresentation(
hit.mob,
hit.attack,
"impact",
now,
impactOrigin,
hit.actorId,
impactTarget,
);
}
if (hit.actorId === PLAYER_AGGRO_ID) {
const current = get();
const defender = applyAuraStats(
@@ -4437,6 +4659,23 @@ export const useCombatStore = create<CombatState>((set, get) => ({
work.resourcePools = rage.resourcePools;
}
if (result.amount > 0) autoAttackEvent = nextAnimationEvent("attack");
if (result.amount > 0) {
const targetPosition = getMobPosition(autoAttackTargetId!);
emitCombatPresentation({
occurredAt: now,
phase: "release",
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: autoAttackTargetId!,
abilityId: "player-auto-attack",
name: "Auto Attack",
source: "fallback",
school: autoAttackSchool,
delivery: "melee",
origin: state.playerPosition,
targetPosition: targetPosition ?? state.playerPosition,
});
}
nextAutoAttackAt = now + profile.intervalMs;
if (result.killed) {
autoAttackTargetId = null;
@@ -4530,6 +4769,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
amount,
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}, nextTickAt);
if (ability) {
const sourceActorId = effect.sourceActorId;
const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party";
const origin = sourceActorId === PLAYER_AGGRO_ID
? state.playerPosition
: getPartyRuntimePosition(sourceActorId) ?? state.playerPosition;
const targetPosition = effect.targetId
? getMobPosition(effect.targetId) ?? getPartyRuntimePosition(effect.targetId)
: state.playerPosition;
emitCombatPresentation({
occurredAt: nextTickAt,
phase: "tick",
actorGroup,
sourceActorId,
...(effect.targetId ? { targetActorId: effect.targetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
origin,
targetPosition: targetPosition ?? origin,
});
}
remainingTicks -= 1;
nextTickAt += effect.intervalMs;
}
@@ -4711,6 +4975,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed);
}
}
emitPlayerAbilityPresentation(
ability,
"tick",
nextTickAt,
get().playerPosition,
cast.targetId,
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
);
ticksCompleted += 1;
nextTickAt += (cast.completesAt - cast.startedAt) / cast.totalTicks;
}
@@ -4724,6 +4996,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: cast.targetId ?? undefined,
}),
});
if (ability) {
emitPlayerAbilityPresentation(
ability,
"release",
now,
get().playerPosition,
cast.targetId,
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
);
}
} else if (ticksCompleted !== cast.ticksCompleted) {
set({ activeCast: { ...cast, ticksCompleted, nextTickAt } });
}
@@ -4736,6 +5018,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
},
resetEncounter: () => set((state) => {
resetCombatPresentationSession();
for (const mob of Object.values(state.mobs)) {
if (mob.summonedBy) removeMobPosition(mob.id);
}
+1
View File
@@ -94,6 +94,7 @@ export interface EnemyCombatDefinition {
readonly healthMultiplier?: number;
readonly damageMultiplier?: number;
readonly moveSpeed?: number;
readonly aggroRange?: number;
readonly leashRange?: number;
readonly attacks: readonly EnemyAttackDefinition[];
}
+21 -2
View File
@@ -121,14 +121,32 @@ export function manastormCueForStatusTransition(
}
function browserAudioFactory(url: string): ManastormAudioHandle {
return new Audio(url);
let stopPlayback: (() => void) | null = null;
let stopped = false;
const handle: ManastormAudioHandle = {
currentTime: 0,
preload: "auto",
volume: 1,
pause: () => {
stopped = true;
stopPlayback?.();
stopPlayback = null;
},
play: async () => {
stopped = false;
const stop = await playMasterAudioCue(url, handle.volume);
if (!stop) throw new Error("Web Audio is unavailable.");
if (stopped) stop();
else stopPlayback = stop;
},
};
return handle;
}
export async function playManastormClientCue(
cueId: ManastormPortalCueId,
audioFactory: ManastormAudioFactory = browserAudioFactory,
): Promise<boolean> {
if (audioFactory === browserAudioFactory && typeof Audio === "undefined") return false;
const cue = manastormPortalAudioCue(cueId);
try {
activeAudio?.pause();
@@ -149,3 +167,4 @@ export function stopManastormClientCue(): void {
activeAudio?.pause();
activeAudio = null;
}
import { playMasterAudioCue } from "./combatAudio";
+99
View File
@@ -20,6 +20,11 @@ import {
type WotlkAttackKind,
} from "./wotlkStats";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import {
emitCombatPresentation,
type CombatPresentationDelivery,
type CombatPresentationSchool,
} from "./combatPresentation";
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
const ATTACK_ACQUISITION_RANGE = 30;
@@ -30,6 +35,43 @@ const TANK_TAUNT_DURATION_MS = 3_000;
let lastAdvanceAt = 0;
let nextRegenAt = 0;
interface PartyPresentationIdentity {
readonly spellId: number;
readonly name: string;
readonly school: CombatPresentationSchool;
readonly delivery: CombatPresentationDelivery;
}
function partyAttackPresentation(member: PartyMember): PartyPresentationIdentity {
if (!partyUsesRangedAttacks(member)) {
const spellId = member.classId === "rogue" ? 1752
: member.classId === "death-knight" ? 45462
: member.classId === "paladin" ? 20271
: 78;
return { spellId, name: "Weapon Attack", school: "physical", delivery: "melee" };
}
if (member.classId === "hunter") return { spellId: 56641, name: "Steady Shot", school: "physical", delivery: "projectile" };
if (member.classId === "mage") return { spellId: 116, name: "Frostbolt", school: "frost", delivery: "projectile" };
if (member.classId === "warlock") return { spellId: 686, name: "Shadow Bolt", school: "shadow", delivery: "projectile" };
if (member.classId === "priest") return { spellId: 585, name: "Smite", school: "holy", delivery: "projectile" };
if (member.classId === "druid") return { spellId: 5176, name: "Wrath", school: "nature", delivery: "projectile" };
return { spellId: 403, name: "Lightning Bolt", school: "nature", delivery: "projectile" };
}
function partyHealPresentation(member: PartyMember): PartyPresentationIdentity {
if (member.classId === "paladin") return { spellId: 635, name: "Holy Light", school: "holy", delivery: "projectile" };
if (member.classId === "shaman") return { spellId: 331, name: "Healing Wave", school: "nature", delivery: "projectile" };
if (member.classId === "druid") return { spellId: 5185, name: "Healing Touch", school: "nature", delivery: "projectile" };
return { spellId: 2061, name: "Flash Heal", school: "holy", delivery: "projectile" };
}
function partyTauntSpellId(member: PartyMember): number {
if (member.classId === "paladin") return 62124;
if (member.classId === "death-knight") return 56222;
if (member.classId === "druid") return 6795;
return 355;
}
export interface PartyBossObjective {
readonly id: string;
readonly name: string;
@@ -455,6 +497,22 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const tauntSource: ThreatSource = { actorId: member.id, role: "tank" };
if (currentCombat.tauntMob(memberTargetId, tauntSource, TANK_TAUNT_DURATION_MS, now)) {
runtimeMember = { ...runtimeMember, tauntReadyAt: now + TANK_TAUNT_COOLDOWN_MS };
const tauntPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: member.id,
targetActorId: memberTargetId,
abilityId: `party-taunt:${member.id}`,
sourceSpellId: partyTauntSpellId(member),
name: "Taunt",
source: "wow335",
school: "physical",
delivery: "aura",
origin: memberPosition,
targetPosition,
} as const;
emitCombatPresentation({ ...tauntPresentation, phase: "release" });
emitCombatPresentation({ ...tauntPresentation, phase: "aura-start" });
}
}
if (now < member.nextActionAt) {
@@ -483,6 +541,23 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
now,
{ actorId: member.id, role: member.role, abilityId: `party:${member.id}` },
);
const presentation = partyAttackPresentation(member);
const attackPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: member.id,
targetActorId: memberTargetId,
abilityId: `party:${member.id}`,
sourceSpellId: presentation.spellId,
name: presentation.name,
source: "wow335",
school: presentation.school,
delivery: presentation.delivery,
origin: memberPosition,
targetPosition: targetPosition!,
} as const;
emitCombatPresentation({ ...attackPresentation, phase: "release" });
emitCombatPresentation({ ...attackPresentation, phase: "impact" });
nextMembers[index] = {
...runtimeMember,
status: desiredStatus,
@@ -531,6 +606,30 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
{ actorId: healer.id, role: healer.role },
support.effectiveHealing,
);
const presentation = partyHealPresentation(healer);
const healerPosition = getPartyRuntimePosition(healer.id) ?? currentPlayer.playerPosition;
const presentationTargetId = support.targetId === "__player__" ? PLAYER_AGGRO_ID : support.targetId;
const targetPosition = presentationTargetId === PLAYER_AGGRO_ID
? currentPlayer.playerPosition
: presentationTargetId
? getPartyRuntimePosition(presentationTargetId)
: null;
const healPresentation = {
occurredAt: now,
actorGroup: "party",
sourceActorId: healer.id,
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
abilityId: `party-heal:${healer.id}`,
sourceSpellId: presentation.spellId,
name: presentation.name,
source: "wow335",
school: presentation.school,
delivery: presentation.delivery,
origin: healerPosition,
targetPosition: targetPosition ?? healerPosition,
} as const;
emitCombatPresentation({ ...healPresentation, phase: "release" });
emitCombatPresentation({ ...healPresentation, phase: "impact" });
}
const targetMapChanged = (
+1 -1
View File
@@ -147,7 +147,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
}
}
}
}, 60_000);
}, 120_000);
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
const recipes = await Promise.all(