Update Healer Man server
This commit is contained in:
1 parent
a64b2cb575
commit
71246e5b75
502 files changed
+4245
-19
No files matched your search
@@ -29,6 +29,7 @@ import {
|
||||
masterAudioOutputEnabled,
|
||||
playCombatAudio,
|
||||
playMasterAudioCue,
|
||||
selectCombatSoundAssets,
|
||||
} from "./combatAudio";
|
||||
|
||||
const fixtureEvent: CombatPresentationEvent = {
|
||||
@@ -56,6 +57,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("combat audio output policy", () => {
|
||||
it("plays source cues only on their mapped phase, with instant-cast release support", () => {
|
||||
const cast = { url: "/cast.mp3", phase: "cast-start" as const };
|
||||
const impact = { url: "/impact.mp3", phase: "impact" as const };
|
||||
expect(selectCombatSoundAssets([cast, impact], { phase: "cast-start" })).toEqual([cast]);
|
||||
expect(selectCombatSoundAssets([cast, impact], { phase: "impact" })).toEqual([impact]);
|
||||
expect(selectCombatSoundAssets([cast, impact], { phase: "tick" })).toEqual([]);
|
||||
expect(selectCombatSoundAssets([cast], { phase: "release", instantCast: true })).toEqual([cast]);
|
||||
expect(selectCombatSoundAssets([cast], { phase: "release" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not create or fetch audio while the master volume is disabled", async () => {
|
||||
const AudioContextConstructor = vi.fn();
|
||||
const fetch = vi.fn();
|
||||
@@ -151,7 +162,7 @@ describe("combat audio output policy", () => {
|
||||
it.each(["original", "missing-file", "bad-audio"])("uses original audio or a generated fallback for %s", async (scenario) => {
|
||||
vi.resetModules();
|
||||
const audio = await import("./combatAudio");
|
||||
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null };
|
||||
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, loop: false, onended: null };
|
||||
const oscillator = {
|
||||
connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
|
||||
frequency: { setValueAtTime: vi.fn() }, type: "sine", onended: null,
|
||||
@@ -190,9 +201,119 @@ describe("combat audio output policy", () => {
|
||||
|
||||
expect(combatMedia.fetch).toHaveBeenCalledWith("/assets/game/combat-effects/audio/flash-heal.wav");
|
||||
expect(source.start).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
|
||||
expect(source.loop).toBe(false);
|
||||
expect(audioContext.createBufferSource).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
|
||||
expect(audioContext.createOscillator).toHaveBeenCalledTimes(scenario === "original" ? 0 : 1);
|
||||
expect(oscillator.start).toHaveBeenCalledTimes(scenario === "original" ? 0 : 1);
|
||||
audio.resetCombatAudio();
|
||||
});
|
||||
|
||||
it("loops only a flagged cast sound and stops the matching actor and ability", async () => {
|
||||
vi.resetModules();
|
||||
const audio = await import("./combatAudio");
|
||||
const sources: Array<{
|
||||
connect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>;
|
||||
start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>;
|
||||
buffer: null; loop: boolean; onended: null;
|
||||
}> = [];
|
||||
const oscillator = {
|
||||
connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
|
||||
frequency: { setValueAtTime: vi.fn() }, type: "sine", onended: null,
|
||||
};
|
||||
const audioContext = {
|
||||
currentTime: 0, state: "running", destination: {},
|
||||
createGain: () => ({
|
||||
connect: vi.fn(), disconnect: vi.fn(), gain: {
|
||||
setTargetAtTime: vi.fn(), setValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(), value: 1,
|
||||
},
|
||||
}),
|
||||
createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }),
|
||||
createBufferSource: vi.fn(() => {
|
||||
const source = {
|
||||
connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
|
||||
buffer: null, loop: false, onended: null,
|
||||
};
|
||||
sources.push(source);
|
||||
return source;
|
||||
}),
|
||||
createOscillator: vi.fn(() => oscillator),
|
||||
decodeAudioData: vi.fn(async () => ({ duration: 0.2 })),
|
||||
resume: vi.fn(async () => undefined),
|
||||
};
|
||||
vi.stubGlobal("window", { AudioContext: function () { return audioContext; } });
|
||||
combatMedia.ready = true;
|
||||
combatMedia.entry.mockReturnValue({
|
||||
key: "runewaker:491147", source: "runewaker", spellId: 491147,
|
||||
sounds: [{ url: "/assets/game/combat-effects/audio/holy-cast.wav", phase: "cast-start", loopDuringCast: true }],
|
||||
});
|
||||
combatMedia.fetch.mockResolvedValue(new Response(new Uint8Array([1, 2, 3])));
|
||||
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
|
||||
const cast = {
|
||||
...fixtureEvent, source: "runewaker" as const, sourceSpellId: 491147,
|
||||
phase: "cast-start" as const, abilityId: "rom-priest-general-0", durationMs: 1_000,
|
||||
};
|
||||
audio.playCombatAudio(cast);
|
||||
audio.playCombatAudio({ ...cast, id: 2, sourceActorId: "second-priest" });
|
||||
audio.playCombatAudio({ ...cast, id: 5, abilityId: "rom-priest-primary-2" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(sources).toHaveLength(3);
|
||||
expect(sources.every((source) => source.loop && source.start.mock.calls.length === 1)).toBe(true);
|
||||
audio.playCombatAudio({ ...cast, id: 3, phase: "cast-cancel" });
|
||||
expect(sources[0].stop).toHaveBeenCalledTimes(1);
|
||||
expect(sources[1].stop).not.toHaveBeenCalled();
|
||||
expect(sources[2].stop).not.toHaveBeenCalled();
|
||||
audio.playCombatAudio({ ...cast, id: 4, sourceActorId: "second-priest", phase: "release" });
|
||||
expect(sources[1].stop).toHaveBeenCalledTimes(1);
|
||||
expect(sources[2].stop).not.toHaveBeenCalled();
|
||||
audio.playCombatAudio({ ...cast, id: 6, abilityId: "rom-priest-primary-2", phase: "cast-cancel" });
|
||||
expect(sources[2].stop).toHaveBeenCalledTimes(1);
|
||||
audio.resetCombatAudio();
|
||||
});
|
||||
|
||||
it("does not start a loop after cancellation and caps a cast loop at its duration", async () => {
|
||||
vi.resetModules();
|
||||
const audio = await import("./combatAudio");
|
||||
const source = {
|
||||
connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
|
||||
buffer: null, loop: false, onended: null,
|
||||
};
|
||||
const audioContext = {
|
||||
currentTime: 0, state: "running", destination: {},
|
||||
createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: {
|
||||
setTargetAtTime: vi.fn(), value: 1,
|
||||
} }),
|
||||
createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }),
|
||||
createBufferSource: vi.fn(() => source),
|
||||
decodeAudioData: vi.fn(async () => ({ duration: 0.2 })),
|
||||
resume: vi.fn(async () => undefined),
|
||||
};
|
||||
vi.stubGlobal("window", { AudioContext: function () { return audioContext; } });
|
||||
combatMedia.ready = true;
|
||||
combatMedia.entry.mockReturnValue({
|
||||
key: "runewaker:491147", source: "runewaker", spellId: 491147,
|
||||
sounds: [{ url: "/assets/game/combat-effects/audio/holy-cast.wav", phase: "cast-start", loopDuringCast: true }],
|
||||
});
|
||||
let resolveFetch!: (response: Response) => void;
|
||||
combatMedia.fetch.mockImplementation(() => new Promise<Response>((resolve) => { resolveFetch = resolve; }));
|
||||
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
|
||||
const cast = {
|
||||
...fixtureEvent, source: "runewaker" as const, sourceSpellId: 491147,
|
||||
phase: "cast-start" as const, abilityId: "rom-priest-general-0", durationMs: 25,
|
||||
};
|
||||
audio.playCombatAudio(cast);
|
||||
audio.playCombatAudio({ ...cast, id: 2, phase: "cast-cancel" });
|
||||
resolveFetch(new Response(new Uint8Array([1, 2, 3])));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(audioContext.createBufferSource).not.toHaveBeenCalled();
|
||||
|
||||
audio.playCombatAudio({ ...cast, id: 3 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(source.loop).toBe(true);
|
||||
expect(source.start).toHaveBeenCalledTimes(1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
expect(source.stop).toHaveBeenCalledTimes(1);
|
||||
audio.resetCombatAudio();
|
||||
});
|
||||
});
|
||||
+88
-6
@@ -14,15 +14,24 @@ const MAX_COMBAT_VOICES = 32;
|
||||
|
||||
interface ActiveVoice {
|
||||
readonly group: CombatActorGroup;
|
||||
readonly castLoop?: ActiveCastLoop;
|
||||
readonly stop: () => void;
|
||||
}
|
||||
|
||||
interface ActiveCastLoop {
|
||||
readonly group: CombatActorGroup;
|
||||
readonly timeout: ReturnType<typeof setTimeout>;
|
||||
cancelled: boolean;
|
||||
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;
|
||||
const activeVoices: ActiveVoice[] = [];
|
||||
const activeCastLoops = new Map<string, ActiveCastLoop>();
|
||||
const activeMasterVoices = new Set<() => void>();
|
||||
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
|
||||
|
||||
@@ -146,10 +155,54 @@ function registerVoice(voice: ActiveVoice): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
function castLoopKey(event: CombatPresentationEvent): string {
|
||||
return `${event.sessionId}:${event.sourceActorId}:${event.abilityId}`;
|
||||
}
|
||||
|
||||
function finishCastLoop(key: string, loop: ActiveCastLoop): void {
|
||||
if (activeCastLoops.get(key) !== loop) return;
|
||||
loop.cancelled = true;
|
||||
clearTimeout(loop.timeout);
|
||||
activeCastLoops.delete(key);
|
||||
}
|
||||
|
||||
function stopCastLoop(key: string): void {
|
||||
const loop = activeCastLoops.get(key);
|
||||
if (!loop) return;
|
||||
finishCastLoop(key, loop);
|
||||
loop.stop?.();
|
||||
}
|
||||
|
||||
function beginCastLoop(event: CombatPresentationEvent): ActiveCastLoop {
|
||||
const key = castLoopKey(event);
|
||||
stopCastLoop(key);
|
||||
const durationMs = Number.isFinite(event.durationMs)
|
||||
? Math.max(0, event.durationMs ?? 0)
|
||||
: 4_500;
|
||||
const loop: ActiveCastLoop = {
|
||||
group: event.actorGroup,
|
||||
cancelled: false,
|
||||
timeout: setTimeout(() => stopCastLoop(key), durationMs),
|
||||
};
|
||||
activeCastLoops.set(key, loop);
|
||||
return loop;
|
||||
}
|
||||
|
||||
export function selectCombatSoundAssets(
|
||||
sounds: readonly CombatEffectSoundAsset[],
|
||||
event: Pick<CombatPresentationEvent, "phase" | "instantCast">,
|
||||
): readonly CombatEffectSoundAsset[] {
|
||||
const exact = sounds.filter((sound) => sound.url && (!sound.phase || sound.phase === event.phase));
|
||||
if (exact.length) return exact;
|
||||
return event.phase === "release" && event.instantCast
|
||||
? sounds.filter((sound) => sound.url && sound.phase === "cast-start")
|
||||
: [];
|
||||
}
|
||||
|
||||
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
|
||||
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
|
||||
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
|
||||
const playable = sounds.filter((sound) => sound.url);
|
||||
const playable = selectCombatSoundAssets(sounds, event);
|
||||
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;
|
||||
@@ -212,7 +265,11 @@ export async function playMasterAudioCue(
|
||||
return stop;
|
||||
}
|
||||
|
||||
async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise<boolean> {
|
||||
async function playBufferedCombatCue(
|
||||
event: CombatPresentationEvent,
|
||||
sound: CombatEffectSoundAsset,
|
||||
castLoop?: ActiveCastLoop,
|
||||
): Promise<boolean> {
|
||||
if (!sound.url) return false;
|
||||
if (
|
||||
!configuredSettings
|
||||
@@ -226,12 +283,14 @@ async function playBufferedCombatCue(event: CombatPresentationEvent, sound: Comb
|
||||
|| !audioContext
|
||||
|| !buffer
|
||||
|| !groupGains
|
||||
|| castLoop?.cancelled
|
||||
) return false;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
const panner = positionPanner(audioContext, event);
|
||||
source.buffer = buffer;
|
||||
if (castLoop) source.loop = true;
|
||||
envelope.gain.value = Math.max(0, Math.min(1, sound.volume ?? 1));
|
||||
source.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
@@ -239,6 +298,7 @@ async function playBufferedCombatCue(event: CombatPresentationEvent, sound: Comb
|
||||
let stopped = false;
|
||||
const voice: ActiveVoice = {
|
||||
group: event.actorGroup,
|
||||
castLoop,
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
@@ -246,8 +306,10 @@ async function playBufferedCombatCue(event: CombatPresentationEvent, sound: Comb
|
||||
},
|
||||
};
|
||||
const unregister = registerVoice(voice);
|
||||
if (castLoop) castLoop.stop = voice.stop;
|
||||
source.onended = () => {
|
||||
unregister();
|
||||
if (castLoop) finishCastLoop(castLoopKey(event), castLoop);
|
||||
source.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
@@ -313,8 +375,15 @@ function playGeneratedCombatCue(event: CombatPresentationEvent): boolean {
|
||||
}
|
||||
|
||||
export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
if (event.phase === "release" || event.phase === "cast-cancel") {
|
||||
stopCastLoop(castLoopKey(event));
|
||||
}
|
||||
if (event.phase === "cast-cancel") {
|
||||
stopCombatAudioGroup(event.actorGroup);
|
||||
// A cancelled cast still cuts off legacy one-shot voices for this group.
|
||||
// Other actors' active cast loops keep playing until their own terminal event.
|
||||
for (const voice of [...activeVoices]) {
|
||||
if (voice.group === event.actorGroup && !voice.castLoop) voice.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -323,12 +392,21 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
) return;
|
||||
const sound = weightedSound(event);
|
||||
if (sound) {
|
||||
void playBufferedCombatCue(event, sound)
|
||||
const castLoop = event.phase === "cast-start" && sound.loopDuringCast
|
||||
? beginCastLoop(event)
|
||||
: undefined;
|
||||
void playBufferedCombatCue(event, sound, castLoop)
|
||||
.then((played) => {
|
||||
if (!played) playGeneratedCombatCue(event);
|
||||
if (!played && !castLoop?.cancelled) {
|
||||
if (castLoop) stopCastLoop(castLoopKey(event));
|
||||
playGeneratedCombatCue(event);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
playGeneratedCombatCue(event);
|
||||
if (!castLoop?.cancelled) {
|
||||
if (castLoop) stopCastLoop(castLoopKey(event));
|
||||
playGeneratedCombatCue(event);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
playGeneratedCombatCue(event);
|
||||
@@ -336,12 +414,16 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
}
|
||||
|
||||
export function stopCombatAudioGroup(group: CombatActorGroup): void {
|
||||
for (const [key, loop] of activeCastLoops) {
|
||||
if (loop.group === group) stopCastLoop(key);
|
||||
}
|
||||
for (const voice of [...activeVoices]) {
|
||||
if (voice.group === group) voice.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCombatAudio(): void {
|
||||
for (const key of activeCastLoops.keys()) stopCastLoop(key);
|
||||
for (const voice of [...activeVoices]) voice.stop();
|
||||
for (const stop of [...activeMasterVoices]) stop();
|
||||
activeVoices.length = 0;
|
||||
|
||||
@@ -154,6 +154,23 @@ describe("combat presentation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("selects only the verified RuneWaker sprite phases for live combat", () => {
|
||||
const manifest = JSON.parse(readFileSync(
|
||||
path.resolve("public/assets/game/combat-effects/manifest.json"),
|
||||
"utf8",
|
||||
)) as { readonly entries: readonly CombatEffectManifestEntry[] };
|
||||
const entry = (spellId: number) => manifest.entries.find((candidate) => (
|
||||
candidate.source === "runewaker" && candidate.spellId === spellId
|
||||
));
|
||||
expect(originalCombatPhaseAssets(entry(490150), "cast-start").map((asset) => asset.model))
|
||||
.toEqual(["model/fx/skill/physical/act_charge02_c_down.ros"]);
|
||||
expect(originalCombatPhaseAssets(entry(490175), "impact").map((asset) => asset.model))
|
||||
.toEqual(["model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros"]);
|
||||
expect(originalCombatPhaseAssets(entry(491168), "release").map((asset) => asset.model))
|
||||
.toEqual(["model/fx/skill/wind/act_electric_ball_f_i.ros"]);
|
||||
expect(originalCombatPhaseAssets(entry(491168), "impact")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every source-backed instant WoW rank on a renderable phase", () => {
|
||||
const manifest = JSON.parse(readFileSync(
|
||||
path.resolve("public/assets/game/combat-effects/manifest.json"),
|
||||
|
||||
@@ -184,6 +184,9 @@ export interface CombatEffectPhaseAsset {
|
||||
export interface CombatEffectSoundAsset {
|
||||
readonly url?: string;
|
||||
readonly sourcePath?: string;
|
||||
readonly phase?: CombatPresentationPhase;
|
||||
/** Loop this source cue only while the matching cast remains active. */
|
||||
readonly loopDuringCast?: boolean;
|
||||
readonly weight?: number;
|
||||
readonly volume?: number;
|
||||
readonly minimumDistance?: number;
|
||||
|
||||
Reference in new issue
Block a user