forked from phenom/healer-man
Release Healer Man 0.1.12
This commit is contained in:
1 parent
13970d03c8
commit
65e24f644e
201 files changed
+5061
-123
No files matched your search
@@ -0,0 +1,678 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
DynamicDrawUsage,
|
||||
Group,
|
||||
MeshBasicMaterial,
|
||||
MultiplyBlending,
|
||||
NormalBlending,
|
||||
ShaderMaterial,
|
||||
SRGBColorSpace,
|
||||
Texture,
|
||||
TextureLoader,
|
||||
Vector2,
|
||||
} from "three";
|
||||
import {
|
||||
combatOriginalPhaseAssets,
|
||||
preloadCombatEffectManifest,
|
||||
type CombatEffectPhaseAsset,
|
||||
type CombatPresentationEvent,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import { abilityById } from "../game/abilityCatalog";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
|
||||
import {
|
||||
interpolateOriginalLifetime,
|
||||
interpolateOriginalScalar,
|
||||
originalAuraMatchesPresentationEvent,
|
||||
originalEmitterParticleBudget,
|
||||
parseOriginalCombatEffectGraph,
|
||||
type OriginalCombatEffectAnimatedTrackName,
|
||||
type OriginalCombatEffectGraph,
|
||||
type OriginalCombatEffectParticle,
|
||||
} from "./originalCombatEffectRuntime";
|
||||
import {
|
||||
parseOriginalCombatMeshGraph,
|
||||
type OriginalCombatMeshGraph,
|
||||
} from "./originalCombatMeshRuntime";
|
||||
|
||||
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph;
|
||||
|
||||
interface LoadedOriginalGraph {
|
||||
readonly asset: CombatEffectPhaseAsset;
|
||||
readonly graph: OriginalSourceEffectGraph;
|
||||
readonly textures: ReadonlyMap<number, Texture>;
|
||||
}
|
||||
|
||||
interface OriginalCombatEffectProps {
|
||||
readonly event: CombatPresentationEvent;
|
||||
readonly quality: Exclude<CombatVfxQuality, "off">;
|
||||
readonly active: boolean;
|
||||
readonly onDone: () => void;
|
||||
}
|
||||
|
||||
const textureLoader = new TextureLoader();
|
||||
const graphRequestCache = new Map<string, Promise<OriginalSourceEffectGraph>>();
|
||||
const textureRequestCache = new Map<string, Promise<Texture>>();
|
||||
const ORIGINAL_EFFECT_SLOT_LIMIT = { low: 384, high: 1_024 } as const;
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec3 particleColor;
|
||||
attribute float particleAlpha;
|
||||
attribute float particleSize;
|
||||
attribute float particleFrame;
|
||||
varying vec3 vParticleColor;
|
||||
varying float vParticleAlpha;
|
||||
varying float vParticleFrame;
|
||||
uniform float viewportHeight;
|
||||
|
||||
void main() {
|
||||
vParticleColor = particleColor;
|
||||
vParticleAlpha = particleAlpha;
|
||||
vParticleFrame = particleFrame;
|
||||
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
gl_PointSize = clamp(
|
||||
particleSize * viewportHeight / max(1.0, -viewPosition.z),
|
||||
1.0,
|
||||
256.0
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
varying vec3 vParticleColor;
|
||||
varying float vParticleAlpha;
|
||||
varying float vParticleFrame;
|
||||
uniform sampler2D sourceTexture;
|
||||
uniform vec2 atlasSize;
|
||||
uniform float alphaKey;
|
||||
|
||||
void main() {
|
||||
float frameCount = max(1.0, atlasSize.x * atlasSize.y);
|
||||
float frame = mod(max(0.0, floor(vParticleFrame)), frameCount);
|
||||
float column = mod(frame, atlasSize.x);
|
||||
float row = floor(frame / atlasSize.x);
|
||||
vec2 atlasUv = (
|
||||
vec2(column, atlasSize.y - row - 1.0) + gl_PointCoord
|
||||
) / atlasSize;
|
||||
vec4 source = texture2D(sourceTexture, atlasUv);
|
||||
float resolvedAlpha = source.a * vParticleAlpha;
|
||||
if ((alphaKey > 0.5 && source.a < 0.5) || resolvedAlpha <= 0.002) discard;
|
||||
gl_FragColor = vec4(source.rgb * vParticleColor, resolvedAlpha);
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
function sourceRandom(seed: number): number {
|
||||
const value = Math.sin(seed * 12.9898 + 78.233) * 43_758.5453;
|
||||
return value - Math.floor(value);
|
||||
}
|
||||
|
||||
function maximumSampleValue(samples: readonly { readonly value: number }[]): number {
|
||||
return samples.reduce((maximum, sample) => Math.max(maximum, sample.value), 0);
|
||||
}
|
||||
|
||||
function graphDurationMs(graph: OriginalCombatEffectGraph): number {
|
||||
return Math.max(1, graph.durationMs, ...graph.animations.map((animation) => animation.durationMs));
|
||||
}
|
||||
|
||||
function graphTailMs(graph: OriginalCombatEffectGraph): number {
|
||||
return graph.particles.reduce((maximum, particle) => (
|
||||
Math.max(maximum, maximumSampleValue(particle.samples.lifespan) * 1_000)
|
||||
), 0);
|
||||
}
|
||||
|
||||
function placementHeight(model: string | undefined, event: CombatPresentationEvent): number {
|
||||
const normalized = model?.toLowerCase() ?? "";
|
||||
if (/(?:_|\/)head(?:\.|_|$)/.test(normalized)) return 1.72;
|
||||
if (/(?:_|\/)chest(?:\.|_|$)/.test(normalized)) return 1.12;
|
||||
if (/(?:_|\/)base(?:\.|_|$)/.test(normalized)) return 0.08;
|
||||
if (event.phase === "cast-start" || /(?:_|\/)hand(?:\.|_|$)/.test(normalized)) return 1.15;
|
||||
return event.delivery === "area" ? 0.08 : 1.05;
|
||||
}
|
||||
|
||||
function liveActorPosition(actorId: string | undefined) {
|
||||
if (!actorId) return null;
|
||||
if (actorId === PLAYER_AURA_ENTITY_ID) return useCombatStore.getState().playerPosition;
|
||||
return getMobPosition(actorId) ?? getPartyRuntimePosition(actorId);
|
||||
}
|
||||
|
||||
function auraDurationMs(event: CombatPresentationEvent): number {
|
||||
const ability = abilityById(event.abilityId);
|
||||
if (!ability) return 6_000;
|
||||
return ability.effects.reduce((maximum, effect) => {
|
||||
if ("durationMs" in effect && typeof effect.durationMs === "number") {
|
||||
return Math.max(maximum, effect.durationMs);
|
||||
}
|
||||
if ("aura" in effect && effect.aura && typeof effect.aura.durationMs === "number") {
|
||||
return Math.max(maximum, effect.aura.durationMs);
|
||||
}
|
||||
return maximum;
|
||||
}, 6_000);
|
||||
}
|
||||
|
||||
function auraStillActive(event: CombatPresentationEvent): boolean {
|
||||
const state = useCombatStore.getState();
|
||||
const targetId = event.targetActorId ?? PLAYER_AURA_ENTITY_ID;
|
||||
const legacyPlayerShield = targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& event.abilityId.includes("power-word-shield")
|
||||
&& state.shield > 0;
|
||||
const eventWithTarget = event.targetActorId === targetId
|
||||
? event
|
||||
: { ...event, targetActorId: targetId };
|
||||
return legacyPlayerShield || state.auras.some((aura) => (
|
||||
originalAuraMatchesPresentationEvent(aura, eventWithTarget)
|
||||
)) || state.effects.some((effect) => (
|
||||
effect.targetId === targetId && effect.sourceAbilityId === event.abilityId
|
||||
));
|
||||
}
|
||||
|
||||
function effectScale(asset: CombatEffectPhaseAsset): number {
|
||||
return typeof asset.scale === "number" && Number.isFinite(asset.scale)
|
||||
? Math.max(0.05, Math.min(5, asset.scale))
|
||||
: 1;
|
||||
}
|
||||
|
||||
async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
|
||||
const existing = graphRequestCache.get(url);
|
||||
if (existing) return existing;
|
||||
// Graph filenames are keyed to the immutable source M2, while the decoded
|
||||
// browser schema can improve between releases. Revalidate the small JSON so
|
||||
// an older cached decoder payload cannot fail the current strict parser.
|
||||
const request = fetch(url, { cache: "no-cache" })
|
||||
.then((response) => response.ok
|
||||
? response.json()
|
||||
: Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`)))
|
||||
.then((value) => (
|
||||
value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh"
|
||||
? parseOriginalCombatMeshGraph(value)
|
||||
: parseOriginalCombatEffectGraph(value)
|
||||
));
|
||||
graphRequestCache.set(url, request);
|
||||
request.catch(() => graphRequestCache.delete(url));
|
||||
return request;
|
||||
}
|
||||
|
||||
async function loadTexture(url: string): Promise<Texture> {
|
||||
const existing = textureRequestCache.get(url);
|
||||
if (existing) return existing;
|
||||
const request = textureLoader.loadAsync(url).then((texture) => {
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.name = `original-combat-effect:${url}`;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
});
|
||||
textureRequestCache.set(url, request);
|
||||
request.catch(() => textureRequestCache.delete(url));
|
||||
return request;
|
||||
}
|
||||
|
||||
async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise<LoadedOriginalGraph> {
|
||||
if (!asset.effectGraphUrl) throw new Error("Original effect phase has no graph URL.");
|
||||
const graph = await loadGraph(asset.effectGraphUrl);
|
||||
const textureIndices = graph.kind === "wow-m2-static-mesh"
|
||||
? [...new Set(graph.layers.map((layer) => layer.textureIndex))]
|
||||
: [...new Set(graph.runtime.readyParticleIndices.map((particleIndex) => (
|
||||
graph.particles[particleIndex]?.textureIndex
|
||||
)).filter((textureIndex): textureIndex is number => textureIndex !== undefined))];
|
||||
const loadedTextures = await Promise.all(textureIndices.map(async (textureIndex) => {
|
||||
const url = graph.textures[textureIndex]?.url;
|
||||
if (!url) throw new Error(`Original effect texture ${textureIndex} has no packaged URL.`);
|
||||
return [textureIndex, await loadTexture(url)] as const;
|
||||
}));
|
||||
return { asset, graph, textures: new Map(loadedTextures) };
|
||||
}
|
||||
|
||||
function materialForParticle(particle: OriginalCombatEffectParticle, texture: Texture): ShaderMaterial {
|
||||
const additive = particle.blend === "no-alpha-add"
|
||||
|| particle.blend === "add"
|
||||
|| particle.blend === "blend-add";
|
||||
const multiply = particle.blend === "mod" || particle.blend === "mod-2x";
|
||||
return new ShaderMaterial({
|
||||
name: `wow-m2-particle-${particle.id}`,
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
uniforms: {
|
||||
sourceTexture: { value: texture },
|
||||
atlasSize: { value: new Vector2(particle.atlas.columns, particle.atlas.rows) },
|
||||
viewportHeight: { value: 720 },
|
||||
alphaKey: { value: particle.blend === "alpha-key" ? 1 : 0 },
|
||||
},
|
||||
blending: additive ? AdditiveBlending : multiply ? MultiplyBlending : NormalBlending,
|
||||
transparent: particle.blend !== "opaque",
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
});
|
||||
}
|
||||
|
||||
const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
graph,
|
||||
particle,
|
||||
texture,
|
||||
budget,
|
||||
active,
|
||||
eventId,
|
||||
emitterIndex,
|
||||
loopTimeline,
|
||||
}: {
|
||||
readonly graph: OriginalCombatEffectGraph;
|
||||
readonly particle: OriginalCombatEffectParticle;
|
||||
readonly texture: Texture;
|
||||
readonly budget: number;
|
||||
readonly active: boolean;
|
||||
readonly eventId: number;
|
||||
readonly emitterIndex: number;
|
||||
readonly loopTimeline: boolean;
|
||||
}) {
|
||||
const elapsedRef = useRef(0);
|
||||
const geometry = useMemo(() => {
|
||||
const next = new BufferGeometry();
|
||||
const position = new BufferAttribute(new Float32Array(budget * 3), 3);
|
||||
const color = new BufferAttribute(new Float32Array(budget * 3), 3);
|
||||
const alpha = new BufferAttribute(new Float32Array(budget), 1);
|
||||
const size = new BufferAttribute(new Float32Array(budget), 1);
|
||||
const frame = new BufferAttribute(new Float32Array(budget), 1);
|
||||
position.setUsage(DynamicDrawUsage);
|
||||
color.setUsage(DynamicDrawUsage);
|
||||
alpha.setUsage(DynamicDrawUsage);
|
||||
size.setUsage(DynamicDrawUsage);
|
||||
frame.setUsage(DynamicDrawUsage);
|
||||
next.setAttribute("position", position);
|
||||
next.setAttribute("particleColor", color);
|
||||
next.setAttribute("particleAlpha", alpha);
|
||||
next.setAttribute("particleSize", size);
|
||||
next.setAttribute("particleFrame", frame);
|
||||
next.setDrawRange(0, budget);
|
||||
return next;
|
||||
}, [budget]);
|
||||
const material = useMemo(() => materialForParticle(particle, texture), [particle, texture]);
|
||||
const durationMs = graphDurationMs(graph);
|
||||
const peakRate = maximumSampleValue(particle.samples.emissionRate);
|
||||
const peakLifespan = maximumSampleValue(particle.samples.lifespan);
|
||||
|
||||
useEffect(() => () => {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
}, [geometry, material]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
if (!active || budget === 0) return;
|
||||
elapsedRef.current += Math.min(0.1, Math.max(0, delta));
|
||||
material.uniforms.viewportHeight.value = state.size.height * state.viewport.dpr;
|
||||
const elapsed = elapsedRef.current;
|
||||
const positions = geometry.getAttribute("position") as BufferAttribute;
|
||||
const colors = geometry.getAttribute("particleColor") as BufferAttribute;
|
||||
const alphas = geometry.getAttribute("particleAlpha") as BufferAttribute;
|
||||
const sizes = geometry.getAttribute("particleSize") as BufferAttribute;
|
||||
const frames = geometry.getAttribute("particleFrame") as BufferAttribute;
|
||||
const cycleLength = Math.max(0.02, peakLifespan);
|
||||
const emitterSeed = eventId * 1_009
|
||||
+ particle.id * 17
|
||||
+ particle.textureIndex * 131
|
||||
+ emitterIndex * 7_919;
|
||||
|
||||
for (let index = 0; index < budget; index += 1) {
|
||||
const slotOffset = peakRate > 0 ? index / peakRate : Number.POSITIVE_INFINITY;
|
||||
const completedCycles = Math.floor((elapsed - slotOffset) / cycleLength);
|
||||
const birthTime = slotOffset + Math.max(0, completedCycles) * cycleLength;
|
||||
const age = elapsed - birthTime;
|
||||
const cycleSeed = emitterSeed + index * 97 + Math.max(0, completedCycles) * 271;
|
||||
const rawTimelineMs = birthTime * 1_000;
|
||||
const timelineMs = loopTimeline ? rawTimelineMs % durationMs : rawTimelineMs;
|
||||
const scalarAt = (track: OriginalCombatEffectAnimatedTrackName) => (
|
||||
interpolateOriginalScalar(
|
||||
particle.samples[track],
|
||||
timelineMs,
|
||||
particle.interpolation[track],
|
||||
) ?? 0
|
||||
);
|
||||
const emissionRate = rawTimelineMs <= durationMs || loopTimeline
|
||||
? Math.max(0, scalarAt("emissionRate"))
|
||||
: 0;
|
||||
const lifespanBase = Math.max(0.02, scalarAt("lifespan"));
|
||||
const lifespan = lifespanBase * Math.max(
|
||||
0.05,
|
||||
1 + (sourceRandom(cycleSeed + 1) * 2 - 1) * particle.lifespanVariation,
|
||||
);
|
||||
const accepted = elapsed >= slotOffset
|
||||
&& age >= 0
|
||||
&& age < lifespan
|
||||
&& scalarAt("enabled") > 0.5
|
||||
&& sourceRandom(cycleSeed + 2) <= Math.min(1, emissionRate / Math.max(peakRate, 0.001));
|
||||
if (!accepted) {
|
||||
alphas.setX(index, 0);
|
||||
sizes.setX(index, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lifeProgress = Math.min(1, Math.max(0, age / lifespan));
|
||||
const speedBase = scalarAt("emissionSpeed");
|
||||
const speedVariation = scalarAt("speedVariation");
|
||||
const speed = speedBase * Math.max(0, 1 + (sourceRandom(cycleSeed + 3) * 2 - 1) * speedVariation);
|
||||
const horizontalRange = particle.samples.horizontalRange.length
|
||||
? scalarAt("horizontalRange")
|
||||
: Math.PI * 2;
|
||||
const verticalRange = scalarAt("verticalRange");
|
||||
const gravity = scalarAt("gravity");
|
||||
const areaLength = scalarAt("emissionAreaLength");
|
||||
const areaWidth = scalarAt("emissionAreaWidth");
|
||||
const zSource = scalarAt("zSource");
|
||||
const azimuth = (sourceRandom(cycleSeed + 4) - 0.5) * horizontalRange;
|
||||
const elevation = (sourceRandom(cycleSeed + 5) - 0.5) * verticalRange;
|
||||
const radial = Math.cos(elevation);
|
||||
const velocityX = Math.sin(azimuth) * radial * speed;
|
||||
const velocityY = Math.cos(azimuth) * radial * speed;
|
||||
const velocityZ = Math.sin(elevation) * speed;
|
||||
let localX = (sourceRandom(cycleSeed + 6) - 0.5) * areaWidth;
|
||||
let localY = (sourceRandom(cycleSeed + 7) - 0.5) * areaLength;
|
||||
let localZ = zSource;
|
||||
if (particle.emitter === "sphere") {
|
||||
const longitude = sourceRandom(cycleSeed + 6) * Math.PI * 2;
|
||||
const sphereZ = sourceRandom(cycleSeed + 7) * 2 - 1;
|
||||
const sphereRadius = Math.sqrt(Math.max(0, 1 - sphereZ * sphereZ));
|
||||
localX = Math.cos(longitude) * sphereRadius * areaWidth;
|
||||
localY = Math.sin(longitude) * sphereRadius * areaLength;
|
||||
localZ += sphereZ * Math.max(areaWidth, areaLength);
|
||||
}
|
||||
// WoW M2 is Z-up; the game scene is Y-up.
|
||||
positions.setXYZ(
|
||||
index,
|
||||
particle.position[0] + localX + velocityX * age,
|
||||
particle.position[2] + localZ + velocityZ * age - gravity * age * age * 0.5,
|
||||
-particle.position[1] - localY - velocityY * age,
|
||||
);
|
||||
const color = interpolateOriginalLifetime(particle.samples.color, lifeProgress) ?? [255, 255, 255];
|
||||
const alpha = interpolateOriginalLifetime(particle.samples.alpha, lifeProgress) ?? 0;
|
||||
const scale = interpolateOriginalLifetime(particle.samples.scale, lifeProgress) ?? [0, 0];
|
||||
const scaleVariation = 1 + (sourceRandom(cycleSeed + 8) * 2 - 1)
|
||||
* (particle.scaleVariation[0] + particle.scaleVariation[1]) * 0.5;
|
||||
colors.setXYZ(index, color[0] / 255, color[1] / 255, color[2] / 255);
|
||||
alphas.setX(index, Math.min(1, Math.max(0, alpha / 32_767)));
|
||||
sizes.setX(index, Math.max(0.002, (scale[0] + scale[1]) * 0.5 * Math.max(0.05, scaleVariation)));
|
||||
const frameSamples = particle.samples.headCell.length
|
||||
? particle.samples.headCell
|
||||
: particle.samples.tailCell;
|
||||
const sourceFrame = interpolateOriginalLifetime(frameSamples, lifeProgress) ?? 0;
|
||||
frames.setX(index, Math.max(0, Math.floor(sourceFrame)));
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
colors.needsUpdate = true;
|
||||
alphas.needsUpdate = true;
|
||||
sizes.needsUpdate = true;
|
||||
frames.needsUpdate = true;
|
||||
});
|
||||
|
||||
return budget > 0 ? (
|
||||
<points geometry={geometry} material={material} frustumCulled={false} />
|
||||
) : null;
|
||||
});
|
||||
|
||||
function meshBlendMode(blendMode: number) {
|
||||
if (blendMode === 3 || blendMode === 4 || blendMode === 7) return AdditiveBlending;
|
||||
if (blendMode === 5 || blendMode === 6) return MultiplyBlending;
|
||||
return NormalBlending;
|
||||
}
|
||||
|
||||
const OriginalStaticMesh = memo(function OriginalStaticMesh({
|
||||
graph,
|
||||
textures,
|
||||
active,
|
||||
}: {
|
||||
readonly graph: OriginalCombatMeshGraph;
|
||||
readonly textures: ReadonlyMap<number, Texture>;
|
||||
readonly active: boolean;
|
||||
}) {
|
||||
const billboardRef = useRef<Group>(null);
|
||||
const geometry = useMemo(() => {
|
||||
const next = new BufferGeometry();
|
||||
const sourcePositions = graph.geometry.positions;
|
||||
const sourceNormals = graph.geometry.normals;
|
||||
const xCenter = Array.from({ length: sourcePositions.length / 3 }, (_, index) => (
|
||||
sourcePositions[index * 3]
|
||||
)).reduce((total, value) => total + value, 0) / (sourcePositions.length / 3);
|
||||
const positions = new Float32Array(sourcePositions.length);
|
||||
const normals = new Float32Array(sourceNormals.length);
|
||||
for (let index = 0; index < sourcePositions.length; index += 3) {
|
||||
// Turn WoW's Z-up source plane into an upright, camera-facing local XY plane.
|
||||
positions[index] = -sourcePositions[index + 1];
|
||||
positions[index + 1] = sourcePositions[index + 2];
|
||||
positions[index + 2] = sourcePositions[index] - xCenter;
|
||||
normals[index] = -sourceNormals[index + 1];
|
||||
normals[index + 1] = sourceNormals[index + 2];
|
||||
normals[index + 2] = sourceNormals[index];
|
||||
}
|
||||
next.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
next.setAttribute("normal", new BufferAttribute(normals, 3));
|
||||
next.setAttribute("uv", new BufferAttribute(new Float32Array(graph.geometry.uvs), 2));
|
||||
next.setIndex([...graph.geometry.indices]);
|
||||
next.clearGroups();
|
||||
for (const [index, layer] of graph.layers.entries()) {
|
||||
next.addGroup(layer.indexStart, layer.indexCount, index);
|
||||
}
|
||||
next.computeBoundingSphere();
|
||||
return next;
|
||||
}, [graph]);
|
||||
const materials = useMemo(() => graph.layers.map((layer) => new MeshBasicMaterial({
|
||||
name: `wow-m2-static-layer-${layer.submeshIndex}`,
|
||||
map: textures.get(layer.textureIndex) ?? null,
|
||||
blending: meshBlendMode(layer.blendMode),
|
||||
transparent: layer.blendMode !== 0,
|
||||
alphaTest: layer.blendMode === 1 ? 0.5 : 0,
|
||||
depthWrite: layer.blendMode === 0,
|
||||
side: DoubleSide,
|
||||
toneMapped: false,
|
||||
})), [graph.layers, textures]);
|
||||
|
||||
useEffect(() => () => {
|
||||
geometry.dispose();
|
||||
for (const material of materials) material.dispose();
|
||||
}, [geometry, materials]);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
if (active && billboardRef.current) billboardRef.current.lookAt(camera.position);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={billboardRef}>
|
||||
<mesh geometry={geometry} material={materials} renderOrder={20} frustumCulled={false} />
|
||||
</group>
|
||||
);
|
||||
});
|
||||
|
||||
function LoadedOriginalCombatEffect({
|
||||
event,
|
||||
quality,
|
||||
active,
|
||||
loaded,
|
||||
onDone,
|
||||
}: OriginalCombatEffectProps & { readonly loaded: readonly LoadedOriginalGraph[] }) {
|
||||
const elapsedRef = useRef(0);
|
||||
const doneRef = useRef(false);
|
||||
const groupRef = useRef<Group>(null);
|
||||
const particleBudgets = useMemo(() => {
|
||||
const emitters = loaded.flatMap((item, loadedIndex) => {
|
||||
const graph = item.graph;
|
||||
return graph.kind === "wow-m2-static-mesh"
|
||||
? []
|
||||
: graph.runtime.readyParticleIndices.map((particleIndex) => ({
|
||||
key: `${loadedIndex}:${particleIndex}`,
|
||||
requested: originalEmitterParticleBudget(graph.particles[particleIndex], quality),
|
||||
}));
|
||||
});
|
||||
const requestedTotal = emitters.reduce((total, emitter) => total + emitter.requested, 0);
|
||||
const limit = ORIGINAL_EFFECT_SLOT_LIMIT[quality];
|
||||
if (requestedTotal <= limit) return new Map(emitters.map((emitter) => [emitter.key, emitter.requested]));
|
||||
const scale = limit / requestedTotal;
|
||||
let remaining = limit;
|
||||
return new Map(emitters.map((emitter) => {
|
||||
const budget = Math.min(
|
||||
remaining,
|
||||
emitter.requested > 0 ? Math.max(1, Math.floor(emitter.requested * scale)) : 0,
|
||||
);
|
||||
remaining -= budget;
|
||||
return [emitter.key, budget];
|
||||
}));
|
||||
}, [loaded, quality]);
|
||||
const durationMs = useMemo(() => {
|
||||
const longestGraph = loaded.reduce((maximum, item) => (
|
||||
Math.max(
|
||||
maximum,
|
||||
item.graph.kind === "wow-m2-static-mesh"
|
||||
? 6_000
|
||||
: graphDurationMs(item.graph) + graphTailMs(item.graph),
|
||||
)
|
||||
), 0);
|
||||
if (event.phase === "cast-start") return Math.max(120, event.durationMs ?? longestGraph);
|
||||
return Math.max(120, Math.min(6_000, longestGraph));
|
||||
}, [event.durationMs, event.phase, loaded]);
|
||||
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 anchor = event.phase === "cast-start" || projectile
|
||||
? event.origin
|
||||
: event.targetPosition ?? event.origin;
|
||||
const loopTimeline = event.phase === "cast-start" || event.phase === "aura-start";
|
||||
|
||||
useFrame((_state, delta) => {
|
||||
if (!active || doneRef.current) return;
|
||||
elapsedRef.current += Math.min(100, Math.max(0, delta * 1_000));
|
||||
if (groupRef.current) {
|
||||
if (projectile && event.targetPosition) {
|
||||
const progress = Math.min(1, elapsedRef.current / projectileDurationMs);
|
||||
groupRef.current.position.set(
|
||||
event.origin[0] + (event.targetPosition[0] - event.origin[0]) * progress,
|
||||
event.origin[1] + (event.targetPosition[1] - event.origin[1]) * progress,
|
||||
event.origin[2] + (event.targetPosition[2] - event.origin[2]) * progress,
|
||||
);
|
||||
} else {
|
||||
const actorId = event.phase === "cast-start" ? event.sourceActorId : event.targetActorId;
|
||||
const live = liveActorPosition(actorId);
|
||||
if (live) groupRef.current.position.set(live[0], live[1], live[2]);
|
||||
}
|
||||
}
|
||||
const terminalDuration = projectile
|
||||
? projectileDurationMs
|
||||
: event.phase === "aura-start"
|
||||
? auraDurationMs(event)
|
||||
: durationMs;
|
||||
if (event.phase === "aura-start"
|
||||
&& elapsedRef.current > 350
|
||||
&& !auraStillActive(event)) {
|
||||
doneRef.current = true;
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
if (elapsedRef.current < terminalDuration) return;
|
||||
doneRef.current = true;
|
||||
onDone();
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={anchor}>
|
||||
{loaded.map((item, loadedIndex) => {
|
||||
const graph = item.graph;
|
||||
return (
|
||||
<group
|
||||
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
|
||||
position={[0, placementHeight(item.asset.model, event), 0]}
|
||||
scale={effectScale(item.asset)}
|
||||
>
|
||||
{graph.kind === "wow-m2-static-mesh" ? (
|
||||
<OriginalStaticMesh graph={graph} textures={item.textures} active={active} />
|
||||
) : graph.runtime.readyParticleIndices.map((particleIndex) => {
|
||||
const particle = graph.particles[particleIndex];
|
||||
const texture = item.textures.get(particle?.textureIndex ?? -1);
|
||||
return particle && texture ? (
|
||||
<OriginalParticleEmitter
|
||||
key={`${particleIndex}:${particle.id}:${particle.textureIndex}`}
|
||||
graph={graph}
|
||||
particle={particle}
|
||||
texture={texture}
|
||||
budget={particleBudgets.get(`${loadedIndex}:${particleIndex}`) ?? 0}
|
||||
active={active}
|
||||
eventId={event.id}
|
||||
emitterIndex={loadedIndex * 4_096 + particleIndex}
|
||||
loopTimeline={loopTimeline}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export const OriginalCombatEffect = memo(function OriginalCombatEffect({
|
||||
event,
|
||||
quality,
|
||||
active,
|
||||
onDone,
|
||||
}: OriginalCombatEffectProps) {
|
||||
const [loaded, setLoaded] = useState<readonly LoadedOriginalGraph[] | null>(null);
|
||||
const onDoneRef = useRef(onDone);
|
||||
const terminalRef = useRef(false);
|
||||
onDoneRef.current = onDone;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
terminalRef.current = false;
|
||||
setLoaded(null);
|
||||
void preloadCombatEffectManifest().then(async () => {
|
||||
const assets = combatOriginalPhaseAssets(event);
|
||||
const settled = await Promise.allSettled(assets.map(loadOriginalGraphAsset));
|
||||
if (cancelled) return;
|
||||
const ready = settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
if (import.meta.env.DEV && assets.length > ready.length) {
|
||||
const failures = settled.flatMap((result) => (
|
||||
result.status === "rejected"
|
||||
? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
|
||||
: []
|
||||
));
|
||||
console.warn(
|
||||
`Original spell effect failed to load for ${event.name} (${event.phase}).`,
|
||||
failures,
|
||||
);
|
||||
}
|
||||
if (ready.length) {
|
||||
setLoaded(ready);
|
||||
return;
|
||||
}
|
||||
if (!terminalRef.current) {
|
||||
terminalRef.current = true;
|
||||
onDoneRef.current();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [event]);
|
||||
|
||||
return loaded ? (
|
||||
<LoadedOriginalCombatEffect
|
||||
event={event}
|
||||
quality={quality}
|
||||
active={active}
|
||||
loaded={loaded}
|
||||
onDone={onDone}
|
||||
/>
|
||||
) : null;
|
||||
}, (previous, next) => (
|
||||
previous.event === next.event
|
||||
&& previous.quality === next.quality
|
||||
&& previous.active === next.active
|
||||
));
|
||||
Reference in new issue
Block a user