import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject, type ReactNode } from "react"; import { useFrame, useThree } from "@react-three/fiber"; import { AdditiveBlending, BufferAttribute, BufferGeometry, DoubleSide, DynamicDrawUsage, Group, Matrix4, MeshBasicMaterial, MultiplyBlending, NormalBlending, ShaderMaterial, SRGBColorSpace, Texture, TextureLoader, Vector2, Vector3, } from "three"; import { combatOriginalPhaseAssets, isPenancePulseVisual, preloadCombatEffectManifest, type CombatEffectPhaseAsset, type CombatPresentationEvent, type CombatPresentationPhase, type CombatVfxQuality, } from "../game/combatPresentation"; import { fetchCombatMediaAsset } from "../game/combatMediaPack"; 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, originalCombatAnchorActorId, originalAuraMatchesPresentationEvent, originalEmitterParticleBudget, selectCombatEffectRender, parseOriginalCombatEffectGraph, type OriginalCombatEffectAnimatedTrackName, type OriginalCombatEffectGraph, type OriginalCombatEffectParticle, } from "./originalCombatEffectRuntime"; import { originalCombatMeshLayerAppearance, originalCombatMeshPlaybackFrame, parseOriginalCombatMeshGraph, type OriginalCombatMeshGraph, } from "./originalCombatMeshRuntime"; import { combatEffectTargetVisualSize, updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry"; import { combatEffectVisualTuning, projectileTravelDurationMs, risingTideImpactPlacement, shouldUseGeneratedOriginalFallback, } from "./combatEffectRuntime"; import { OriginalWowModel } from "./OriginalWowModel"; import { OriginalParticleGeometry } from "./OriginalParticleGeometry"; import { RunewakerParticleEffect } from "./RunewakerParticleEffect"; import { parseRunewakerParticleGraph, runeDurationMs, type RunewakerParticleGraph, } from "./runewakerParticleRuntime"; import { parseOriginalWowModelGraph, updateWowBoneMatrices, wowModelFrame, wowModelDurationMs, wowModelTextureUrls, wowParticleChild, type OriginalWowModelGraph, } from "./originalWowModelRuntime"; type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph | OriginalWowModelGraph | RunewakerParticleGraph; function sourceParticles(graph: OriginalSourceEffectGraph): OriginalCombatEffectGraph | null { return graph.kind === "wow-m2-model" ? graph.particleGraph : graph.kind === "wow-m2-particle-effect" ? graph : null; } interface LoadedOriginalGraph { readonly asset: CombatEffectPhaseAsset; readonly graph: OriginalSourceEffectGraph; readonly textures: ReadonlyMap; readonly sourceTextures: ReadonlyMap; } interface OriginalCombatEffectProps { readonly event: CombatPresentationEvent; readonly alternateOriginalPhase?: CombatPresentationPhase; readonly quality: Exclude; readonly active: boolean; readonly onDone: () => void; } const textureLoader = new TextureLoader(); const graphRequestCache = new Map>(); const textureRequestCache = new Map>(); 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); float inheritedScale = length(modelViewMatrix[0].xyz); gl_Position = projectionMatrix * viewPosition; gl_PointSize = clamp( particleSize * inheritedScale * 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 } `; 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 (normalized.endsWith(".ros") && /(?:_|\/)down(?:\.|_|$)/.test(normalized)) return 0.08; if (/(?:_|\/)head(?:\.|_|$)/.test(normalized)) return 1.72; if (/(?:_|\/)chest(?:\.|_|$)/.test(normalized)) return 1.12; if (/(?:_|\/)base(?:\.|_|$)/.test(normalized)) return 0.08; 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; } function effectPlacementHeight( asset: CombatEffectPhaseAsset, graph: OriginalSourceEffectGraph, event: CombatPresentationEvent, ): number { const fallback = placementHeight(asset.model, event); if (graph.kind !== "wow-m2-static-mesh") return fallback; // The converted mesh remains in WoW's source scale. Lift its lowest Z-up // vertex to the actor's feet after applying the packaged presentation scale. return Math.max(fallback, -graph.bounds.minimum[2] * effectScale(asset)); } async function loadGraph(url: string): Promise { 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 = fetchCombatMediaAsset(url) .then((response) => response.ok ? response.json() : Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`))) .then((value) => ( value?.kind === "runewaker-ros-particle-archive" ? parseRunewakerParticleGraph(value) : value?.kind === "wow-m2-model" ? parseOriginalWowModelGraph(value) : value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh" ? parseOriginalCombatMeshGraph(value) : parseOriginalCombatEffectGraph(value) )); graphRequestCache.set(url, request); request.catch(() => graphRequestCache.delete(url)); return request; } async function loadTexture(url: string): Promise { const existing = textureRequestCache.get(url); if (existing) return existing; const request = fetchCombatMediaAsset(url).then(async (response) => { if (!response.ok) throw new Error(`Original effect texture request failed (${response.status}): ${url}`); const objectUrl = URL.createObjectURL(await response.blob()); try { const texture = await textureLoader.loadAsync(objectUrl); texture.colorSpace = SRGBColorSpace; texture.name = `original-combat-effect:${url}`; texture.needsUpdate = true; return texture; } finally { URL.revokeObjectURL(objectUrl); } }); textureRequestCache.set(url, request); request.catch(() => textureRequestCache.delete(url)); return request; } async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise { if (!asset.effectGraphUrl) throw new Error("Original effect phase has no graph URL."); const graph = await loadGraph(asset.effectGraphUrl); const sourceTextures = new Map(); if (graph.kind === "wow-m2-model") { await Promise.all(wowModelTextureUrls(graph).map(async (url) => sourceTextures.set(url, await loadTexture(url)))); } else if (graph.kind === "runewaker-ros-particle-archive") { await Promise.all(graph.textures.map(async (texture) => ( sourceTextures.set(texture.url, await loadTexture(texture.url)) ))); } const textureIndices = graph.kind === "wow-m2-model" ? graph.textures.flatMap((texture, index) => texture.url ? [index] : []) : graph.kind === "wow-m2-static-mesh" ? [...new Set(graph.layers.map((layer) => layer.textureIndex))] : graph.kind === "runewaker-ros-particle-archive" ? [] : [...new Set(graph.runtime.readyParticleIndices.map((particleIndex) => ( graph.particles[particleIndex]?.textureIndex )).filter((textureIndex): textureIndex is number => textureIndex !== undefined))]; 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), sourceTextures }; } 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, }); } export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({ graph, particle, texture, budget, active, eventId, emitterIndex, loopTimeline, modelGraph, sourceTextures, timelineRef, opacity = 1, }: { readonly graph: OriginalCombatEffectGraph; readonly particle: OriginalCombatEffectParticle; readonly texture: Texture; readonly budget: number; readonly active: boolean; readonly eventId: number; readonly emitterIndex: number; readonly loopTimeline: boolean; readonly modelGraph?: OriginalWowModelGraph; readonly sourceTextures?: ReadonlyMap; readonly timelineRef?: MutableRefObject; readonly opacity?: number; }) { const elapsedRef = useRef(0); const sourcePose = useMemo(() => ({ bones: modelGraph?.bones.map(() => new Matrix4()) ?? [], births: new Float64Array(budget).fill(Number.NaN), birthMatrices: Array.from({ length: modelGraph ? budget : 0 }, () => new Matrix4()), position: new Vector3(), velocity: new Vector3(), origin: new Vector3(), }), [modelGraph, budget]); 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.setAttribute("particleAge", new BufferAttribute(new Float32Array(budget), 1)); 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; if (timelineRef) elapsedRef.current = timelineRef.current; else 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 ages = geometry.getAttribute("particleAge") 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; ages.setX(index, Math.max(0, age)); 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 && (particle.samples.enabled.length === 0 || 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. sourcePose.position.set(particle.position[0] + localX, particle.position[1] + localY, particle.position[2] + localZ); sourcePose.velocity.set(velocityX * age, velocityY * age, velocityZ * age); if (modelGraph && sourcePose.bones[particle.parentBone]) { if (sourcePose.births[index] !== rawTimelineMs) { updateWowBoneMatrices(modelGraph, wowModelFrame(modelGraph, rawTimelineMs, loopTimeline), sourcePose.bones); sourcePose.birthMatrices[index].copy(sourcePose.bones[particle.parentBone]); sourcePose.births[index] = rawTimelineMs; } const bone = sourcePose.birthMatrices[index]; sourcePose.position.applyMatrix4(bone); sourcePose.origin.set(0, 0, 0).applyMatrix4(bone); sourcePose.velocity.applyMatrix4(bone).sub(sourcePose.origin); } sourcePose.position.add(sourcePose.velocity); positions.setXYZ(index, sourcePose.position.x, sourcePose.position.z - gravity * age * age * 0.5, -sourcePose.position.y); 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)) * opacity); 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; }); const geometryChild = wowParticleChild(modelGraph, particle.geometryModel); const recursiveChild = wowParticleChild(modelGraph, particle.recursionModel); return budget > 0 ? ( {geometryChild && sourceTextures ? : !recursiveChild ? : null} {recursiveChild && sourceTextures && } ) : null; }); const OriginalRecursiveParticles = memo(function OriginalRecursiveParticles({ graph, source, textures, budget, active, eventId }: { readonly graph: OriginalWowModelGraph; readonly source: BufferGeometry; readonly textures: ReadonlyMap; readonly budget: number; readonly active: boolean; readonly eventId: number; }) { // Recursive effects share the parent's particle pool. Bound the secondary // emitters so large dispels cannot multiply the entire effect budget. const slots = useMemo(() => Array.from({ length: Math.min(8, budget) }, () => ({ group: new Group(), age: { current: 0 }, })), [budget]); useFrame(() => { if (!active) return; const positions = source.getAttribute("position"), alpha = source.getAttribute("particleAlpha"); const sizes = source.getAttribute("particleSize"), ages = source.getAttribute("particleAge"); slots.forEach((slot, index) => { slot.group.position.fromBufferAttribute(positions, index); slot.group.visible = alpha.getX(index) > 0; slot.group.scale.setScalar(sizes.getX(index)); slot.age.current = ages.getX(index); }); }); const particles = graph.particleGraph; return {slots.map((slot, slotIndex) => {particles?.runtime.readyParticleIndices.map((index) => { const particle = particles.particles[index]; const texture = textures.get(graph.textures[particle.textureIndex].url!); return texture && ; })} , )}; }); 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, playbackRef, }: { readonly graph: OriginalCombatMeshGraph; readonly textures: ReadonlyMap; readonly active: boolean; readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; }) { const camera = useThree((state) => state.camera); const sourcePlaneRef = useRef(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 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(() => { const initialFrame = originalCombatMeshPlaybackFrame(graph, 0); return graph.layers.map((layer) => { const appearance = originalCombatMeshLayerAppearance(graph, layer, initialFrame); const material = new MeshBasicMaterial({ name: `wow-m2-static-layer-${layer.submeshIndex}`, map: textures.get(layer.textureIndex) ?? null, blending: meshBlendMode(layer.blendMode), transparent: layer.blendMode !== 0, opacity: appearance.opacity, alphaTest: layer.blendMode === 1 ? 0.5 : 0, depthWrite: layer.blendMode === 0, side: DoubleSide, toneMapped: false, }); material.color.setRGB(...appearance.color); return material; }); }, [graph, textures]); useEffect(() => () => { geometry.dispose(); for (const material of materials) material.dispose(); }, [geometry, materials]); useEffect(() => { // WoW authored this mesh as a billboard. Resolve that orientation once at // spawn so it stays readable without rotating whenever the camera moves. sourcePlaneRef.current?.lookAt(camera.position); }, [camera]); useFrame(() => { if (!active) return; const playback = originalCombatMeshPlaybackFrame( graph, playbackRef.current.elapsedMs, playbackRef.current.decaying, ); for (const [index, layer] of graph.layers.entries()) { const appearance = originalCombatMeshLayerAppearance(graph, layer, playback); materials[index].color.setRGB(...appearance.color); materials[index].opacity = appearance.opacity; } }); return ( ); }); function LoadedOriginalCombatEffect({ event, quality, active, loaded, onDone, }: OriginalCombatEffectProps & { readonly loaded: readonly LoadedOriginalGraph[] }) { const elapsedRef = useRef(0); const doneRef = useRef(false); const groupRef = useRef(null); const liveAnchorPosition = useMemo(() => new Vector3(), []); const auraDecayStartedAtRef = useRef(null); const staticMeshPlaybackRef = useRef({ elapsedMs: 0, decaying: false }); const particleBudgets = useMemo(() => { const emitters = loaded.flatMap((item, loadedIndex) => { const graph = sourceParticles(item.graph); return !graph ? [] : 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-model" ? wowModelDurationMs(item.graph) + (item.graph.particleGraph ? graphTailMs(item.graph.particleGraph) : 0) : item.graph.kind === "wow-m2-static-mesh" ? 6_000 : item.graph.kind === "runewaker-ros-particle-archive" ? runeDurationMs(item.graph, event.phase) : graphDurationMs(item.graph) + graphTailMs(item.graph), ) ), 0); 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 staticMeshDecayDurationMs = useMemo(() => loaded.reduce((maximum, item) => ( item.graph.kind === "wow-m2-static-mesh" || item.graph.kind === "wow-m2-model" ? Math.max( maximum, item.graph.animations.find((animation) => animation.id === 159)?.durationMs ?? 0, ) : maximum ), 0), [loaded]); const projectile = (event.phase === "release" || (event.phase === "tick" && isPenancePulseVisual(event))) && event.delivery === "projectile" && event.targetPosition !== undefined; const projectileDurationMs = projectile ? projectileTravelDurationMs(event) : 0; const anchor = event.phase === "cast-start" || projectile ? event.origin : event.targetPosition ?? event.origin; const loopTimeline = event.phase === "cast-start" || event.phase === "aura-start"; const tuning = combatEffectVisualTuning(event); useFrame((_state, delta) => { if (!active || doneRef.current) return; elapsedRef.current += Math.min(100, Math.max(0, delta * 1_000)); if (auraDecayStartedAtRef.current === null) { staticMeshPlaybackRef.current.elapsedMs = elapsedRef.current; staticMeshPlaybackRef.current.decaying = false; } else { staticMeshPlaybackRef.current.elapsedMs = Math.max( 0, elapsedRef.current - auraDecayStartedAtRef.current, ); staticMeshPlaybackRef.current.decaying = true; } 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 = originalCombatAnchorActorId(event); if (!updateCombatEffectAnchorPosition(actorId, groupRef.current, liveAnchorPosition)) { 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 (auraDecayStartedAtRef.current !== null) { if (staticMeshPlaybackRef.current.elapsedMs < staticMeshDecayDurationMs) return; doneRef.current = true; onDone(); return; } if (event.phase === "aura-start" && elapsedRef.current > 350 && !auraStillActive(event)) { if (staticMeshDecayDurationMs > 0) { auraDecayStartedAtRef.current = elapsedRef.current; staticMeshPlaybackRef.current.elapsedMs = 0; staticMeshPlaybackRef.current.decaying = true; return; } doneRef.current = true; onDone(); return; } if (elapsedRef.current < terminalDuration) return; doneRef.current = true; onDone(); }); return ( {loaded.map((item, loadedIndex) => { const graph = item.graph; 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) * tuning.scale; return ( {graph.kind === "wow-m2-model" && } {graph.kind === "runewaker-ros-particle-archive" ? ( ) : graph.kind === "wow-m2-static-mesh" ? ( ) : sourceParticles(graph)?.runtime.readyParticleIndices.map((particleIndex) => { const particles = sourceParticles(graph)!; const particle = particles.particles[particleIndex]; const texture = item.textures.get(particle?.textureIndex ?? -1); return particle && texture ? ( ) : null; })} ); })} ); } export const OriginalCombatEffect = memo(function OriginalCombatEffect({ event, alternateOriginalPhase, quality, active, onDone, fallback, }: OriginalCombatEffectProps & { readonly fallback: ReactNode }) { const [loaded, setLoaded] = useState(null); const [renderEvent, setRenderEvent] = useState(event); const completedMissingOriginalRef = useRef(false); useEffect(() => { let cancelled = false; setLoaded(null); setRenderEvent(event); completedMissingOriginalRef.current = false; void preloadCombatEffectManifest().then(async () => { const loadEvent = async (candidate: CombatPresentationEvent) => { const assets = combatOriginalPhaseAssets(candidate); const settled = await Promise.allSettled(assets.map(loadOriginalGraphAsset)); return { event: candidate, assets, settled, ready: settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []), }; }; const primary = await loadEvent(event); const attempts = [primary]; let selected = primary; if (!cancelled && selected.ready.length === 0 && alternateOriginalPhase) { const alternate = await loadEvent({ ...event, id: -event.id, phase: alternateOriginalPhase, }); attempts.push(alternate); if (alternate.ready.length > 0) selected = alternate; } if (cancelled) return; if (import.meta.env.DEV) { for (const attempt of attempts) { if (attempt.assets.length <= attempt.ready.length) continue; const failures = attempt.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 ${attempt.event.name} (${attempt.event.phase}).`, failures, ); } } // Keep any usable original layers; only substitute when none can render. setRenderEvent(selected.event); setLoaded(selected.ready); }).catch(() => { if (!cancelled) { setRenderEvent(event); setLoaded([]); } }); return () => { cancelled = true; }; }, [alternateOriginalPhase, event]); const selection = loaded === null ? null : selectCombatEffectRender("original", loaded.map((item) => item.asset)); const hasOriginalInstantKit = event.phase === "release" && event.instantCast === true && (["cast-start", "impact", "aura-start"] as const).some((phase) => ( combatOriginalPhaseAssets({ ...event, phase }).length > 0 )); const resolvedFallback = shouldUseGeneratedOriginalFallback(event, hasOriginalInstantKit) ? fallback : null; useEffect(() => { if ( loaded === null || selection?.kind === "original" || resolvedFallback !== null || completedMissingOriginalRef.current ) return; completedMissingOriginalRef.current = true; onDone(); }, [loaded, onDone, resolvedFallback, selection?.kind]); if (loaded === null || selection === null) return null; return selection.kind === "original" ? ( ) : resolvedFallback; }, (previous, next) => ( previous.event === next.event && previous.alternateOriginalPhase === next.alternateOriginalPhase && previous.quality === next.quality && previous.active === next.active ));