Update Healer Man server

This commit is contained in:
phenom committed 2026-09-24 18:15:45 -04:00
1 parent 71246e5b75
commit a868401ebc
40 files changed
+1522 -161

No files matched your search

+8 -3
View File
@@ -42,8 +42,10 @@ function Preview() {
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)
.then((index: { results: { model: string; graphUrl: string; status: string;
rendererReady?: boolean; partialRendererReady?: boolean }[] }) => {
const available = index.results.filter((entry) =>
entry.status === "decoded" || entry.rendererReady || entry.partialRendererReady)
.map(({ model, graphUrl }) => ({ model, graphUrl }));
setChoices(available);
setSelected(available.find((entry) => entry.model.includes("electric_ball_t_freeze_hit"))?.graphUrl
@@ -83,8 +85,11 @@ function Preview() {
<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`
? `${loaded.graph.partialRendererReady ? "Partial original" : "Ready"} · ${runeLayers(loaded.graph, phase).length} sprite layers · ${loaded.graph.textures.length} textures`
: "Loading RuneWaker media…")}</div>
{loaded?.graph.partialRendererReady && <div style={{ padding: "0 20px 8px" }}>
Fidelity limit: {loaded.graph.omittedVisuals?.join(", ")}
</div>}
<div style={{ height: "calc(100vh - 100px)" }}>
<Canvas camera={{ position: [3, 2.5, 5], fov: 45 }}>
<color attach="background" args={["#0b121b"]} />
+56
View File
@@ -208,6 +208,62 @@ describe("combat audio output policy", () => {
audio.resetCombatAudio();
});
it("positions release audio at the caster and impact audio at the target", async () => {
vi.resetModules();
const audio = await import("./combatAudio");
const panners: Array<{
positionX: { value: number };
positionY: { value: number };
positionZ: { value: number };
}> = [];
const audioContext = {
currentTime: 0, state: "running", destination: {},
createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: {
setTargetAtTime: vi.fn(), value: 1,
} }),
createPanner: () => {
const panner = {
connect: vi.fn(), disconnect: vi.fn(),
positionX: { value: 0 }, positionY: { value: 0 }, positionZ: { value: 0 },
panningModel: "HRTF", distanceModel: "inverse",
refDistance: 0, maxDistance: 0, rolloffFactor: 0,
};
panners.push(panner);
return panner;
},
createBufferSource: () => ({
connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
buffer: null, loop: false, onended: null,
}),
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:490256", source: "runewaker", spellId: 490256,
sounds: [
{ url: "/assets/game/combat-effects/audio/rising-release.mp3", phase: "release" },
{ url: "/assets/game/combat-effects/audio/rising-impact.mp3", phase: "impact" },
],
});
combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1, 2, 3])));
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
const release = {
...fixtureEvent, source: "runewaker" as const, sourceSpellId: 490256,
phase: "release" as const, origin: [1, 2, 3] as const,
targetPosition: [9, 8, 7] as const,
};
audio.playCombatAudio(release);
audio.playCombatAudio({ ...release, id: 2, phase: "impact" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(panners.map((panner) => [
panner.positionX.value, panner.positionY.value, panner.positionZ.value,
])).toEqual([[1, 2, 3], [9, 8, 7]]);
audio.resetCombatAudio();
});
it("loops only a flagged cast sound and stops the matching actor and ability", async () => {
vi.resetModules();
const audio = await import("./combatAudio");
+6 -3
View File
@@ -128,15 +128,18 @@ function positionPanner(
event: CombatPresentationEvent,
): PannerNode | StereoPannerNode {
if (typeof audioContext.createPanner === "function") {
const position = event.phase === "impact"
? event.targetPosition ?? event.origin
: event.origin;
const panner = audioContext.createPanner();
panner.panningModel = "HRTF";
panner.distanceModel = "inverse";
panner.refDistance = event.actorGroup === "player" ? 8 : 5;
panner.maxDistance = 55;
panner.rolloffFactor = event.actorGroup === "player" ? 0.15 : 0.8;
panner.positionX.value = event.origin[0];
panner.positionY.value = event.origin[1];
panner.positionZ.value = event.origin[2];
panner.positionX.value = position[0];
panner.positionY.value = position[1];
panner.positionZ.value = position[2];
return panner;
}
return audioContext.createStereoPanner();
+2
View File
@@ -178,6 +178,8 @@ export interface CombatEffectPhaseAsset {
/** Browser-ready graph generated from the original client asset. */
readonly effectGraphUrl?: string;
readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "wow-m2-model" | "runewaker-particles";
/** A source graph with verified sprite layers but omitted unsupported visual controllers. */
readonly visualCompleteness?: "partial";
readonly originalSourceSha256?: string;
}
+50 -30
View File
@@ -34,6 +34,7 @@ import {
updateCombatEffectQueue,
originalInstantCastEvent,
originalInstantTerminalEvent,
risingTideArrivalDelayMs,
type ActiveCombatEffect,
} from "./combatEffectRuntime";
import { OriginalCombatEffect } from "./OriginalCombatEffect";
@@ -296,37 +297,56 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
activeRef.current = active;
}, [active]);
useEffect(() => subscribeCombatPresentation((message) => {
if (message.kind === "reset") {
setEffects([]);
resetCombatAudio();
return;
}
const event = message.event;
if (activeRef.current) playCombatAudio(event);
setEffects((current) => {
const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
? current.filter((effect) => !(
effect.event.phase === "cast-start"
&& effect.event.sourceActorId === event.sourceActorId
&& effect.event.abilityId === event.abilityId
))
: current;
const presentationSettings = settingsRef.current;
const quality = qualityRef.current;
if (
event.phase === "cast-cancel"
|| !activeRef.current
|| quality === "off"
|| !presentationSettings.vfxEnabled[event.actorGroup]
) return withoutCast;
const withInstantSourceCast = effectiveCombatVfxMode(presentationSettings) === "original"
&& originalInstantCastEvent(event)
? addOriginalInstantCastEffect(withoutCast, event, quality)
: withoutCast;
return updateCombatEffectQueue(withInstantSourceCast, event, quality);
useEffect(() => {
const pendingArrivals = new Set<ReturnType<typeof setTimeout>>();
const present = (event: CombatPresentationEvent) => {
if (activeRef.current) playCombatAudio(event);
setEffects((current) => {
const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
? current.filter((effect) => !(
effect.event.phase === "cast-start"
&& effect.event.sourceActorId === event.sourceActorId
&& effect.event.abilityId === event.abilityId
))
: current;
const presentationSettings = settingsRef.current;
const quality = qualityRef.current;
if (
event.phase === "cast-cancel"
|| !activeRef.current
|| quality === "off"
|| !presentationSettings.vfxEnabled[event.actorGroup]
) return withoutCast;
const withInstantSourceCast = effectiveCombatVfxMode(presentationSettings) === "original"
&& originalInstantCastEvent(event)
? addOriginalInstantCastEffect(withoutCast, event, quality)
: withoutCast;
return updateCombatEffectQueue(withInstantSourceCast, event, quality);
});
};
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "reset") {
for (const timer of pendingArrivals) clearTimeout(timer);
pendingArrivals.clear();
setEffects([]);
resetCombatAudio();
return;
}
const event = message.event;
const arrivalDelay = risingTideArrivalDelayMs(event);
if (arrivalDelay > 0) {
const timer = setTimeout(() => {
pendingArrivals.delete(timer);
present(event);
}, arrivalDelay);
pendingArrivals.add(timer);
} else present(event);
});
}), []);
return () => {
unsubscribe();
for (const timer of pendingArrivals) clearTimeout(timer);
};
}, []);
useEffect(() => () => resetCombatAudio(), []);
+3
View File
@@ -44,6 +44,7 @@ import {
updateMobRuntimePosition,
} from "../game/mobRuntimeRegistry";
import { combatHasLineOfSight } from "../game/combatSpatial";
import { registerCombatEffectTargetVisualSize } from "./combatEffectAnchorRegistry";
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
import { dungeonEnemyScaling } from "../game/dungeonBossScaling";
@@ -909,6 +910,8 @@ function ProxyMob({
const proceduralModelAnimation = model?.animationMode === "procedural";
const labelHeight = model?.labelHeight ?? (boss ? 3.25 : 2.65) * scale;
const markerRadius = model?.markerRadius ?? 0.9 * scale;
useEffect(() => registerCombatEffectTargetVisualSize(instanceId,
{ height: labelHeight, radius: markerRadius }), [instanceId, labelHeight, markerRadius]);
const dead = mob?.dead ?? false;
const lootable = Boolean(dead && mob?.hasLoot && !mob.looted);
const attackRevision = mob?.attackRevision ?? 0;
+13 -11
View File
@@ -49,8 +49,10 @@ import {
parseOriginalCombatMeshGraph,
type OriginalCombatMeshGraph,
} from "./originalCombatMeshRuntime";
import { updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
import { shouldUseGeneratedOriginalFallback } from "./combatEffectRuntime";
import { combatEffectTargetVisualSize, updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
import {
projectileTravelDurationMs, risingTideImpactPlacement, shouldUseGeneratedOriginalFallback,
} from "./combatEffectRuntime";
import { OriginalWowModel } from "./OriginalWowModel";
import { OriginalParticleGeometry } from "./OriginalParticleGeometry";
import { RunewakerParticleEffect } from "./RunewakerParticleEffect";
@@ -722,13 +724,7 @@ function LoadedOriginalCombatEffect({
const projectile = event.phase === "release"
&& event.delivery === "projectile"
&& event.targetPosition !== undefined;
const projectileDurationMs = projectile && event.targetPosition
? Math.max(180, Math.min(550, Math.hypot(
event.targetPosition[0] - event.origin[0],
event.targetPosition[1] - event.origin[1],
event.targetPosition[2] - event.origin[2],
) / 28 * 1_000))
: 0;
const projectileDurationMs = projectile ? projectileTravelDurationMs(event) : 0;
const anchor = event.phase === "cast-start" || projectile
? event.origin
: event.targetPosition ?? event.origin;
@@ -796,11 +792,17 @@ function LoadedOriginalCombatEffect({
<group ref={groupRef} position={anchor}>
{loaded.map((item, loadedIndex) => {
const graph = item.graph;
const scale = effectScale(item.asset);
const risingTideTarget = event.phase === "impact" && event.sourceSpellId === 490256
&& item.asset.model?.toLowerCase() === "model/fx/skill/ice/act_riptide_t_freeze_hit.ros"
? risingTideImpactPlacement(event, combatEffectTargetVisualSize(event.targetActorId))
: null;
const scale = effectScale(item.asset) * (risingTideTarget?.scale ?? 1);
return (
<group
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
position={[0, effectPlacementHeight(item.asset, graph, event), 0]}
position={risingTideTarget
? [risingTideTarget.offsetX, risingTideTarget.height, risingTideTarget.offsetZ]
: [0, effectPlacementHeight(item.asset, graph, event), 0]}
scale={scale}
>
{graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures}
+237 -41
View File
@@ -1,14 +1,18 @@
import { memo, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber";
import {
AddEquation, BufferAttribute, CustomBlending, DoubleSide, DynamicDrawUsage,
AddEquation, AdditiveBlending, BufferAttribute, BufferGeometry, ClampToEdgeWrapping,
CustomBlending, DoubleSide, DynamicDrawUsage,
InstancedBufferAttribute, InstancedBufferGeometry, OneFactor, OneMinusSrcAlphaFactor,
ShaderMaterial, Vector2, ZeroFactor, type Texture,
Mesh, MeshBasicMaterial, PlaneGeometry, RepeatWrapping, ShaderMaterial, Vector2, ZeroFactor,
type Texture,
} from "three";
import type { CombatPresentationPhase, CombatVfxQuality } from "../game/combatPresentation";
import {
runeEmissionsBetween, runeLayers, runeSample, type RuneParticleLayer, type RuneVector3,
type RunewakerParticleGraph,
runeEmissionsBetween, runeGlobalAlpha, runeLayers, runeMotion, runeSample,
type RuneDecalController, type RuneEmitterController, type RuneEntityVisualizerController,
type RuneGlobalAlphaController, type RuneGlobalColorController, type RuneParticleLayer,
type RuneVector3, type RunewakerParticleGraph,
} from "./runewakerParticleRuntime";
interface Particle {
@@ -32,6 +36,7 @@ const vertexShader = /* glsl */ `
varying vec4 vColor;
varying float vFrame;
uniform vec2 quadOffset;
uniform float billboardType;
void main() {
vec4 viewPosition = modelViewMatrix * vec4(instancePosition, 1.0);
@@ -39,7 +44,16 @@ const vertexShader = /* glsl */ `
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);
if (billboardType == 2.0) {
vec3 worldPosition = (modelMatrix * vec4(instancePosition, 1.0)).xyz;
vec3 toCamera = normalize(cameraPosition - worldPosition);
vec3 right = cross(vec3(0.0, 1.0, 0.0), toCamera);
right = length(right) < 0.001 ? vec3(1.0, 0.0, 0.0) : normalize(right);
vec3 worldOffset = right * rotated.x + vec3(0.0, 1.0, 0.0) * rotated.y;
viewPosition.xyz += (viewMatrix * vec4(worldOffset, 0.0)).xyz * length(modelMatrix[0].xyz);
} else {
viewPosition.xy += rotated * length(modelViewMatrix[0].xyz);
}
gl_Position = projectionMatrix * viewPosition;
vUv = uv;
vColor = instanceColor;
@@ -78,68 +92,74 @@ function random(seed: number): number {
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 vectorAt(emitter: RuneEmitterController, track: number, time: number, fallback: RuneVector3): RuneVector3 {
return runeSample(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 scalarAt(emitter: RuneEmitterController, track: number, time: number, fallback = 0): number {
return runeSample(emitter.tracks[track] as { time: number; value: number }[], time, fallback);
}
function spawn(layer: RuneParticleLayer, time: number, seed: number): Particle {
function spawn(emitter: RuneEmitterController, position: RuneVector3,
randomRotation: boolean, 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);
const kind = Math.round(scalarAt(emitter, 1, time));
const emitterPosition = vectorAt(emitter, 2, time, [0, 0, 0]);
const extents = vectorAt(emitter, 3, time, [0, 0, 0]);
const radius = scalarAt(emitter, 4, time);
let offset: RuneVector3 = [0, 0, 0];
let radialDirection: RuneVector3 | null = null;
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) {
} else if (kind === 4 || kind === 5) {
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]
const distance = kind === 5 ? radius : randomAt(2) * radius;
const orientation = emitter.emitterOrientation;
const radial = orientation === 1 || orientation === 4
? [0, Math.sin(angle), Math.cos(angle)] as RuneVector3
: orientation === 2 || orientation === 5
? [Math.sin(angle) * distance, 0, Math.cos(angle) * distance]
: [Math.sin(angle) * distance, Math.cos(angle) * distance, 0];
? [Math.sin(angle), 0, Math.cos(angle)] as RuneVector3
: [Math.sin(angle), Math.cos(angle), 0] as RuneVector3;
offset = [radial[0] * distance, radial[1] * distance, radial[2] * distance];
radialDirection = radial;
}
const direction = vectorAt(layer, 10, time, [0, 1, 0]);
const deviation = Math.min(Math.PI, Math.abs(scalarAt(layer, 11, time)));
const direction = radialDirection ?? vectorAt(emitter, 10, time, [0, 1, 0]);
const deviation = radialDirection ? 0 : Math.min(Math.PI, Math.abs(scalarAt(emitter, 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]);
const speed = scalarAt(emitter, 14, time) + randomAt(7) * (
scalarAt(emitter, 15, time) - scalarAt(emitter, 14, time));
const force0 = vectorAt(emitter, 20, time, [0, 0, 0]);
const force1 = vectorAt(emitter, 21, time, [0, 0, 0]);
const minLife = scalarAt(emitter, 12, time, 0.5);
const maxLife = scalarAt(emitter, 13, time, 0.5);
const minScale = scalarAt(emitter, 16, time, 1);
const maxScale = scalarAt(emitter, 17, time, 1);
const minOrientation = vectorAt(emitter, 18, time, [0, 0, 0]);
const maxOrientation = vectorAt(emitter, 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]],
origin: [position[0] + emitterPosition[0] + offset[0],
position[1] + emitterPosition[1] + offset[1],
-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,
rotationSign: randomRotation && randomAt(11) < 0.5 ? -1 : 1,
};
}
const RuneEmitter = memo(function RuneEmitter({
layer, texture, budget, active, eventId, timelineRef,
graph, phase, layer, texture, budget, active, eventId, timelineRef,
}: {
readonly graph: RunewakerParticleGraph;
readonly phase: CombatPresentationPhase;
readonly layer: RuneParticleLayer;
readonly texture: Texture;
readonly budget: number;
@@ -181,6 +201,7 @@ const RuneEmitter = memo(function RuneEmitter({
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]) },
billboardType: { value: layer.sprite.billboardType },
},
transparent: true,
depthWrite: false,
@@ -198,6 +219,7 @@ const RuneEmitter = memo(function RuneEmitter({
useFrame(() => {
if (!active || budget === 0) return;
const time = timelineRef.current.elapsedMs / 1_000;
const globalAlpha = runeGlobalAlpha(graph, phase, time);
const current = system.current;
if (time < current.lastTime) {
current.particles.fill(null); current.debt = 0; current.births = 0;
@@ -214,7 +236,8 @@ const RuneEmitter = memo(function RuneEmitter({
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.particles[index] = spawn(layer.emitter, layer.position,
layer.sprite.randomRotation, time, seed);
current.births++; births--;
}
}
@@ -237,7 +260,8 @@ const RuneEmitter = memo(function RuneEmitter({
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])));
colors.setXYZW(index, color[0], color[1], color[2],
Math.max(0, Math.min(1, alpha * color[3] * globalAlpha)));
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
@@ -251,6 +275,151 @@ const RuneEmitter = memo(function RuneEmitter({
return <mesh name="original-runewaker-particle-layer" geometry={geometry} material={material} frustumCulled={false} />;
});
const RuneEntityEmitter = memo(function RuneEntityEmitter({
graph, emitter, visualizer, texture, budget, active, eventId, timelineRef,
}: {
readonly graph: RunewakerParticleGraph;
readonly emitter: RuneEmitterController;
readonly visualizer: RuneEntityVisualizerController;
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 pool = useMemo(() => {
const mesh = graph.runeMesh;
if (!mesh) throw new Error("Urgent Heal rune geometry is missing.");
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array(mesh.positions), 3));
geometry.setAttribute("uv", new BufferAttribute(new Float32Array(mesh.uvs), 2));
const tiledTexture = texture.clone();
tiledTexture.wrapS = RepeatWrapping;
tiledTexture.wrapT = ClampToEdgeWrapping;
tiledTexture.needsUpdate = true;
const meshes = Array.from({ length: budget }, (_, index) => {
const material = new MeshBasicMaterial({
name: `runewaker-urgent-rune-${index}`,
map: tiledTexture,
transparent: true,
blending: AdditiveBlending,
depthWrite: false,
side: DoubleSide,
toneMapped: false,
});
const instance = new Mesh(geometry, material);
instance.name = "original-runewaker-rune-cylinder";
instance.frustumCulled = false;
instance.visible = false;
return instance;
});
return { geometry, tiledTexture, meshes };
}, [budget, graph.runeMesh, texture]);
useEffect(() => () => {
pool.geometry.dispose();
pool.tiledTexture.dispose();
pool.meshes.forEach((mesh) => mesh.material.dispose());
}, [pool]);
useFrame(() => {
if (!active) {
pool.meshes.forEach((mesh) => { mesh.visible = false; });
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(
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(emitter, [0, 0, 0], false, time, seed);
current.births++; births--;
}
const next = current.particles[index];
const mesh = pool.meshes[index];
if (!next) { mesh.visible = false; continue; }
const age = Math.max(0, time - next.born);
const lifetime = Math.min(1, age / next.life);
const color = runeSample(visualizer.tracks[1], lifetime, [1, 1, 1, 1]);
const alpha = runeSample(visualizer.tracks[2], lifetime, 1);
const scale = runeSample(visualizer.tracks[3], lifetime, 1) * next.scale;
const ageSquared = age * age * 0.5;
mesh.position.set(
next.origin[0] + next.velocity[0] * age + next.acceleration[0] * ageSquared,
next.origin[1] + next.velocity[1] * age + next.acceleration[1] * ageSquared,
next.origin[2] + next.velocity[2] * age + next.acceleration[2] * ageSquared,
);
mesh.scale.setScalar(Math.max(0.001, scale));
mesh.rotation.y = next.orientation;
mesh.material.color.setRGB(color[0], color[1], color[2]);
mesh.material.opacity = Math.max(0, Math.min(1, alpha * color[3]));
mesh.visible = scale > 0.001 && mesh.material.opacity > 0.002;
}
});
return <group name="original-runewaker-rune-entities">
{pool.meshes.map((mesh, index) => <primitive key={index} object={mesh} />)}
</group>;
});
const RuneGroundDecal = memo(function RuneGroundDecal({
decal, alpha, color, texture, active, timelineRef,
}: {
readonly decal: RuneDecalController;
readonly alpha: RuneGlobalAlphaController;
readonly color: RuneGlobalColorController;
readonly texture: Texture;
readonly active: boolean;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
}) {
const meshRef = useRef<Mesh<PlaneGeometry, MeshBasicMaterial>>(null);
const geometry = useMemo(() => new PlaneGeometry(2, 2), []);
const material = useMemo(() => new MeshBasicMaterial({
name: "runewaker-urgent-ground-decal",
map: texture,
transparent: true,
blending: AdditiveBlending,
depthWrite: false,
side: DoubleSide,
toneMapped: false,
}), [texture]);
useEffect(() => () => { geometry.dispose(); material.dispose(); }, [geometry, material]);
useFrame(() => {
const mesh = meshRef.current;
if (!mesh) return;
if (!active) { mesh.visible = false; return; }
const time = timelineRef.current.elapsedMs / 1_000;
const radius = runeSample(decal.tracks[2], time, 0);
const rotation = runeSample(decal.tracks[1], time, 0);
const opacity = runeSample(alpha.alphaTrack, Math.max(0, time - alpha.startTime), 0);
const tint = runeSample(color.tracks[0], time, [1, 1, 1, 1]);
material.color.setRGB(tint[0], tint[1], tint[2]);
material.opacity = Math.max(0, Math.min(1, opacity * tint[3]));
mesh.scale.set(radius, radius, 1);
mesh.rotation.z = rotation;
mesh.visible = time <= alpha.startTime + alpha.duration && material.opacity > 0.002;
});
// The client projects this decal onto terrain triangles. The preview uses a level ground plane.
return <group name="original-runewaker-ground-decal" position={[0, 0.1, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<mesh ref={meshRef} geometry={geometry} material={material} visible={false} frustumCulled={false} />
</group>;
});
export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
graph, textures, phase, quality, active, eventId, playbackRef,
}: {
@@ -263,6 +432,22 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
}) {
const layers = useMemo(() => runeLayers(graph, phase), [graph, phase]);
const motion = useMemo(() => runeMotion(graph, phase), [graph, phase]);
const runeEntity = motion?.controllers.find(
(controller): controller is RuneEntityVisualizerController => controller.type === "entity-visualizer",
);
const runeEmitter = motion?.controllers.find(
(controller): controller is RuneEmitterController => controller.type === "emitter" && controller.anchor === runeEntity?.anchor,
);
const decal = motion?.controllers.find(
(controller): controller is RuneDecalController => controller.type === "decal",
);
const decalAlpha = motion?.controllers.find(
(controller): controller is RuneGlobalAlphaController => controller.type === "global-alpha" && controller.anchor === decal?.anchor,
);
const decalColor = motion?.controllers.find(
(controller): controller is RuneGlobalColorController => controller.type === "global-color" && controller.anchor === decal?.anchor,
);
const limit = quality === "high" ? 256 : 96;
return <group name="original-runewaker-particles" scale={0.1}>
{layers.map((layer, index) => {
@@ -270,9 +455,20 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
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}
return <RuneEmitter key={`${layer.sprite.anchor}:${index}`} graph={graph} phase={phase}
layer={layer} texture={texture}
budget={budget} active={active} eventId={eventId + index * 271}
timelineRef={playbackRef} />;
})}
{graph.runeMesh && runeEntity && runeEmitter && textures.get(graph.runeMesh.textureAssetUrl)
? <RuneEntityEmitter graph={graph} emitter={runeEmitter} visualizer={runeEntity}
texture={textures.get(graph.runeMesh.textureAssetUrl)!}
budget={quality === "high" ? 12 : 6} active={active} eventId={eventId + 617}
timelineRef={playbackRef} />
: null}
{decal && decalAlpha && decalColor && textures.get(decal.textureAssetUrl)
? <RuneGroundDecal decal={decal} alpha={decalAlpha} color={decalColor}
texture={textures.get(decal.textureAssetUrl)!} active={active} timelineRef={playbackRef} />
: null}
</group>;
});
@@ -4,6 +4,8 @@ import {
clearCombatEffectAnchorRegistry,
registerCombatEffectAnchor,
updateCombatEffectAnchorPosition,
registerCombatEffectTargetVisualSize,
combatEffectTargetVisualSize,
} from "./combatEffectAnchorRegistry";
afterEach(() => clearCombatEffectAnchorRegistry());
@@ -49,4 +51,14 @@ describe("combat-effect visual anchors", () => {
expect(updateCombatEffectAnchorPosition("missing", effect, new Vector3())).toBe(false);
expect(effect.position.toArray()).toEqual([1, 2, 3]);
});
it("tracks creature display bounds without losing a replacement registration", () => {
const unregisterOld = registerCombatEffectTargetVisualSize("verdan", { height: 10, radius: 3 });
const current = { height: 15.85, radius: 5.3 };
const unregisterCurrent = registerCombatEffectTargetVisualSize("verdan", current);
unregisterOld();
expect(combatEffectTargetVisualSize("verdan")).toBe(current);
unregisterCurrent();
expect(combatEffectTargetVisualSize("verdan")).toBeNull();
});
});
+20
View File
@@ -1,6 +1,25 @@
import { Vector3, type Object3D } from "three";
const anchors = new Map<string, Object3D>();
export interface CombatEffectTargetVisualSize {
readonly height: number;
readonly radius: number;
}
const targetVisualSizes = new Map<string, CombatEffectTargetVisualSize>();
/** Model display bounds used to place target-attached spell effects on large creatures. */
export function registerCombatEffectTargetVisualSize(
actorId: string, size: CombatEffectTargetVisualSize,
): () => void {
targetVisualSizes.set(actorId, size);
return () => {
if (targetVisualSizes.get(actorId) === size) targetVisualSizes.delete(actorId);
};
}
export function combatEffectTargetVisualSize(actorId: string | undefined): CombatEffectTargetVisualSize | null {
return actorId ? targetVisualSizes.get(actorId) ?? null : null;
}
export function registerCombatEffectAnchor(actorId: string, object: Object3D): () => void {
anchors.set(actorId, object);
@@ -30,4 +49,5 @@ export function updateCombatEffectAnchorPosition(
export function clearCombatEffectAnchorRegistry(): void {
anchors.clear();
targetVisualSizes.clear();
}
+32
View File
@@ -7,6 +7,9 @@ import {
advanceCombatEffectFrame,
originalInstantCastEvent,
originalInstantTerminalEvent,
projectileTravelDurationMs,
risingTideArrivalDelayMs,
risingTideImpactPlacement,
shouldUseGeneratedOriginalFallback,
updateCombatEffectQueue,
type ActiveCombatEffect,
@@ -46,6 +49,35 @@ function enqueue(
}
describe("combat-effect runtime budget", () => {
it("delays only the Rising Tide target arrival by the projectile's travel time", () => {
const release = event(1, { source: "runewaker", sourceSpellId: 490256,
phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] });
const impact = { ...release, id: 2, phase: "impact" as const };
expect(projectileTravelDurationMs(release)).toBe(500);
expect(risingTideArrivalDelayMs(release)).toBe(0);
expect(risingTideArrivalDelayMs(impact)).toBe(500);
expect(risingTideArrivalDelayMs({ ...impact, sourceSpellId: 491147 })).toBe(0);
expect(risingTideArrivalDelayMs({ ...impact, delivery: "area" })).toBe(0);
expect(projectileTravelDurationMs({ ...release, targetPosition: [100, 0, 0] })).toBe(550);
expect(projectileTravelDurationMs({ ...release, targetPosition: [1, 0, 0] })).toBe(180);
});
it("places Rising Tide impact on a giant target's caster-facing body surface", () => {
const cast = event(1, { origin: [0, 0, 0], targetPosition: [10, 0, 0] });
const verdan = risingTideImpactPlacement(cast, { height: 15.85, radius: 5.3 });
expect(verdan.height).toBe(2.4);
expect(verdan.offsetX).toBeCloseTo(-5.05);
expect(verdan.offsetZ).toBe(0);
expect(verdan.scale).toBe(5);
const humanoid = risingTideImpactPlacement(cast, { height: 2.65, radius: 0.9 });
expect(humanoid.height).toBeCloseTo(1.1925);
expect(humanoid.offsetX).toBeCloseTo(-0.65);
expect(humanoid.scale).toBe(1);
expect(risingTideImpactPlacement(cast, null)).toEqual({
offsetX: 0, height: 1.05, offsetZ: 0, scale: 1,
});
});
it("expires effects by wall time while bounding motion after a hitch", () => {
let frame = advanceCombatEffectFrame(0, 1 / 60, 420);
frame = advanceCombatEffectFrame(frame.elapsedMs, 1 / 60, 420);
+50
View File
@@ -2,6 +2,7 @@ import type {
CombatPresentationEvent,
CombatVfxQuality,
} from "../game/combatPresentation";
import type { CombatEffectTargetVisualSize } from "./combatEffectAnchorRegistry";
export interface ActiveCombatEffect {
readonly key: string;
@@ -15,6 +16,55 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, numbe
high: 32,
};
/** Keep source projectile travel and its arrival cue on the same presentation clock. */
export function projectileTravelDurationMs(
event: Pick<CombatPresentationEvent, "origin" | "targetPosition" | "delivery">,
): number {
if (event.delivery !== "projectile" || !event.targetPosition) return 0;
const distance = Math.hypot(
event.targetPosition[0] - event.origin[0],
event.targetPosition[1] - event.origin[1],
event.targetPosition[2] - event.origin[2],
);
return Number.isFinite(distance) ? Math.max(180, Math.min(550, distance / 28 * 1_000)) : 0;
}
/** RuneWaker's Rising Tide impact model and sound are target arrival events. */
export function risingTideArrivalDelayMs(
event: Pick<CombatPresentationEvent,
"phase" | "source" | "sourceSpellId" | "origin" | "targetPosition" | "delivery">,
): number {
return event.phase === "impact" && event.source === "runewaker" && event.sourceSpellId === 490256
? projectileTravelDurationMs(event) : 0;
}
/** Approximate RuneWaker's target `freeze_hit` attachment from displayed creature bounds. */
export function risingTideImpactPlacement(
event: Pick<CombatPresentationEvent, "origin" | "targetPosition">,
size: CombatEffectTargetVisualSize | null,
): { readonly offsetX: number; readonly height: number; readonly offsetZ: number; readonly scale: number } {
if (!size || !event.targetPosition || !Number.isFinite(size.height)
|| !Number.isFinite(size.radius)) {
return { offsetX: 0, height: 1.05, offsetZ: 0, scale: 1 };
}
const radius = Math.max(0.6, Math.min(6, size.radius));
// Giant bosses can extend beyond the camera's top edge. Keep the hit on the
// visible lower body even when their model label is many metres above ground.
const height = Math.max(1.05, Math.min(2.4, size.height * 0.45));
const frontDistance = Math.max(0, radius - 0.25);
const towardCasterX = event.origin[0] - event.targetPosition[0];
const towardCasterZ = event.origin[2] - event.targetPosition[2];
const horizontalLength = Math.hypot(towardCasterX, towardCasterZ);
const directionX = horizontalLength > 0.001 ? towardCasterX / horizontalLength : 0;
const directionZ = horizontalLength > 0.001 ? towardCasterZ / horizontalLength : 0;
return {
offsetX: directionX * frontDistance,
height,
offsetZ: directionZ * frontDistance,
scale: Math.max(1, Math.min(5, radius / 0.9)),
};
}
export interface CombatEffectFrameState {
readonly elapsedMs: number;
readonly motionDeltaSeconds: number;
+44 -1
View File
@@ -2,7 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
parseRunewakerParticleGraph, runeDurationMs, runeEmissionsBetween, runeLayers, runeMotion, runeSample,
parseRunewakerParticleGraph, runeDurationMs, runeEmissionsBetween, runeGlobalAlpha,
runeLayers, runeMotion, runeSample,
} from "./runewakerParticleRuntime";
const archiveRoot = path.join(process.cwd(), "public/assets/game/combat-effects/original/runewaker");
@@ -46,6 +47,48 @@ describe("RuneWaker sprite particle runtime", () => {
expect(() => parseRunewakerParticleGraph(graphFor("earth/act_casting_c_hand.ros"))).toThrow();
});
it("renders the source Rising Tide cast, projectile, and target hit layers", () => {
const cast = parseRunewakerParticleGraph(graphFor("ice/act_icecone01_c_hand.ros"));
const release = parseRunewakerParticleGraph(graphFor("ice/act_icecone01_f.ros"));
const impact = parseRunewakerParticleGraph(graphFor("ice/act_riptide_t_freeze_hit.ros"));
expect(runeLayers(cast, "cast-start")).toHaveLength(4);
expect(runeLayers(release, "release")).toHaveLength(3);
expect(runeMotion(impact, "impact")?.name).toBe("explode");
expect(runeLayers(impact, "impact")).toHaveLength(5);
expect(runeGlobalAlpha(cast, "cast-start", 0.3)).toBe(1);
expect(runeGlobalAlpha(impact, "impact", 0.1)).toBe(1);
const altered = graphFor("ice/act_riptide_t_freeze_hit.ros");
altered.motions[0].controllers.find((controller: { eventID: number }) => controller.eventID === 1).eventID = 25;
expect(() => parseRunewakerParticleGraph(altered)).toThrow();
});
it("accepts Urgent Heal's original sprites, rune entity, and level-ground decal as partial", () => {
const graph = parseRunewakerParticleGraph(graphFor("holy/act_benedictory_heal_t_down.ros"));
expect(graph.rendererReady).toBe(false);
expect(graph.partialRendererReady).toBe(true);
expect(graph.visualFidelity).toBe("partial-original");
expect(graph.omittedVisuals).toEqual(["CRuEntity_Decal:terrain-projection"]);
expect(runeLayers(graph, "impact")).toHaveLength(2);
expect(graph.runeMesh?.positions).toHaveLength(72 * 9);
expect(graph.runeMesh?.uvs).toHaveLength(72 * 6);
expect(graph.motions[0].controllers.filter((controller) =>
controller.type === "entity-visualizer" || controller.type === "decal"
|| controller.type === "global-color")).toHaveLength(3);
expect(runeDurationMs(graph, "impact")).toBeGreaterThan(1_630);
expect(runeGlobalAlpha(graph, "impact", 0)).toBe(1);
const altered = graphFor("holy/act_benedictory_heal_t_down.ros");
altered.motions[0].controllers.find((controller: { sourceType: string }) =>
controller.sourceType === "CRuController_Particle_Visualizer_Entity").entityName = "model/other.ros";
expect(() => parseRunewakerParticleGraph(altered)).toThrow();
const missingMesh = graphFor("holy/act_benedictory_heal_t_down.ros");
delete missingMesh.runeMesh;
expect(() => parseRunewakerParticleGraph(missingMesh)).toThrow();
const wrongDecal = graphFor("holy/act_benedictory_heal_t_down.ros");
wrongDecal.motions[0].controllers.find((controller: { type: string }) =>
controller.type === "decal").decalTexture = "texture/other.dds";
expect(() => parseRunewakerParticleGraph(wrongDecal)).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 }[];
+265 -15
View File
@@ -11,7 +11,7 @@ export interface RuneSpriteController {
readonly textureAssetUrl: string;
readonly shader: "ruSHADER_PARTICLE_SPRITEEX";
readonly blendMode: 0 | 1 | 2;
readonly billboardType: 1;
readonly billboardType: 1 | 2;
readonly tileSizePixels: readonly [number, number];
readonly frameRate: number;
readonly frameCount: number;
@@ -36,16 +36,61 @@ export interface RuneEmitterController {
readonly tracks: readonly RuneTrack<number | RuneVector3>[];
}
export interface RuneAudioTriggerController {
export interface RuneEventTriggerController {
readonly type: "unsupported";
readonly sourceType: "CRuController_EventTrigger";
readonly anchor: string;
readonly eventID: 43;
readonly eventParameter0: 1;
readonly eventParameter1: 0;
readonly eventID: number;
readonly eventParameter0: number;
readonly eventParameter1: number;
readonly eventString: string;
readonly eventDuration: number;
readonly startTime: 0;
readonly startTime: number;
}
export interface RuneGlobalAlphaController {
readonly type: "global-alpha";
readonly sourceType: "CRuController_GlobalAlpha";
readonly anchor: string;
readonly startTime: number;
readonly duration: number;
readonly alphaTrack: RuneTrack<number>;
}
export interface RuneEntityVisualizerController {
readonly type: "entity-visualizer";
readonly sourceType: "CRuController_Particle_Visualizer_Entity";
readonly anchor: "frame/rune";
readonly entityName: string;
readonly orientToTravel: false;
readonly tracks: readonly [RuneTrack<string>, RuneTrack<readonly [number, number, number, number]>,
RuneTrack<number>, RuneTrack<number>];
}
export interface RuneDecalController {
readonly type: "decal";
readonly sourceType: "CRuController_Decal";
readonly anchor: "frame/decal";
readonly decalTexture: string;
readonly textureAssetUrl: string;
readonly billboardType: 0;
readonly tracks: readonly [RuneTrack<string>, RuneTrack<number>, RuneTrack<number>];
}
export interface RuneGlobalColorController {
readonly type: "global-color";
readonly sourceType: "CRuController_GlobalColor";
readonly anchor: "frame/decal";
readonly tracks: readonly [RuneTrack<readonly [number, number, number, number]>];
}
export interface RuneMeshGeometry {
readonly sourceModel: string;
readonly sourceSha256: string;
readonly sourceTexture: string;
readonly textureAssetUrl: string;
readonly positions: readonly number[];
readonly uvs: readonly number[];
}
export interface RuneParticleLayer {
@@ -61,10 +106,16 @@ export interface RunewakerParticleGraph {
readonly sourceModel: string;
readonly sourceSha256: string;
readonly rendererReady: boolean;
readonly partialRendererReady?: boolean;
readonly visualFidelity?: "partial-original";
readonly omittedVisuals?: readonly string[];
readonly runeMesh?: RuneMeshGeometry;
readonly textures: readonly { readonly url: string; readonly sourcePath: string }[];
readonly motions: readonly {
readonly name: string;
readonly controllers: readonly (RuneSpriteController | RuneEmitterController | RuneAudioTriggerController)[];
readonly controllers: readonly (RuneSpriteController | RuneEmitterController
| RuneEventTriggerController | RuneGlobalAlphaController
| RuneEntityVisualizerController | RuneDecalController | RuneGlobalColorController)[];
}[];
readonly sprites: readonly { readonly name: string; readonly position: RuneVector3 }[];
readonly unsupported: readonly string[];
@@ -105,6 +156,163 @@ function isVisualOnlyHolyCastTrigger(
&& finite(controller.eventDuration) && controller.eventDuration > 0;
}
const ICE_CAST_MODEL = "model/fx/skill/ice/act_icecone01_c_hand.ros";
const ICE_RELEASE_MODEL = "model/fx/skill/ice/act_icecone01_f.ros";
const RIPTIDE_IMPACT_MODEL = "model/fx/skill/ice/act_riptide_t_freeze_hit.ros";
const URGENT_IMPACT_MODEL = "model/fx/skill/holy/act_benedictory_heal_t_down.ros";
const expectedSpecialControllerCounts = new Map([
["model/fx/skill/holy/act_casting_c_hand.ros", [1, 0]],
[ICE_CAST_MODEL, [3, 3]],
[ICE_RELEASE_MODEL, [4, 0]],
[RIPTIDE_IMPACT_MODEL, [2, 1]],
[URGENT_IMPACT_MODEL, [1, 1]],
]);
const urgentOmittedVisuals = [
"CRuEntity_Decal:terrain-projection",
];
const urgentUnsupportedTypes = new Set([
"CRuController_EventTrigger",
]);
function isVerifiedUrgentPartial(graph: Record<string, unknown>): boolean {
const mesh = graph.runeMesh && typeof graph.runeMesh === "object"
? graph.runeMesh as Record<string, unknown> : null;
return graph.sourceModel === URGENT_IMPACT_MODEL
&& graph.rendererReady === false && graph.partialRendererReady === true
&& graph.visualFidelity === "partial-original"
&& Array.isArray(graph.omittedVisuals)
&& graph.omittedVisuals.length === urgentOmittedVisuals.length
&& graph.omittedVisuals.every((visual, index) => visual === urgentOmittedVisuals[index])
&& Array.isArray(graph.unsupported)
&& graph.unsupported.length === urgentUnsupportedTypes.size
&& graph.unsupported.every((type) => urgentUnsupportedTypes.has(type))
&& mesh?.sourceModel === "model/fx/skill/holy/_polygon/rune_cylinder.ros"
&& mesh.sourceTexture === "texture/fx/skill/rune.dds"
&& typeof mesh.sourceSha256 === "string" && /^[a-f0-9]{64}$/.test(mesh.sourceSha256)
&& typeof mesh.textureAssetUrl === "string"
&& Array.isArray(mesh.positions) && mesh.positions.length === 72 * 9 && mesh.positions.every(finite)
&& Array.isArray(mesh.uvs) && mesh.uvs.length === 72 * 6 && mesh.uvs.every(finite);
}
function isUrgentPartialController(
graph: Record<string, unknown>, motion: Record<string, unknown>, controller: Record<string, unknown>,
): boolean {
if (!isVerifiedUrgentPartial(graph) || motion.name !== "explode") return false;
return controller.sourceType === "CRuController_EventTrigger" && controller.anchor === "frame"
&& controller.eventID === 43 && controller.eventParameter0 === 0
&& controller.eventParameter1 === 0 && controller.startTime === 0
&& controller.eventDuration === 0
&& normalizedEventString(controller) === "sound/fx/skill/holy/act_benedictory_heal_t_down.mp3";
}
function stringKeys(value: unknown): value is RuneTrack<string> {
return Array.isArray(value) && value.every((key) => {
const sample = record(key);
return finite(sample.time) && typeof sample.value === "string";
});
}
function isUrgentEntityController(graph: Record<string, unknown>, controller: Record<string, unknown>): boolean {
if (!isVerifiedUrgentPartial(graph)) return false;
const tracks = controller.tracks;
return controller.sourceType === "CRuController_Particle_Visualizer_Entity"
&& controller.anchor === "frame/rune" && controller.orientToTravel === false
&& typeof controller.entityName === "string"
&& controller.entityName.replaceAll("\\", "/").toLowerCase()
=== "model/fx/skill/holy/_polygon/rune_cylinder.ros"
&& Array.isArray(tracks) && tracks.length === 4
&& stringKeys(tracks[0]) && tracks[0].length === 2
&& tracks[0][0].value === "idle"
&& keys(tracks[1]) && tracks[1].length === 2
&& tracks[1].every((key) => Array.isArray(key.value) && key.value.length === 4)
&& keys(tracks[2]) && tracks[2].length === 3
&& keys(tracks[3]) && tracks[3].length === 3;
}
function isUrgentDecalController(graph: Record<string, unknown>, controller: Record<string, unknown>): boolean {
if (!isVerifiedUrgentPartial(graph)) return false;
const tracks = controller.tracks;
return controller.sourceType === "CRuController_Decal"
&& controller.anchor === "frame/decal" && controller.billboardType === 0
&& typeof controller.decalTexture === "string"
&& controller.decalTexture.replaceAll("\\", "/").toLowerCase()
=== "texture/fx/skill/kan_rune_circle.dds"
&& typeof controller.textureAssetUrl === "string"
&& Array.isArray(tracks) && tracks.length === 3
&& stringKeys(tracks[0]) && tracks[0].length === 1
&& tracks[0][0].value.replaceAll("\\", "/").toLowerCase()
=== "texture/fx/skill/kan_rune_circle.dds"
&& keys(tracks[1]) && tracks[1].length === 2
&& keys(tracks[2]) && tracks[2].length === 3;
}
function isUrgentGlobalColor(graph: Record<string, unknown>, controller: Record<string, unknown>): boolean {
if (!isVerifiedUrgentPartial(graph)) return false;
const tracks = controller.tracks;
return controller.sourceType === "CRuController_GlobalColor"
&& controller.anchor === "frame/decal" && Array.isArray(tracks)
&& tracks.length === 1 && keys(tracks[0]) && tracks[0].length === 2
&& tracks[0].every((key) => Array.isArray(key.value) && key.value.length === 4);
}
function normalizedEventString(controller: Record<string, unknown>) {
return typeof controller.eventString === "string"
? controller.eventString.replaceAll("\\", "/").toLowerCase() : null;
}
function isVisualOnlyRisingTideTrigger(
graph: Record<string, unknown>, motion: Record<string, unknown>, controller: Record<string, unknown>,
): boolean {
if (graph.rendererReady !== true || controller.sourceType !== "CRuController_EventTrigger"
|| controller.startTime !== 0 || controller.eventParameter1 !== 0) return false;
const eventString = normalizedEventString(controller);
if (graph.sourceModel === ICE_CAST_MODEL && ["idle", "appear", "disappear"].includes(String(motion.name))) {
return controller.eventID === 43 && controller.eventParameter0 === 1
&& eventString === "sound/fx/skill/ice/act_cone01_c_hand.mp3"
&& finite(controller.eventDuration) && controller.eventDuration > 0;
}
if (graph.sourceModel === ICE_RELEASE_MODEL && ["idle", "appear", "disappear"].includes(String(motion.name))) {
return controller.eventID === 44 && controller.eventParameter0 === 1
&& eventString === "missile" && controller.eventDuration === 1
|| motion.name === "appear" && controller.eventID === 43
&& controller.eventParameter0 === 0 && controller.eventDuration === 0
&& eventString === "sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3";
}
if (graph.sourceModel === RIPTIDE_IMPACT_MODEL && motion.name === "explode") {
return controller.eventID === 1 && controller.eventParameter0 === 0
&& controller.eventDuration === 0 && eventString === ""
|| controller.eventID === 43 && controller.eventParameter0 === 0
&& controller.eventDuration === 0
&& eventString === "sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3";
}
return false;
}
function isVerifiedGlobalAlpha(
graph: Record<string, unknown>, motion: Record<string, unknown>, controller: Record<string, unknown>,
): boolean {
if (graph.rendererReady !== true && !isVerifiedUrgentPartial(graph)) return false;
if (controller.sourceType !== "CRuController_GlobalAlpha"
|| controller.startTime !== 0 || !finite(controller.duration) || !keys(controller.alphaTrack)
|| !controller.alphaTrack.every((key) => finite(key.value) && key.value >= 0 && key.value <= 1)) return false;
const track = controller.alphaTrack;
if (graph.sourceModel === ICE_CAST_MODEL && ["idle", "appear", "disappear"].includes(String(motion.name))) {
return motion.name === "appear"
? controller.duration > 0 && track.length === 2
&& track[0].time === 0 && track[0].value === 0 && track[1].value === 1
: controller.duration === 0 && track.length === 1
&& track[0].time === 0 && track[0].value === 1;
}
if (isVerifiedUrgentPartial(graph) && motion.name === "explode") {
return controller.anchor === "frame/decal" && controller.duration > 0
&& track.length === 4 && track[0].time === 0 && track[0].value === 0
&& track[1].value === 1 && track[2].value === 1 && track[3].value === 0;
}
return graph.sourceModel === RIPTIDE_IMPACT_MODEL && motion.name === "explode"
&& controller.duration === 0 && track.length === 1
&& track[0].time === 0 && track[0].value === 1;
}
/** Parse only the source controller slice implemented by the browser renderer. */
export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGraph {
const graph = record(value);
@@ -114,12 +322,20 @@ export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGr
|| !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")) {
const urgentPartial = isVerifiedUrgentPartial(graph);
if (!urgentPartial && 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));
if (urgentPartial && !textureUrls.has((graph.runeMesh as RuneMeshGeometry).textureAssetUrl)) {
throw new TypeError("Urgent Heal rune mesh texture is not packaged.");
}
let layerCount = 0;
let audioTriggerCount = 0;
let visualOnlyTriggerCount = 0;
let globalAlphaCount = 0;
let entityCount = 0;
let decalCount = 0;
let colorCount = 0;
for (const rawMotion of graph.motions) {
const motion = record(rawMotion);
if (typeof motion.name !== "string" || !Array.isArray(motion.controllers)) {
@@ -133,13 +349,13 @@ export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGr
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))) {
|| ![0, 1, 4, 5].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
|| ![0, 1, 2].includes(Number(controller.blendMode)) || ![1, 2].includes(Number(controller.billboardType))
|| typeof controller.textureAssetUrl !== "string" || !textureUrls.has(controller.textureAssetUrl)
|| !Array.isArray(controller.tileSizePixels) || !controller.tileSizePixels.every(finite)
|| !Array.isArray(controller.tracks) || controller.tracks.length !== 4
@@ -149,12 +365,31 @@ export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGr
}
layerCount++;
} else if (controller.type === "unsupported" && isVisualOnlyHolyCastTrigger(graph, motion, controller)) {
audioTriggerCount++;
visualOnlyTriggerCount++;
} else if (controller.type === "unsupported" && isVisualOnlyRisingTideTrigger(graph, motion, controller)) {
visualOnlyTriggerCount++;
} else if (controller.type === "unsupported" && isUrgentPartialController(graph, motion, controller)) {
visualOnlyTriggerCount++;
} else if (controller.type === "entity-visualizer" && isUrgentEntityController(graph, controller)) {
if (!emitters.has(controller.anchor)) throw new TypeError("Urgent rune entity has no emitter.");
entityCount++;
} else if (controller.type === "decal" && isUrgentDecalController(graph, controller)) {
if (!textureUrls.has(controller.textureAssetUrl)) throw new TypeError("Urgent decal texture is not packaged.");
decalCount++;
} else if (controller.type === "global-color" && isUrgentGlobalColor(graph, controller)) {
colorCount++;
} else if (controller.type === "global-alpha" && isVerifiedGlobalAlpha(graph, motion, controller)) {
globalAlphaCount++;
} 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.");
const [expectedTriggers, expectedAlpha] = expectedSpecialControllerCounts.get(graph.sourceModel) ?? [0, 0];
if (visualOnlyTriggerCount !== expectedTriggers || globalAlphaCount !== expectedAlpha
|| entityCount !== (urgentPartial ? 1 : 0)
|| decalCount !== (urgentPartial ? 1 : 0)
|| colorCount !== (urgentPartial ? 1 : 0)
|| graph.unsupported.length !== (urgentPartial ? urgentUnsupportedTypes.size : expectedTriggers > 0 ? 1 : 0)) {
throw new TypeError("RuneWaker graph has unverified event or alpha controllers.");
}
if (!layerCount) throw new TypeError("RuneWaker graph has no renderable sprites.");
return graph as unknown as RunewakerParticleGraph;
@@ -216,6 +451,17 @@ export function runeLayers(graph: RunewakerParticleGraph, phase: CombatPresentat
});
}
export function runeGlobalAlpha(graph: RunewakerParticleGraph, phase: CombatPresentationPhase, time: number): number {
// Urgent Heal's alpha track belongs to its ground decal, not its sprite layers.
if (graph.partialRendererReady) return 1;
const controller = runeMotion(graph, phase)?.controllers.find(
(candidate): candidate is RuneGlobalAlphaController => candidate.type === "global-alpha",
);
if (!controller) return 1;
return Math.max(0, Math.min(1, runeSample(controller.alphaTrack,
Math.max(0, time - controller.startTime), 1)));
}
export function runeDurationMs(graph: RunewakerParticleGraph, phase: CombatPresentationPhase): number {
const layers = runeLayers(graph, phase);
const lastEmission = layers.reduce((maximum, layer) => Math.max(maximum,
@@ -223,5 +469,9 @@ export function runeDurationMs(graph: RunewakerParticleGraph, phase: CombatPrese
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));
const decal = runeMotion(graph, phase)?.controllers.find(
(controller): controller is RuneDecalController => controller.type === "decal",
);
const decalEnd = decal ? Math.max(...decal.tracks[2].map((sample) => sample.time)) : 0;
return Math.max(400, Math.min(6_000, Math.max(lastEmission + maxLife + 0.2, decalEnd) * 1_000));
}