Update Healer Man server
This commit is contained in:
1 parent
71246e5b75
commit
a868401ebc
40 files changed
+1522
-161
No files matched your search
@@ -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"]} />
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(), []);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user