Update Healer Man server
This commit is contained in:
1 parent
a64b2cb575
commit
71246e5b75
502 files changed
+4245
-19
No files matched your search
@@ -0,0 +1,105 @@
|
||||
// Development-only visual QA surface. Loaded from scripts/combat-effects/rom-preview.html.
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { Grid, OrbitControls } from "@react-three/drei";
|
||||
import { SRGBColorSpace, TextureLoader, type Texture } from "three";
|
||||
import { RunewakerParticleEffect } from "../scene/RunewakerParticleEffect";
|
||||
import { parseRunewakerParticleGraph, runeLayers, type RunewakerParticleGraph } from "../scene/runewakerParticleRuntime";
|
||||
import type { CombatPresentationPhase } from "../game/combatPresentation";
|
||||
|
||||
interface Choice { readonly model: string; readonly graphUrl: string }
|
||||
interface Loaded { readonly graph: RunewakerParticleGraph; readonly textures: ReadonlyMap<string, Texture> }
|
||||
|
||||
function Effect({ loaded, phase, playing, replay, freezeAt }: {
|
||||
readonly loaded: Loaded; readonly phase: CombatPresentationPhase; readonly playing: boolean;
|
||||
readonly replay: number; readonly freezeAt: boolean;
|
||||
}) {
|
||||
const playback = useRef({ elapsedMs: 0, decaying: false });
|
||||
useEffect(() => { playback.current.elapsedMs = 0; }, [replay, loaded]);
|
||||
useFrame((_, delta) => {
|
||||
if (!playing) return;
|
||||
playback.current.elapsedMs += Math.min(0.1, delta) * 1_000;
|
||||
if (freezeAt && playback.current.elapsedMs >= 250) playback.current.elapsedMs = 250;
|
||||
else if (playback.current.elapsedMs > 2_000) playback.current.elapsedMs = 0;
|
||||
});
|
||||
return <group position={[0, 1.1, 0]}>
|
||||
<RunewakerParticleEffect graph={loaded.graph} textures={loaded.textures} phase={phase}
|
||||
quality="high" active eventId={1} playbackRef={playback} />
|
||||
</group>;
|
||||
}
|
||||
|
||||
function Preview() {
|
||||
const [choices, setChoices] = useState<readonly Choice[]>([]);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [phase, setPhase] = useState<CombatPresentationPhase>("impact");
|
||||
const [playing, setPlaying] = useState(true);
|
||||
const [freezeAt, setFreezeAt] = useState(false);
|
||||
const [replay, setReplay] = useState(0);
|
||||
const loader = useMemo(() => new TextureLoader(), []);
|
||||
useEffect(() => {
|
||||
void fetch("/assets/game/combat-effects/original/runewaker/manifest.json")
|
||||
.then((response) => response.json())
|
||||
.then((index: { results: { model: string; graphUrl: string; status: string; rendererReady?: boolean }[] }) => {
|
||||
const available = index.results.filter((entry) => entry.status === "decoded" || entry.rendererReady)
|
||||
.map(({ model, graphUrl }) => ({ model, graphUrl }));
|
||||
setChoices(available);
|
||||
setSelected(available.find((entry) => entry.model.includes("electric_ball_t_freeze_hit"))?.graphUrl
|
||||
?? available[0]?.graphUrl ?? "");
|
||||
}).catch((reason) => setError(String(reason)));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
let cancelled = false;
|
||||
const ownedTextures: Texture[] = [];
|
||||
setLoaded(null); setError("");
|
||||
void fetch(selected).then((response) => response.json()).then(async (value) => {
|
||||
const graph = parseRunewakerParticleGraph(value);
|
||||
const textures = new Map<string, Texture>();
|
||||
await Promise.all(graph.textures.map(async (asset) => {
|
||||
const texture = await loader.loadAsync(asset.url);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
ownedTextures.push(texture);
|
||||
textures.set(asset.url, texture);
|
||||
}));
|
||||
if (!cancelled) setLoaded({ graph, textures });
|
||||
}).catch((reason) => { if (!cancelled) setError(String(reason)); });
|
||||
return () => { cancelled = true; ownedTextures.forEach((texture) => texture.dispose()); };
|
||||
}, [selected, loader]);
|
||||
return <>
|
||||
<div style={{ padding: "14px 20px", display: "flex", gap: 16, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<strong>RuneWaker particle preview · {choices.length} sprite models</strong>
|
||||
<label>Model <select aria-label="Model" value={selected} onChange={(event) => setSelected(event.target.value)}
|
||||
style={{ maxWidth: "55vw", padding: 8 }}>
|
||||
{choices.map((choice) => <option key={choice.graphUrl} value={choice.graphUrl}>{choice.model}</option>)}
|
||||
</select></label>
|
||||
<label>Phase <select aria-label="Phase" value={phase} onChange={(event) => setPhase(event.target.value as CombatPresentationPhase)}>
|
||||
<option value="cast-start">Cast start</option><option value="release">Release</option><option value="impact">Impact</option>
|
||||
</select></label>
|
||||
<button onClick={() => setPlaying(!playing)}>{playing ? "Pause" : "Play"}</button>
|
||||
<button onClick={() => setReplay(replay + 1)}>Replay</button>
|
||||
<label><input type="checkbox" checked={freezeAt} onChange={(event) => { setFreezeAt(event.target.checked); setReplay(replay + 1); }} />Freeze at 250 ms</label>
|
||||
</div>
|
||||
<div role="status" style={{ padding: "0 20px 8px" }}>{error || (loaded
|
||||
? `Ready · ${runeLayers(loaded.graph, phase).length} sprite layers · ${loaded.graph.textures.length} textures`
|
||||
: "Loading RuneWaker media…")}</div>
|
||||
<div style={{ height: "calc(100vh - 100px)" }}>
|
||||
<Canvas camera={{ position: [3, 2.5, 5], fov: 45 }}>
|
||||
<color attach="background" args={["#0b121b"]} />
|
||||
<Grid args={[20, 20]} cellColor="#304051" sectionColor="#4a637d" fadeDistance={25} />
|
||||
<mesh position={[-1.8, 0.9, 0]}><capsuleGeometry args={[0.25, 1.25, 6, 12]} /><meshBasicMaterial color="#35465c" /></mesh>
|
||||
{loaded && <Effect key={`${selected}:${replay}`} loaded={loaded} phase={phase} playing={playing}
|
||||
replay={replay} freezeAt={freezeAt} />}
|
||||
<OrbitControls target={[0, 1, 0]} />
|
||||
</Canvas>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<Preview />);
|
||||
if (import.meta.hot) import.meta.hot.dispose(() => root.unmount());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -53,12 +53,17 @@ import { updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
|
||||
import { shouldUseGeneratedOriginalFallback } from "./combatEffectRuntime";
|
||||
import { OriginalWowModel } from "./OriginalWowModel";
|
||||
import { OriginalParticleGeometry } from "./OriginalParticleGeometry";
|
||||
import { RunewakerParticleEffect } from "./RunewakerParticleEffect";
|
||||
import {
|
||||
parseRunewakerParticleGraph, runeDurationMs, type RunewakerParticleGraph,
|
||||
} from "./runewakerParticleRuntime";
|
||||
import {
|
||||
parseOriginalWowModelGraph, updateWowBoneMatrices, wowModelFrame, wowModelDurationMs, wowModelTextureUrls, wowParticleChild,
|
||||
type OriginalWowModelGraph,
|
||||
} from "./originalWowModelRuntime";
|
||||
|
||||
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph | OriginalWowModelGraph;
|
||||
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph
|
||||
| OriginalWowModelGraph | RunewakerParticleGraph;
|
||||
|
||||
function sourceParticles(graph: OriginalSourceEffectGraph): OriginalCombatEffectGraph | null {
|
||||
return graph.kind === "wow-m2-model" ? graph.particleGraph
|
||||
@@ -155,6 +160,7 @@ function graphTailMs(graph: OriginalCombatEffectGraph): number {
|
||||
|
||||
function placementHeight(model: string | undefined, event: CombatPresentationEvent): number {
|
||||
const normalized = model?.toLowerCase() ?? "";
|
||||
if (normalized.endsWith(".ros") && /(?:_|\/)down(?:\.|_|$)/.test(normalized)) return 0.08;
|
||||
if (/(?:_|\/)head(?:\.|_|$)/.test(normalized)) return 1.72;
|
||||
if (/(?:_|\/)chest(?:\.|_|$)/.test(normalized)) return 1.12;
|
||||
if (/(?:_|\/)base(?:\.|_|$)/.test(normalized)) return 0.08;
|
||||
@@ -227,7 +233,8 @@ async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
|
||||
? response.json()
|
||||
: Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`)))
|
||||
.then((value) => (
|
||||
value?.kind === "wow-m2-model" ? parseOriginalWowModelGraph(value)
|
||||
value?.kind === "runewaker-ros-particle-archive" ? parseRunewakerParticleGraph(value)
|
||||
: value?.kind === "wow-m2-model" ? parseOriginalWowModelGraph(value)
|
||||
: value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh"
|
||||
? parseOriginalCombatMeshGraph(value)
|
||||
: parseOriginalCombatEffectGraph(value)
|
||||
@@ -264,11 +271,17 @@ async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise<Lo
|
||||
const sourceTextures = new Map<string, Texture>();
|
||||
if (graph.kind === "wow-m2-model") {
|
||||
await Promise.all(wowModelTextureUrls(graph).map(async (url) => sourceTextures.set(url, await loadTexture(url))));
|
||||
} else if (graph.kind === "runewaker-ros-particle-archive") {
|
||||
await Promise.all(graph.textures.map(async (texture) => (
|
||||
sourceTextures.set(texture.url, await loadTexture(texture.url))
|
||||
)));
|
||||
}
|
||||
const textureIndices = graph.kind === "wow-m2-model"
|
||||
? graph.textures.flatMap((texture, index) => texture.url ? [index] : [])
|
||||
: graph.kind === "wow-m2-static-mesh"
|
||||
? [...new Set(graph.layers.map((layer) => layer.textureIndex))]
|
||||
: graph.kind === "runewaker-ros-particle-archive"
|
||||
? []
|
||||
: [...new Set(graph.runtime.readyParticleIndices.map((particleIndex) => (
|
||||
graph.particles[particleIndex]?.textureIndex
|
||||
)).filter((textureIndex): textureIndex is number => textureIndex !== undefined))];
|
||||
@@ -690,6 +703,8 @@ function LoadedOriginalCombatEffect({
|
||||
+ (item.graph.particleGraph ? graphTailMs(item.graph.particleGraph) : 0)
|
||||
: item.graph.kind === "wow-m2-static-mesh"
|
||||
? 6_000
|
||||
: item.graph.kind === "runewaker-ros-particle-archive"
|
||||
? runeDurationMs(item.graph, event.phase)
|
||||
: graphDurationMs(item.graph) + graphTailMs(item.graph),
|
||||
)
|
||||
), 0);
|
||||
@@ -790,7 +805,11 @@ function LoadedOriginalCombatEffect({
|
||||
>
|
||||
{graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures}
|
||||
active={active} playbackRef={staticMeshPlaybackRef} loop={loopTimeline} />}
|
||||
{graph.kind === "wow-m2-static-mesh" ? (
|
||||
{graph.kind === "runewaker-ros-particle-archive" ? (
|
||||
<RunewakerParticleEffect graph={graph} textures={item.sourceTextures}
|
||||
phase={event.phase} quality={quality} active={active} eventId={event.id}
|
||||
playbackRef={staticMeshPlaybackRef} />
|
||||
) : graph.kind === "wow-m2-static-mesh" ? (
|
||||
<OriginalStaticMesh
|
||||
graph={graph}
|
||||
textures={item.textures}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { memo, useEffect, useMemo, useRef, type MutableRefObject } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
AddEquation, BufferAttribute, CustomBlending, DoubleSide, DynamicDrawUsage,
|
||||
InstancedBufferAttribute, InstancedBufferGeometry, OneFactor, OneMinusSrcAlphaFactor,
|
||||
ShaderMaterial, Vector2, ZeroFactor, type Texture,
|
||||
} from "three";
|
||||
import type { CombatPresentationPhase, CombatVfxQuality } from "../game/combatPresentation";
|
||||
import {
|
||||
runeEmissionsBetween, runeLayers, runeSample, type RuneParticleLayer, type RuneVector3,
|
||||
type RunewakerParticleGraph,
|
||||
} from "./runewakerParticleRuntime";
|
||||
|
||||
interface Particle {
|
||||
born: number;
|
||||
life: number;
|
||||
origin: RuneVector3;
|
||||
velocity: RuneVector3;
|
||||
acceleration: RuneVector3;
|
||||
scale: number;
|
||||
orientation: number;
|
||||
rotationSign: number;
|
||||
}
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec3 instancePosition;
|
||||
attribute vec4 instanceColor;
|
||||
attribute vec2 instanceSize;
|
||||
attribute float instanceRotation;
|
||||
attribute float instanceFrame;
|
||||
varying vec2 vUv;
|
||||
varying vec4 vColor;
|
||||
varying float vFrame;
|
||||
uniform vec2 quadOffset;
|
||||
|
||||
void main() {
|
||||
vec4 viewPosition = modelViewMatrix * vec4(instancePosition, 1.0);
|
||||
vec2 local = (position.xy + quadOffset) * instanceSize;
|
||||
float s = sin(instanceRotation);
|
||||
float c = cos(instanceRotation);
|
||||
vec2 rotated = vec2(local.x * c - local.y * s, local.x * s + local.y * c);
|
||||
viewPosition.xy += rotated * length(modelViewMatrix[0].xyz);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
vUv = uv;
|
||||
vColor = instanceColor;
|
||||
vFrame = instanceFrame;
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform sampler2D sourceTexture;
|
||||
uniform vec2 tileUv;
|
||||
uniform float atlasColumns;
|
||||
uniform float frameCount;
|
||||
varying vec2 vUv;
|
||||
varying vec4 vColor;
|
||||
varying float vFrame;
|
||||
|
||||
void main() {
|
||||
if (vColor.a <= 0.002) discard;
|
||||
float frame = mod(floor(max(0.0, vFrame)), max(1.0, frameCount));
|
||||
float column = mod(frame, atlasColumns);
|
||||
float row = floor(frame / atlasColumns);
|
||||
vec2 sourceUv = vec2(column * tileUv.x + vUv.x * tileUv.x,
|
||||
1.0 - (row + 1.0) * tileUv.y + vUv.y * tileUv.y);
|
||||
vec4 sampled = texture2D(sourceTexture, sourceUv);
|
||||
float alpha = sampled.a * vColor.a;
|
||||
if (alpha <= 0.002) discard;
|
||||
gl_FragColor = vec4(sampled.rgb * vColor.rgb * alpha, alpha);
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
function random(seed: number): number {
|
||||
let value = (seed + 0x6d2b79f5) | 0;
|
||||
value = Math.imul(value ^ value >>> 15, value | 1);
|
||||
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
|
||||
return ((value ^ value >>> 14) >>> 0) / 4_294_967_296;
|
||||
}
|
||||
|
||||
function vectorAt(layer: RuneParticleLayer, track: number, time: number, fallback: RuneVector3): RuneVector3 {
|
||||
return runeSample(layer.emitter.tracks[track] as { time: number; value: RuneVector3 }[], time, fallback);
|
||||
}
|
||||
|
||||
function scalarAt(layer: RuneParticleLayer, track: number, time: number, fallback = 0): number {
|
||||
return runeSample(layer.emitter.tracks[track] as { time: number; value: number }[], time, fallback);
|
||||
}
|
||||
|
||||
function spawn(layer: RuneParticleLayer, time: number, seed: number): Particle {
|
||||
const randomAt = (index: number) => random(seed + index * 1619);
|
||||
const kind = Math.round(scalarAt(layer, 1, time));
|
||||
const emitterPosition = vectorAt(layer, 2, time, [0, 0, 0]);
|
||||
const extents = vectorAt(layer, 3, time, [0, 0, 0]);
|
||||
const radius = scalarAt(layer, 4, time);
|
||||
let offset: RuneVector3 = [0, 0, 0];
|
||||
if (kind === 1) {
|
||||
offset = [(randomAt(1) * 2 - 1) * extents[0],
|
||||
(randomAt(2) * 2 - 1) * extents[1], (randomAt(3) * 2 - 1) * extents[2]];
|
||||
} else if (kind === 4) {
|
||||
const angle = randomAt(1) * Math.PI * 2;
|
||||
const distance = randomAt(2) * radius;
|
||||
const orientation = layer.emitter.emitterOrientation;
|
||||
offset = orientation === 1 || orientation === 4
|
||||
? [0, Math.sin(angle) * distance, Math.cos(angle) * distance]
|
||||
: orientation === 2 || orientation === 5
|
||||
? [Math.sin(angle) * distance, 0, Math.cos(angle) * distance]
|
||||
: [Math.sin(angle) * distance, Math.cos(angle) * distance, 0];
|
||||
}
|
||||
const direction = vectorAt(layer, 10, time, [0, 1, 0]);
|
||||
const deviation = Math.min(Math.PI, Math.abs(scalarAt(layer, 11, time)));
|
||||
const spread = Math.tan(deviation * 0.5);
|
||||
const dx = direction[0] + (randomAt(4) * 2 - 1) * spread;
|
||||
const dy = direction[1] + (randomAt(5) * 2 - 1) * spread;
|
||||
const dz = direction[2] + (randomAt(6) * 2 - 1) * spread;
|
||||
const magnitude = Math.max(0.00001, Math.hypot(dx, dy, dz));
|
||||
const speed = scalarAt(layer, 14, time) + randomAt(7) * (
|
||||
scalarAt(layer, 15, time) - scalarAt(layer, 14, time));
|
||||
const force0 = vectorAt(layer, 20, time, [0, 0, 0]);
|
||||
const force1 = vectorAt(layer, 21, time, [0, 0, 0]);
|
||||
const minLife = scalarAt(layer, 12, time, 0.5);
|
||||
const maxLife = scalarAt(layer, 13, time, 0.5);
|
||||
const minScale = scalarAt(layer, 16, time, 1);
|
||||
const maxScale = scalarAt(layer, 17, time, 1);
|
||||
const minOrientation = vectorAt(layer, 18, time, [0, 0, 0]);
|
||||
const maxOrientation = vectorAt(layer, 19, time, [0, 0, 0]);
|
||||
return {
|
||||
born: time,
|
||||
life: Math.max(0.02, minLife + randomAt(8) * (maxLife - minLife)),
|
||||
origin: [layer.position[0] + emitterPosition[0] + offset[0],
|
||||
layer.position[1] + emitterPosition[1] + offset[1],
|
||||
-layer.position[2] - emitterPosition[2] - offset[2]],
|
||||
velocity: [dx / magnitude * speed, dy / magnitude * speed, -dz / magnitude * speed],
|
||||
acceleration: [force0[0] + force1[0], force0[1] + force1[1], -force0[2] - force1[2]],
|
||||
scale: minScale + randomAt(9) * (maxScale - minScale),
|
||||
orientation: (minOrientation[0] + randomAt(10) * (maxOrientation[0] - minOrientation[0])) * Math.PI / 180,
|
||||
rotationSign: layer.sprite.randomRotation && randomAt(11) < 0.5 ? -1 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
const RuneEmitter = memo(function RuneEmitter({
|
||||
layer, texture, budget, active, eventId, timelineRef,
|
||||
}: {
|
||||
readonly layer: RuneParticleLayer;
|
||||
readonly texture: Texture;
|
||||
readonly budget: number;
|
||||
readonly active: boolean;
|
||||
readonly eventId: number;
|
||||
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
|
||||
}) {
|
||||
const system = useRef({ particles: Array<Particle | null>(budget).fill(null), debt: 0, births: 0, lastTime: 0 });
|
||||
const geometry = useMemo(() => {
|
||||
const next = new InstancedBufferGeometry();
|
||||
next.setAttribute("position", new BufferAttribute(new Float32Array([
|
||||
-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0,
|
||||
]), 3));
|
||||
next.setAttribute("uv", new BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||||
next.setIndex([0, 1, 2, 0, 2, 3]);
|
||||
for (const [name, components] of [
|
||||
["instancePosition", 3], ["instanceColor", 4], ["instanceSize", 2],
|
||||
["instanceRotation", 1], ["instanceFrame", 1],
|
||||
] as const) {
|
||||
const attribute = new InstancedBufferAttribute(new Float32Array(budget * components), components);
|
||||
attribute.setUsage(DynamicDrawUsage);
|
||||
next.setAttribute(name, attribute);
|
||||
}
|
||||
next.instanceCount = budget;
|
||||
return next;
|
||||
}, [budget]);
|
||||
const material = useMemo(() => {
|
||||
const image = texture.image as { width?: number; height?: number } | undefined;
|
||||
const tileWidth = Math.max(1, layer.sprite.tileSizePixels[0]);
|
||||
const tileHeight = Math.max(1, layer.sprite.tileSizePixels[1]);
|
||||
const width = Math.max(tileWidth, image?.width ?? tileWidth);
|
||||
const height = Math.max(tileHeight, image?.height ?? tileHeight);
|
||||
return new ShaderMaterial({
|
||||
name: `runewaker-particle-${layer.sprite.anchor}`,
|
||||
vertexShader, fragmentShader,
|
||||
uniforms: {
|
||||
sourceTexture: { value: texture },
|
||||
tileUv: { value: new Vector2(tileWidth / width, tileHeight / height) },
|
||||
atlasColumns: { value: Math.max(1, Math.floor(width / tileWidth)) },
|
||||
frameCount: { value: Math.max(1, layer.sprite.frameCount) },
|
||||
quadOffset: { value: new Vector2(layer.sprite.quadOffset[0], layer.sprite.quadOffset[1]) },
|
||||
},
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: DoubleSide,
|
||||
toneMapped: false,
|
||||
blending: CustomBlending,
|
||||
blendEquation: AddEquation,
|
||||
blendSrc: layer.sprite.blendMode === 2 ? ZeroFactor : OneFactor,
|
||||
blendDst: layer.sprite.blendMode === 1 ? OneFactor : OneMinusSrcAlphaFactor,
|
||||
});
|
||||
}, [layer, texture]);
|
||||
|
||||
useEffect(() => () => { geometry.dispose(); material.dispose(); }, [geometry, material]);
|
||||
|
||||
useFrame(() => {
|
||||
if (!active || budget === 0) return;
|
||||
const time = timelineRef.current.elapsedMs / 1_000;
|
||||
const current = system.current;
|
||||
if (time < current.lastTime) {
|
||||
current.particles.fill(null); current.debt = 0; current.births = 0;
|
||||
}
|
||||
const previousTime = current.lastTime;
|
||||
current.lastTime = time;
|
||||
current.debt = Math.min(budget, current.debt + runeEmissionsBetween(
|
||||
layer.emitter.tracks[9] as { time: number; value: number }[], previousTime, time,
|
||||
));
|
||||
let births = Math.min(budget, Math.floor(current.debt));
|
||||
current.debt -= births;
|
||||
for (let index = 0; index < budget; index++) {
|
||||
const particle = current.particles[index];
|
||||
if (particle && time - particle.born >= particle.life) current.particles[index] = null;
|
||||
if (!current.particles[index] && births > 0) {
|
||||
const seed = eventId * 1009 + current.births * 7919 + index * 97;
|
||||
current.particles[index] = spawn(layer, time, seed);
|
||||
current.births++; births--;
|
||||
}
|
||||
}
|
||||
const positions = geometry.getAttribute("instancePosition") as InstancedBufferAttribute;
|
||||
const colors = geometry.getAttribute("instanceColor") as InstancedBufferAttribute;
|
||||
const sizes = geometry.getAttribute("instanceSize") as InstancedBufferAttribute;
|
||||
const rotations = geometry.getAttribute("instanceRotation") as InstancedBufferAttribute;
|
||||
const frames = geometry.getAttribute("instanceFrame") as InstancedBufferAttribute;
|
||||
for (let index = 0; index < budget; index++) {
|
||||
const particle = current.particles[index];
|
||||
if (!particle) { colors.setXYZW(index, 0, 0, 0, 0); continue; }
|
||||
const age = Math.max(0, time - particle.born);
|
||||
const lifetime = Math.min(1, age / particle.life);
|
||||
const color = runeSample(layer.sprite.tracks[0], lifetime, [1, 1, 1, 1]);
|
||||
const alpha = runeSample(layer.sprite.tracks[1], lifetime, 1);
|
||||
const scale = runeSample(layer.sprite.tracks[2], lifetime, 1) * particle.scale;
|
||||
const rotationSpeed = runeSample(layer.sprite.tracks[3], lifetime, 0) * Math.PI / 180;
|
||||
const ageSquared = age * age * 0.5;
|
||||
positions.setXYZ(index,
|
||||
particle.origin[0] + particle.velocity[0] * age + particle.acceleration[0] * ageSquared,
|
||||
particle.origin[1] + particle.velocity[1] * age + particle.acceleration[1] * ageSquared,
|
||||
particle.origin[2] + particle.velocity[2] * age + particle.acceleration[2] * ageSquared);
|
||||
colors.setXYZW(index, color[0], color[1], color[2], Math.max(0, Math.min(1, alpha * color[3])));
|
||||
sizes.setXY(index, Math.max(0.001, scale * layer.sprite.quadScale[0]),
|
||||
Math.max(0.001, scale * layer.sprite.quadScale[1]));
|
||||
rotations.setX(index, layer.sprite.orientToTravel
|
||||
? Math.atan2(particle.velocity[0], particle.velocity[1])
|
||||
: particle.orientation + age * rotationSpeed * particle.rotationSign);
|
||||
frames.setX(index, Math.floor(age * Math.max(0, layer.sprite.frameRate)));
|
||||
}
|
||||
for (const attribute of [positions, colors, sizes, rotations, frames]) attribute.needsUpdate = true;
|
||||
});
|
||||
|
||||
return <mesh name="original-runewaker-particle-layer" geometry={geometry} material={material} frustumCulled={false} />;
|
||||
});
|
||||
|
||||
export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
|
||||
graph, textures, phase, quality, active, eventId, playbackRef,
|
||||
}: {
|
||||
readonly graph: RunewakerParticleGraph;
|
||||
readonly textures: ReadonlyMap<string, Texture>;
|
||||
readonly phase: CombatPresentationPhase;
|
||||
readonly quality: Exclude<CombatVfxQuality, "off">;
|
||||
readonly active: boolean;
|
||||
readonly eventId: number;
|
||||
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
|
||||
}) {
|
||||
const layers = useMemo(() => runeLayers(graph, phase), [graph, phase]);
|
||||
const limit = quality === "high" ? 256 : 96;
|
||||
return <group name="original-runewaker-particles" scale={0.1}>
|
||||
{layers.map((layer, index) => {
|
||||
const texture = textures.get(layer.sprite.textureAssetUrl);
|
||||
if (!texture) return null;
|
||||
const sourceMaximum = Number(layer.emitter.tracks[0][0]?.value ?? 0);
|
||||
const budget = Math.max(1, Math.min(sourceMaximum, Math.floor(limit / Math.max(1, layers.length))));
|
||||
return <RuneEmitter key={`${layer.sprite.anchor}:${index}`} layer={layer} texture={texture}
|
||||
budget={budget} active={active} eventId={eventId + index * 271}
|
||||
timelineRef={playbackRef} />;
|
||||
})}
|
||||
</group>;
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseRunewakerParticleGraph, runeDurationMs, runeEmissionsBetween, runeLayers, runeMotion, runeSample,
|
||||
} from "./runewakerParticleRuntime";
|
||||
|
||||
const archiveRoot = path.join(process.cwd(), "public/assets/game/combat-effects/original/runewaker");
|
||||
const archive = JSON.parse(fs.readFileSync(path.join(archiveRoot, "manifest.json"), "utf8"));
|
||||
|
||||
function graphFor(suffix: string) {
|
||||
const result = archive.results.find((entry: { model: string }) => entry.model.endsWith(suffix));
|
||||
expect(result).toBeDefined();
|
||||
return JSON.parse(fs.readFileSync(path.join(process.cwd(), "public", result.graphUrl.slice(1)), "utf8"));
|
||||
}
|
||||
|
||||
describe("RuneWaker sprite particle runtime", () => {
|
||||
it("pairs authentic Mage impact sprites with their emitter controllers", () => {
|
||||
const graph = parseRunewakerParticleGraph(graphFor("act_electric_ball_t_freeze_hit_e.ros"));
|
||||
expect(runeMotion(graph, "impact")?.name).toBe("explode");
|
||||
const layers = runeLayers(graph, "impact");
|
||||
expect(layers).toHaveLength(2);
|
||||
expect(layers.every((layer) => layer.sprite.textureAssetUrl && layer.emitter.anchor === layer.sprite.anchor)).toBe(true);
|
||||
expect(runeDurationMs(graph, "impact")).toBeGreaterThan(900);
|
||||
});
|
||||
|
||||
it("uses the source idle motion and interpolates emission keys", () => {
|
||||
const graph = parseRunewakerParticleGraph(graphFor("act_charge02_c_down.ros"));
|
||||
expect(runeMotion(graph, "cast-start")?.name).toBe("idle");
|
||||
expect(runeLayers(graph, "cast-start")).toHaveLength(3);
|
||||
expect(runeSample([{ time: 0, value: 0 }, { time: 1, value: 20 }], 0.25, 0)).toBe(5);
|
||||
});
|
||||
|
||||
it("accepts only the Priest holy casting audio trigger as a visual-only controller", () => {
|
||||
const graph = parseRunewakerParticleGraph(graphFor("holy/act_casting_c_hand.ros"));
|
||||
expect(runeMotion(graph, "cast-start")?.name).toBe("idle");
|
||||
expect(runeLayers(graph, "cast-start")).toHaveLength(3);
|
||||
const altered = graphFor("holy/act_casting_c_hand.ros");
|
||||
const event = altered.motions.find((motion: { name: string }) => motion.name === "appear")
|
||||
.controllers.find((controller: { type: string }) => controller.type === "unsupported");
|
||||
event.eventID = 25;
|
||||
expect(() => parseRunewakerParticleGraph(altered)).toThrow();
|
||||
const unreported = graphFor("holy/act_casting_c_hand.ros");
|
||||
unreported.unsupported = [];
|
||||
expect(() => parseRunewakerParticleGraph(unreported)).toThrow();
|
||||
expect(() => parseRunewakerParticleGraph(graphFor("earth/act_casting_c_hand.ros"))).toThrow();
|
||||
});
|
||||
|
||||
it("keeps a 33 ms source burst when the first browser frame is slower", () => {
|
||||
const graph = parseRunewakerParticleGraph(graphFor("act_shieldstorm_t_freeze_hit_e.ros"));
|
||||
const rate = runeLayers(graph, "impact")[0].emitter.tracks[9] as { time: number; value: number }[];
|
||||
expect(rate[1].time).toBeCloseTo(1 / 30, 4);
|
||||
expect(runeEmissionsBetween(rate, 0, 0.1)).toBeGreaterThan(3);
|
||||
expect(runeEmissionsBetween(rate, 0.1, 0.2)).toBe(0);
|
||||
expect(runeEmissionsBetween(rate, 0, 0.02) + runeEmissionsBetween(rate, 0.02, 0.1))
|
||||
.toBeCloseTo(runeEmissionsBetween(rate, 0, 0.1), 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { CombatPresentationPhase } from "../game/combatPresentation";
|
||||
|
||||
export type RuneVector3 = readonly [number, number, number];
|
||||
export interface RuneKey<T> { readonly time: number; readonly value: T }
|
||||
export type RuneTrack<T> = readonly RuneKey<T>[];
|
||||
|
||||
export interface RuneSpriteController {
|
||||
readonly type: "sprite";
|
||||
readonly anchor: string;
|
||||
readonly texture: string;
|
||||
readonly textureAssetUrl: string;
|
||||
readonly shader: "ruSHADER_PARTICLE_SPRITEEX";
|
||||
readonly blendMode: 0 | 1 | 2;
|
||||
readonly billboardType: 1;
|
||||
readonly tileSizePixels: readonly [number, number];
|
||||
readonly frameRate: number;
|
||||
readonly frameCount: number;
|
||||
readonly quadScale: RuneVector3;
|
||||
readonly quadOffset: RuneVector3;
|
||||
readonly orientToTravel: boolean;
|
||||
readonly randomRotation: boolean;
|
||||
readonly tracks: readonly [
|
||||
RuneTrack<readonly [number, number, number, number]>,
|
||||
RuneTrack<number>,
|
||||
RuneTrack<number>,
|
||||
RuneTrack<number>,
|
||||
];
|
||||
}
|
||||
|
||||
export interface RuneEmitterController {
|
||||
readonly type: "emitter";
|
||||
readonly anchor: string;
|
||||
readonly emitterOrientation: number;
|
||||
readonly particlePath: string;
|
||||
readonly emitterPath: string;
|
||||
readonly tracks: readonly RuneTrack<number | RuneVector3>[];
|
||||
}
|
||||
|
||||
export interface RuneAudioTriggerController {
|
||||
readonly type: "unsupported";
|
||||
readonly sourceType: "CRuController_EventTrigger";
|
||||
readonly anchor: string;
|
||||
readonly eventID: 43;
|
||||
readonly eventParameter0: 1;
|
||||
readonly eventParameter1: 0;
|
||||
readonly eventString: string;
|
||||
readonly eventDuration: number;
|
||||
readonly startTime: 0;
|
||||
}
|
||||
|
||||
export interface RuneParticleLayer {
|
||||
readonly sprite: RuneSpriteController;
|
||||
readonly emitter: RuneEmitterController;
|
||||
readonly position: RuneVector3;
|
||||
}
|
||||
|
||||
export interface RunewakerParticleGraph {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: "runewaker-ros-particle-archive";
|
||||
readonly source: "runewaker";
|
||||
readonly sourceModel: string;
|
||||
readonly sourceSha256: string;
|
||||
readonly rendererReady: boolean;
|
||||
readonly textures: readonly { readonly url: string; readonly sourcePath: string }[];
|
||||
readonly motions: readonly {
|
||||
readonly name: string;
|
||||
readonly controllers: readonly (RuneSpriteController | RuneEmitterController | RuneAudioTriggerController)[];
|
||||
}[];
|
||||
readonly sprites: readonly { readonly name: string; readonly position: RuneVector3 }[];
|
||||
readonly unsupported: readonly string[];
|
||||
readonly missing: readonly string[];
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("RuneWaker graph must be an object.");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function finite(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function keys(value: unknown): value is readonly RuneKey<unknown>[] {
|
||||
return Array.isArray(value) && value.every((key) => {
|
||||
const sample = record(key);
|
||||
return finite(sample.time) && (finite(sample.value)
|
||||
|| Array.isArray(sample.value) && sample.value.every(finite));
|
||||
});
|
||||
}
|
||||
|
||||
function isVisualOnlyHolyCastTrigger(
|
||||
graph: Record<string, unknown>, motion: Record<string, unknown>, controller: Record<string, unknown>,
|
||||
): boolean {
|
||||
return graph.rendererReady === true
|
||||
&& graph.sourceModel === "model/fx/skill/holy/act_casting_c_hand.ros"
|
||||
&& motion.name === "appear"
|
||||
&& controller.sourceType === "CRuController_EventTrigger"
|
||||
&& controller.eventID === 43 // ruENTITYEVENT_AUDIO_3D_DIRECT
|
||||
&& controller.eventParameter0 === 1
|
||||
&& controller.eventParameter1 === 0
|
||||
&& typeof controller.eventString === "string"
|
||||
&& controller.eventString.replaceAll("\\", "/").toLowerCase()
|
||||
=== "sound/fx/skill/holy/act_casting_c_hand.wav"
|
||||
&& controller.startTime === 0
|
||||
&& finite(controller.eventDuration) && controller.eventDuration > 0;
|
||||
}
|
||||
|
||||
/** Parse only the source controller slice implemented by the browser renderer. */
|
||||
export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGraph {
|
||||
const graph = record(value);
|
||||
if (graph.schemaVersion !== 1 || graph.kind !== "runewaker-ros-particle-archive"
|
||||
|| graph.source !== "runewaker" || typeof graph.sourceModel !== "string"
|
||||
|| !Array.isArray(graph.motions) || !Array.isArray(graph.textures)
|
||||
|| !Array.isArray(graph.sprites) || !Array.isArray(graph.unsupported)
|
||||
|| !Array.isArray(graph.missing)) throw new TypeError("Invalid RuneWaker particle graph header.");
|
||||
if (graph.missing.length) throw new TypeError("RuneWaker graph has missing source layers.");
|
||||
if (graph.unsupported.some((type) => type !== "CRuController_EventTrigger")) {
|
||||
throw new TypeError("RuneWaker graph has unsupported source layers.");
|
||||
}
|
||||
const textureUrls = new Set(graph.textures.map((texture) => record(texture).url));
|
||||
let layerCount = 0;
|
||||
let audioTriggerCount = 0;
|
||||
for (const rawMotion of graph.motions) {
|
||||
const motion = record(rawMotion);
|
||||
if (typeof motion.name !== "string" || !Array.isArray(motion.controllers)) {
|
||||
throw new TypeError("Invalid RuneWaker motion.");
|
||||
}
|
||||
const emitters = new Set(motion.controllers.filter((controller) => record(controller).type === "emitter")
|
||||
.map((controller) => record(controller).anchor));
|
||||
for (const rawController of motion.controllers) {
|
||||
const controller = record(rawController);
|
||||
if (controller.type === "emitter") {
|
||||
if (typeof controller.anchor !== "string" || controller.particlePath !== ""
|
||||
|| controller.emitterPath !== "" || !Array.isArray(controller.tracks)
|
||||
|| controller.tracks.length !== 27 || !controller.tracks.every(keys)
|
||||
|| ![0, 1, 4].includes(Number((controller.tracks[1] as RuneTrack<number>)[0]?.value))) {
|
||||
throw new TypeError("Unsupported RuneWaker emitter controller.");
|
||||
}
|
||||
} else if (controller.type === "sprite") {
|
||||
if (typeof controller.anchor !== "string" || !emitters.has(controller.anchor)
|
||||
|| controller.shader !== "ruSHADER_PARTICLE_SPRITEEX"
|
||||
|| ![0, 1, 2].includes(Number(controller.blendMode)) || controller.billboardType !== 1
|
||||
|| typeof controller.textureAssetUrl !== "string" || !textureUrls.has(controller.textureAssetUrl)
|
||||
|| !Array.isArray(controller.tileSizePixels) || !controller.tileSizePixels.every(finite)
|
||||
|| !Array.isArray(controller.tracks) || controller.tracks.length !== 4
|
||||
|| !controller.tracks.every(keys) || !finite(controller.frameRate)
|
||||
|| !finite(controller.frameCount) || !Array.isArray(controller.quadScale)) {
|
||||
throw new TypeError("Unsupported RuneWaker sprite controller.");
|
||||
}
|
||||
layerCount++;
|
||||
} else if (controller.type === "unsupported" && isVisualOnlyHolyCastTrigger(graph, motion, controller)) {
|
||||
audioTriggerCount++;
|
||||
} else throw new TypeError(`Unsupported RuneWaker controller ${String(controller.sourceType)}.`);
|
||||
}
|
||||
}
|
||||
if (audioTriggerCount !== graph.unsupported.length || audioTriggerCount > 1) {
|
||||
throw new TypeError("RuneWaker graph has unverified event triggers.");
|
||||
}
|
||||
if (!layerCount) throw new TypeError("RuneWaker graph has no renderable sprites.");
|
||||
return graph as unknown as RunewakerParticleGraph;
|
||||
}
|
||||
|
||||
export function runeSample<T extends number | readonly number[]>(track: RuneTrack<T>, time: number, fallback: T): T {
|
||||
if (!track.length) return fallback;
|
||||
if (time <= track[0].time) return track[0].value;
|
||||
for (let index = 1; index < track.length; index++) {
|
||||
const right = track[index];
|
||||
if (time > right.time) continue;
|
||||
const left = track[index - 1];
|
||||
const t = Math.min(1, Math.max(0, (time - left.time) / Math.max(0.000001, right.time - left.time)));
|
||||
if (typeof left.value === "number" && typeof right.value === "number") {
|
||||
return (left.value + (right.value - left.value) * t) as T;
|
||||
}
|
||||
if (Array.isArray(left.value) && Array.isArray(right.value)) {
|
||||
const rightValues = right.value as readonly number[];
|
||||
return left.value.map((value, component) => value + (rightValues[component] - value) * t) as unknown as T;
|
||||
}
|
||||
return left.value;
|
||||
}
|
||||
return track[track.length - 1].value;
|
||||
}
|
||||
|
||||
/** Integrate a source rate track so a brief burst survives a slow render frame. */
|
||||
export function runeEmissionsBetween(track: RuneTrack<number>, start: number, end: number): number {
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || !track.length) return 0;
|
||||
const boundaries = [start, ...track.map((key) => key.time).filter((time) => time > start && time < end), end];
|
||||
let total = 0;
|
||||
for (let index = 1; index < boundaries.length; index++) {
|
||||
const left = boundaries[index - 1];
|
||||
const right = boundaries[index];
|
||||
total += (Math.max(0, runeSample(track, left, 0)) + Math.max(0, runeSample(track, right, 0)))
|
||||
* (right - left) * 0.5;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function runeMotion(graph: RunewakerParticleGraph, phase: CombatPresentationPhase) {
|
||||
const preferred = phase === "impact" ? "explode" : "idle";
|
||||
return graph.motions.find((motion) => motion.name === preferred)
|
||||
?? graph.motions.find((motion) => motion.name === "appear")
|
||||
?? graph.motions[0];
|
||||
}
|
||||
|
||||
export function runeLayers(graph: RunewakerParticleGraph, phase: CombatPresentationPhase): readonly RuneParticleLayer[] {
|
||||
const motion = runeMotion(graph, phase);
|
||||
if (!motion) return [];
|
||||
const emitters = new Map(motion.controllers.filter((controller): controller is RuneEmitterController => controller.type === "emitter")
|
||||
.map((emitter) => [emitter.anchor, emitter]));
|
||||
return motion.controllers.flatMap((controller) => {
|
||||
if (controller.type !== "sprite") return [];
|
||||
const emitter = emitters.get(controller.anchor);
|
||||
if (!emitter) return [];
|
||||
const source = graph.sprites.find((sprite) => controller.anchor === sprite.name
|
||||
|| controller.anchor.endsWith(`/${sprite.name}`));
|
||||
return [{ sprite: controller, emitter, position: source?.position ?? [0, 0, 0] }];
|
||||
});
|
||||
}
|
||||
|
||||
export function runeDurationMs(graph: RunewakerParticleGraph, phase: CombatPresentationPhase): number {
|
||||
const layers = runeLayers(graph, phase);
|
||||
const lastEmission = layers.reduce((maximum, layer) => Math.max(maximum,
|
||||
...layer.emitter.tracks[9].map((sample) => sample.time)), 0);
|
||||
const maxLife = layers.reduce((maximum, layer) => Math.max(maximum,
|
||||
...layer.emitter.tracks[12].map((sample) => Number(sample.value)),
|
||||
...layer.emitter.tracks[13].map((sample) => Number(sample.value))), 0);
|
||||
return Math.max(400, Math.min(6_000, (lastEmission + maxLife + 0.2) * 1_000));
|
||||
}
|
||||
Reference in new issue
Block a user