Update Healer Man server

This commit is contained in:
phenom committed 2026-09-24 16:42:43 -04:00
1 parent a64b2cb575
commit 71246e5b75
502 files changed
+4245 -19

No files matched your search

+105
View File
@@ -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());
}
+122 -1
View File
@@ -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
View File
@@ -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;
+17
View File
@@ -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"),
+3
View File
@@ -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;
+22 -3
View File
@@ -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}
+278
View File
@@ -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);
});
});
+227
View File
@@ -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));
}