Release Healer Man 0.1.30
This commit is contained in:
1 parent
544c1bee91
commit
7d7fd567f0
845 files changed
+2040
-87
No files matched your search
@@ -29,8 +29,11 @@ import {
|
||||
} from "../game/combatAudio";
|
||||
import {
|
||||
advanceCombatEffectFrame,
|
||||
addOriginalInstantCastEffect,
|
||||
capCombatEffects,
|
||||
updateCombatEffectQueue,
|
||||
originalInstantCastEvent,
|
||||
originalInstantTerminalEvent,
|
||||
type ActiveCombatEffect,
|
||||
} from "./combatEffectRuntime";
|
||||
import { OriginalCombatEffect } from "./OriginalCombatEffect";
|
||||
@@ -278,8 +281,11 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
qualityRef.current = safeGraphics && settings.vfxQuality === "high" ? "low" : settings.vfxQuality;
|
||||
configureCombatAudio(settings, masterVolume);
|
||||
if (combatMediaPreloadEnabled(settings, masterVolume)) void preloadCombatEffectManifest();
|
||||
setEffects((current) => capCombatEffects(current.filter(({ event }) => (
|
||||
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
|
||||
const mode = effectiveCombatVfxMode(settings);
|
||||
setEffects((current) => capCombatEffects(current.filter(({ event, originalOnly }) => (
|
||||
qualityRef.current !== "off"
|
||||
&& settings.vfxEnabled[event.actorGroup]
|
||||
&& (!originalOnly || mode === "original")
|
||||
)), qualityRef.current));
|
||||
for (const group of ["player", "party", "enemy"] as const) {
|
||||
if (!settings.audioEnabled[group] || settings.audioVolume[group] <= 0) stopCombatAudioGroup(group);
|
||||
@@ -314,7 +320,11 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
|| quality === "off"
|
||||
|| !presentationSettings.vfxEnabled[event.actorGroup]
|
||||
) return withoutCast;
|
||||
return updateCombatEffectQueue(withoutCast, event, quality);
|
||||
const withInstantSourceCast = effectiveCombatVfxMode(presentationSettings) === "original"
|
||||
&& originalInstantCastEvent(event)
|
||||
? addOriginalInstantCastEffect(withoutCast, event, quality)
|
||||
: withoutCast;
|
||||
return updateCombatEffectQueue(withInstantSourceCast, event, quality);
|
||||
});
|
||||
}), []);
|
||||
|
||||
@@ -333,6 +343,7 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
return (
|
||||
<group name="combat-effects">
|
||||
{effects.map((effect) => (
|
||||
effect.originalOnly && vfxMode !== "original" ? null :
|
||||
vfxMode === "original" ? (
|
||||
<OriginalCombatEffect
|
||||
key={`original:${effect.key}`}
|
||||
@@ -340,7 +351,10 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
quality={effectiveQuality}
|
||||
active={active}
|
||||
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
|
||||
fallback={(
|
||||
alternateOriginalPhase={effect.originalOnly
|
||||
? undefined
|
||||
: originalInstantTerminalEvent(effect.event)?.phase}
|
||||
fallback={effect.originalOnly ? null : (
|
||||
<CombatEffectPrimitive
|
||||
event={effect.event}
|
||||
quality={effectiveQuality}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DoubleSide,
|
||||
DynamicDrawUsage,
|
||||
Group,
|
||||
Matrix4,
|
||||
MeshBasicMaterial,
|
||||
MultiplyBlending,
|
||||
NormalBlending,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
preloadCombatEffectManifest,
|
||||
type CombatEffectPhaseAsset,
|
||||
type CombatPresentationEvent,
|
||||
type CombatPresentationPhase,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import { fetchCombatMediaAsset } from "../game/combatMediaPack";
|
||||
@@ -48,17 +50,30 @@ import {
|
||||
type OriginalCombatMeshGraph,
|
||||
} from "./originalCombatMeshRuntime";
|
||||
import { updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
|
||||
import { OriginalWowModel } from "./OriginalWowModel";
|
||||
import { OriginalParticleGeometry } from "./OriginalParticleGeometry";
|
||||
import {
|
||||
parseOriginalWowModelGraph, updateWowBoneMatrices, wowModelFrame, wowModelDurationMs, wowModelTextureUrls, wowParticleChild,
|
||||
type OriginalWowModelGraph,
|
||||
} from "./originalWowModelRuntime";
|
||||
|
||||
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph;
|
||||
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph | OriginalWowModelGraph;
|
||||
|
||||
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<number, Texture>;
|
||||
readonly sourceTextures: ReadonlyMap<string, Texture>;
|
||||
}
|
||||
|
||||
interface OriginalCombatEffectProps {
|
||||
readonly event: CombatPresentationEvent;
|
||||
readonly alternateOriginalPhase?: CombatPresentationPhase;
|
||||
readonly quality: Exclude<CombatVfxQuality, "off">;
|
||||
readonly active: boolean;
|
||||
readonly onDone: () => void;
|
||||
@@ -210,7 +225,8 @@ async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
|
||||
? response.json()
|
||||
: Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`)))
|
||||
.then((value) => (
|
||||
value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh"
|
||||
value?.kind === "wow-m2-model" ? parseOriginalWowModelGraph(value)
|
||||
: value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh"
|
||||
? parseOriginalCombatMeshGraph(value)
|
||||
: parseOriginalCombatEffectGraph(value)
|
||||
));
|
||||
@@ -243,7 +259,13 @@ async function loadTexture(url: string): Promise<Texture> {
|
||||
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"
|
||||
const sourceTextures = new Map<string, Texture>();
|
||||
if (graph.kind === "wow-m2-model") {
|
||||
await Promise.all(wowModelTextureUrls(graph).map(async (url) => sourceTextures.set(url, await loadTexture(url))));
|
||||
}
|
||||
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))]
|
||||
: [...new Set(graph.runtime.readyParticleIndices.map((particleIndex) => (
|
||||
graph.particles[particleIndex]?.textureIndex
|
||||
@@ -253,7 +275,7 @@ async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise<Lo
|
||||
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) };
|
||||
return { asset, graph, textures: new Map(loadedTextures), sourceTextures };
|
||||
}
|
||||
|
||||
function materialForParticle(particle: OriginalCombatEffectParticle, texture: Texture): ShaderMaterial {
|
||||
@@ -278,7 +300,7 @@ function materialForParticle(particle: OriginalCombatEffectParticle, texture: Te
|
||||
});
|
||||
}
|
||||
|
||||
const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
graph,
|
||||
particle,
|
||||
texture,
|
||||
@@ -287,6 +309,9 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
eventId,
|
||||
emitterIndex,
|
||||
loopTimeline,
|
||||
modelGraph,
|
||||
sourceTextures,
|
||||
timelineRef,
|
||||
}: {
|
||||
readonly graph: OriginalCombatEffectGraph;
|
||||
readonly particle: OriginalCombatEffectParticle;
|
||||
@@ -296,8 +321,17 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
readonly eventId: number;
|
||||
readonly emitterIndex: number;
|
||||
readonly loopTimeline: boolean;
|
||||
readonly modelGraph?: OriginalWowModelGraph;
|
||||
readonly sourceTextures?: ReadonlyMap<string, Texture>;
|
||||
readonly timelineRef?: MutableRefObject<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);
|
||||
@@ -315,6 +349,7 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
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]);
|
||||
@@ -330,7 +365,8 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
|
||||
useFrame((state, delta) => {
|
||||
if (!active || budget === 0) return;
|
||||
elapsedRef.current += Math.min(0.1, Math.max(0, delta));
|
||||
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;
|
||||
@@ -338,6 +374,7 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
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
|
||||
@@ -349,6 +386,7 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
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;
|
||||
@@ -370,7 +408,7 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
const accepted = elapsed >= slotOffset
|
||||
&& age >= 0
|
||||
&& age < lifespan
|
||||
&& scalarAt("enabled") > 0.5
|
||||
&& (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);
|
||||
@@ -408,12 +446,22 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
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,
|
||||
);
|
||||
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];
|
||||
@@ -435,11 +483,52 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
|
||||
frames.needsUpdate = true;
|
||||
});
|
||||
|
||||
const geometryChild = wowParticleChild(modelGraph, particle.geometryModel);
|
||||
const recursiveChild = wowParticleChild(modelGraph, particle.recursionModel);
|
||||
return budget > 0 ? (
|
||||
<points geometry={geometry} material={material} frustumCulled={false} />
|
||||
<group>
|
||||
{geometryChild && sourceTextures ? <OriginalParticleGeometry graph={geometryChild} source={geometry}
|
||||
textures={sourceTextures} budget={budget} active={active} /> : !recursiveChild
|
||||
? <points geometry={geometry} material={material} frustumCulled={false} /> : null}
|
||||
{recursiveChild && sourceTextures && <OriginalRecursiveParticles graph={recursiveChild} source={geometry}
|
||||
textures={sourceTextures} budget={budget} active={active} eventId={eventId} />}
|
||||
</group>
|
||||
) : null;
|
||||
});
|
||||
|
||||
const OriginalRecursiveParticles = memo(function OriginalRecursiveParticles({ graph, source, textures, budget, active, eventId }: {
|
||||
readonly graph: OriginalWowModelGraph; readonly source: BufferGeometry; readonly textures: ReadonlyMap<string, Texture>;
|
||||
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 <group name="original-wow-recursive-particles">{slots.map((slot, slotIndex) =>
|
||||
<primitive key={slotIndex} object={slot.group}>
|
||||
{particles?.runtime.readyParticleIndices.map((index) => {
|
||||
const particle = particles.particles[index];
|
||||
const texture = textures.get(graph.textures[particle.textureIndex].url!);
|
||||
return texture && <OriginalParticleEmitter key={index} graph={particles} modelGraph={graph} particle={particle}
|
||||
texture={texture} budget={Math.min(16, originalEmitterParticleBudget(particle, "high"))} active={active}
|
||||
eventId={eventId} emitterIndex={slotIndex * 32 + index} loopTimeline timelineRef={slot.age} sourceTextures={textures} />;
|
||||
})}
|
||||
</primitive>,
|
||||
)}</group>;
|
||||
});
|
||||
|
||||
function meshBlendMode(blendMode: number) {
|
||||
if (blendMode === 3 || blendMode === 4 || blendMode === 7) return AdditiveBlending;
|
||||
if (blendMode === 5 || blendMode === 6) return MultiplyBlending;
|
||||
@@ -569,8 +658,8 @@ function LoadedOriginalCombatEffect({
|
||||
const staticMeshPlaybackRef = useRef({ elapsedMs: 0, decaying: false });
|
||||
const particleBudgets = useMemo(() => {
|
||||
const emitters = loaded.flatMap((item, loadedIndex) => {
|
||||
const graph = item.graph;
|
||||
return graph.kind === "wow-m2-static-mesh"
|
||||
const graph = sourceParticles(item.graph);
|
||||
return !graph
|
||||
? []
|
||||
: graph.runtime.readyParticleIndices.map((particleIndex) => ({
|
||||
key: `${loadedIndex}:${particleIndex}`,
|
||||
@@ -595,7 +684,9 @@ function LoadedOriginalCombatEffect({
|
||||
const longestGraph = loaded.reduce((maximum, item) => (
|
||||
Math.max(
|
||||
maximum,
|
||||
item.graph.kind === "wow-m2-static-mesh"
|
||||
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
|
||||
: graphDurationMs(item.graph) + graphTailMs(item.graph),
|
||||
)
|
||||
@@ -604,7 +695,7 @@ function LoadedOriginalCombatEffect({
|
||||
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-static-mesh" || item.graph.kind === "wow-m2-model"
|
||||
? Math.max(
|
||||
maximum,
|
||||
item.graph.animations.find((animation) => animation.id === 159)?.durationMs ?? 0,
|
||||
@@ -695,6 +786,8 @@ function LoadedOriginalCombatEffect({
|
||||
position={[0, effectPlacementHeight(item.asset, graph, event), 0]}
|
||||
scale={scale}
|
||||
>
|
||||
{graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures}
|
||||
active={active} playbackRef={staticMeshPlaybackRef} loop={loopTimeline} />}
|
||||
{graph.kind === "wow-m2-static-mesh" ? (
|
||||
<OriginalStaticMesh
|
||||
graph={graph}
|
||||
@@ -702,19 +795,22 @@ function LoadedOriginalCombatEffect({
|
||||
active={active}
|
||||
playbackRef={staticMeshPlaybackRef}
|
||||
/>
|
||||
) : graph.runtime.readyParticleIndices.map((particleIndex) => {
|
||||
const particle = graph.particles[particleIndex];
|
||||
) : 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 ? (
|
||||
<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}
|
||||
graph={particles}
|
||||
modelGraph={graph.kind === "wow-m2-model" ? graph : undefined}
|
||||
sourceTextures={item.sourceTextures}
|
||||
particle={particle}
|
||||
texture={texture}
|
||||
budget={particleBudgets.get(`${loadedIndex}:${particleIndex}`) ?? 0}
|
||||
active={active}
|
||||
eventId={event.id}
|
||||
emitterIndex={loadedIndex * 4_096 + particleIndex}
|
||||
loopTimeline={loopTimeline}
|
||||
/>
|
||||
) : null;
|
||||
@@ -728,47 +824,91 @@ function LoadedOriginalCombatEffect({
|
||||
|
||||
export const OriginalCombatEffect = memo(function OriginalCombatEffect({
|
||||
event,
|
||||
alternateOriginalPhase,
|
||||
quality,
|
||||
active,
|
||||
onDone,
|
||||
fallback,
|
||||
}: OriginalCombatEffectProps & { readonly fallback: ReactNode }) {
|
||||
const [loaded, setLoaded] = useState<readonly LoadedOriginalGraph[] | null>(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 assets = combatOriginalPhaseAssets(event);
|
||||
const settled = await Promise.allSettled(assets.map(loadOriginalGraphAsset));
|
||||
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;
|
||||
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 (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.
|
||||
setLoaded(ready);
|
||||
setRenderEvent(selected.event);
|
||||
setLoaded(selected.ready);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setLoaded([]);
|
||||
if (!cancelled) {
|
||||
setRenderEvent(event);
|
||||
setLoaded([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [event]);
|
||||
}, [alternateOriginalPhase, event]);
|
||||
|
||||
if (loaded === null) return null;
|
||||
const selection = selectCombatEffectRender("original", loaded.map((item) => item.asset));
|
||||
const selection = loaded === null
|
||||
? null
|
||||
: selectCombatEffectRender("original", loaded.map((item) => item.asset));
|
||||
useEffect(() => {
|
||||
if (
|
||||
loaded === null
|
||||
|| selection?.kind === "original"
|
||||
|| fallback !== null
|
||||
|| completedMissingOriginalRef.current
|
||||
) return;
|
||||
completedMissingOriginalRef.current = true;
|
||||
onDone();
|
||||
}, [fallback, loaded, onDone, selection?.kind]);
|
||||
|
||||
if (loaded === null || selection === null) return null;
|
||||
return selection.kind === "original" ? (
|
||||
<LoadedOriginalCombatEffect
|
||||
event={event}
|
||||
event={renderEvent}
|
||||
quality={quality}
|
||||
active={active}
|
||||
loaded={loaded}
|
||||
@@ -777,6 +917,7 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
|
||||
) : fallback;
|
||||
}, (previous, next) => (
|
||||
previous.event === next.event
|
||||
&& previous.alternateOriginalPhase === next.alternateOriginalPhase
|
||||
&& previous.quality === next.quality
|
||||
&& previous.active === next.active
|
||||
));
|
||||
@@ -0,0 +1,79 @@
|
||||
import { memo, useEffect, useMemo } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
AdditiveBlending, BufferAttribute, BufferGeometry, DoubleSide, DynamicDrawUsage,
|
||||
InstancedBufferAttribute, InstancedMesh, Matrix4, MeshBasicMaterial, NormalBlending, Quaternion, Vector3,
|
||||
} from "three";
|
||||
import type { Texture } from "three";
|
||||
import { updateWowBoneMatrices, wowModelFrame, type OriginalWowModelGraph } from "./originalWowModelRuntime";
|
||||
|
||||
/** Pool the original model geometry for particle emitters that spawn M2s. */
|
||||
export const OriginalParticleGeometry = memo(function OriginalParticleGeometry({ graph, source, budget, textures, active }: {
|
||||
readonly graph: OriginalWowModelGraph; readonly source: BufferGeometry; readonly budget: number;
|
||||
readonly textures: ReadonlyMap<string, Texture>; readonly active: boolean;
|
||||
}) {
|
||||
const pool = useMemo(() => {
|
||||
const sourceMesh = graph.mesh;
|
||||
if (!sourceMesh) return [];
|
||||
return sourceMesh.layers.map((layer) => {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(new Float32Array(sourceMesh.positions), 3));
|
||||
geometry.setAttribute("uv", new BufferAttribute(new Float32Array(sourceMesh.uvs), 2));
|
||||
geometry.setAttribute("particleOpacity", new InstancedBufferAttribute(new Float32Array(budget), 1));
|
||||
geometry.setIndex(sourceMesh.indices.slice(layer.indexStart, layer.indexStart + layer.indexCount));
|
||||
const materialSource = graph.materials[layer.materialIndex];
|
||||
const material = new MeshBasicMaterial({
|
||||
map: textures.get(graph.textures[layer.textureIndices[0]].url!), transparent: true, depthWrite: false,
|
||||
side: DoubleSide, blending: [3, 4, 7].includes(materialSource.blendMode) ? AdditiveBlending : NormalBlending,
|
||||
toneMapped: false,
|
||||
});
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.vertexShader = shader.vertexShader.replace("#include <common>",
|
||||
"#include <common>\nattribute float particleOpacity;\nvarying float vSourceParticleOpacity;");
|
||||
shader.vertexShader = shader.vertexShader.replace("#include <begin_vertex>",
|
||||
"#include <begin_vertex>\nvSourceParticleOpacity = particleOpacity;");
|
||||
shader.fragmentShader = shader.fragmentShader.replace("#include <common>",
|
||||
"#include <common>\nvarying float vSourceParticleOpacity;");
|
||||
shader.fragmentShader = shader.fragmentShader.replace("#include <alphamap_fragment>",
|
||||
"#include <alphamap_fragment>\ndiffuseColor.a *= vSourceParticleOpacity;");
|
||||
};
|
||||
material.customProgramCacheKey = () => "wow-original-geometry-particle";
|
||||
const mesh = new InstancedMesh(geometry, material, budget);
|
||||
mesh.instanceMatrix.setUsage(DynamicDrawUsage); mesh.frustumCulled = false;
|
||||
return mesh;
|
||||
});
|
||||
}, [graph, budget, textures]);
|
||||
const scratch = useMemo(() => ({
|
||||
matrix: new Matrix4(), position: new Vector3(), scale: new Vector3(),
|
||||
rotation: new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), -Math.PI / 2),
|
||||
bones: graph.bones.map(() => new Matrix4()),
|
||||
}), [graph]);
|
||||
useEffect(() => () => pool.forEach((mesh) => {
|
||||
mesh.geometry.dispose(); (mesh.material as MeshBasicMaterial).dispose(); mesh.dispose();
|
||||
}), [pool]);
|
||||
useFrame(() => {
|
||||
if (!active) return;
|
||||
const positions = source.getAttribute("position"), sizes = source.getAttribute("particleSize");
|
||||
const alpha = source.getAttribute("particleAlpha"), age = source.getAttribute("particleAge");
|
||||
const color = source.getAttribute("particleColor");
|
||||
pool.forEach((mesh) => {
|
||||
const opacity = mesh.geometry.getAttribute("particleOpacity") as InstancedBufferAttribute;
|
||||
for (let index = 0; index < budget; index++) {
|
||||
scratch.position.fromBufferAttribute(positions, index);
|
||||
scratch.scale.setScalar(alpha.getX(index) > 0 ? sizes.getX(index) : 0);
|
||||
scratch.matrix.compose(scratch.position, scratch.rotation, scratch.scale);
|
||||
updateWowBoneMatrices(graph, wowModelFrame(graph, age.getX(index) * 1000), scratch.bones);
|
||||
if (scratch.bones[0]) scratch.matrix.multiply(scratch.bones[0]);
|
||||
mesh.setMatrixAt(index, scratch.matrix);
|
||||
mesh.setColorAt(index, (mesh.material as MeshBasicMaterial).color.setRGB(color.getX(index), color.getY(index), color.getZ(index)));
|
||||
opacity.setX(index, alpha.getX(index));
|
||||
}
|
||||
// Instance colors already carry the lifetime tint; do not tint it twice.
|
||||
(mesh.material as MeshBasicMaterial).color.setRGB(1, 1, 1);
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||
opacity.needsUpdate = true;
|
||||
});
|
||||
});
|
||||
return <group name="original-wow-geometry-particles">{pool.map((mesh, index) => <primitive key={index} object={mesh} />)}</group>;
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { memo, useEffect, useMemo, useRef, type MutableRefObject } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
AdditiveBlending, BufferAttribute, BufferGeometry, ClampToEdgeWrapping, DoubleSide,
|
||||
Group, Matrix4, MeshBasicMaterial, MultiplyBlending, NormalBlending, Quaternion,
|
||||
RepeatWrapping, Texture, Vector3,
|
||||
} from "three";
|
||||
import { sampleOriginalCombatMeshTrack } from "./originalCombatMeshRuntime";
|
||||
import { updateWowBoneMatrices, wowModelFrame, type OriginalWowModelGraph, type WowRibbon } from "./originalWowModelRuntime";
|
||||
|
||||
const blend = (mode: number) => [3, 4, 7].includes(mode) ? AdditiveBlending
|
||||
: [5, 6].includes(mode) ? MultiplyBlending : NormalBlending;
|
||||
|
||||
export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures, active, playbackRef, loop }: {
|
||||
readonly graph: OriginalWowModelGraph;
|
||||
readonly textures: ReadonlyMap<number, Texture>;
|
||||
readonly active: boolean;
|
||||
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
|
||||
readonly loop: boolean;
|
||||
}) {
|
||||
const root = useRef<Group>(null);
|
||||
const scratch = useMemo(() => ({
|
||||
bones: graph.bones.map(() => new Matrix4()), position: new Vector3(), vertex: new Vector3(),
|
||||
normal: new Vector3(), normalSum: new Vector3(), camera: new Quaternion(), world: new Quaternion(),
|
||||
billboardAxis: new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), -Math.PI / 2),
|
||||
}), [graph]);
|
||||
const geometry = useMemo(() => {
|
||||
if (!graph.mesh) return null;
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(new Float32Array(graph.mesh.positions), 3));
|
||||
geometry.setAttribute("normal", new BufferAttribute(new Float32Array(graph.mesh.normals), 3));
|
||||
geometry.setAttribute("uv", new BufferAttribute(new Float32Array(graph.mesh.uvs), 2));
|
||||
geometry.setAttribute("uv1", new BufferAttribute(new Float32Array(graph.mesh.uvs2), 2));
|
||||
geometry.setIndex([...graph.mesh.indices]);
|
||||
graph.mesh.layers.forEach((layer, index) => geometry.addGroup(layer.indexStart, layer.indexCount, index));
|
||||
return geometry;
|
||||
}, [graph]);
|
||||
const materials = useMemo(() => graph.mesh?.layers.map((layer) => {
|
||||
const source = graph.materials[layer.materialIndex];
|
||||
const maps = layer.textureIndices.map((index, slot) => {
|
||||
const texture = textures.get(index)!.clone();
|
||||
texture.wrapS = graph.textures[index].flags & 1 ? RepeatWrapping : ClampToEdgeWrapping;
|
||||
texture.wrapT = graph.textures[index].flags & 2 ? RepeatWrapping : ClampToEdgeWrapping;
|
||||
texture.channel = Math.max(0, layer.textureCoordinateIndices[slot] ?? 0);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
});
|
||||
const material = new MeshBasicMaterial({
|
||||
map: maps[0], blending: blend(source.blendMode), transparent: source.blendMode !== 0,
|
||||
alphaTest: source.blendMode === 1 ? 0.5 : 0, depthWrite: !(source.flags & 16) && source.blendMode < 2,
|
||||
depthTest: !(source.flags & 8), side: DoubleSide, toneMapped: false,
|
||||
});
|
||||
// The WotLK spell batches use one or two source textures. Preserve the
|
||||
// second color/alpha layer and its independent UV transform in the shader.
|
||||
if (maps[1]) {
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.wowSecondMap = { value: maps[1] };
|
||||
shader.uniforms.wowSecondMatrix = { value: maps[1].matrix };
|
||||
shader.vertexShader = shader.vertexShader.replace("#include <common>",
|
||||
"#include <common>\nuniform mat3 wowSecondMatrix;\nvarying vec2 vWowSecondUv;"
|
||||
+ (maps[1].channel === 1 ? "\n#ifndef USE_UV1\nattribute vec2 uv1;\n#endif" : ""));
|
||||
shader.vertexShader = shader.vertexShader.replace("#include <uv_vertex>",
|
||||
`#include <uv_vertex>\nvWowSecondUv = (wowSecondMatrix * vec3(${maps[1].channel === 1 ? "uv1" : "uv"}, 1.0)).xy;`);
|
||||
shader.fragmentShader = shader.fragmentShader.replace("#include <common>",
|
||||
"#include <common>\nuniform sampler2D wowSecondMap;\nvarying vec2 vWowSecondUv;");
|
||||
shader.fragmentShader = shader.fragmentShader.replace("#include <map_fragment>",
|
||||
"#include <map_fragment>\ndiffuseColor *= texture2D(wowSecondMap, vWowSecondUv);");
|
||||
};
|
||||
material.customProgramCacheKey = () => `wow-dual-${maps[1].channel}`;
|
||||
}
|
||||
return { material, maps };
|
||||
}) ?? [], [graph, textures]);
|
||||
useEffect(() => () => {
|
||||
geometry?.dispose();
|
||||
materials.forEach(({ material, maps }) => { material.dispose(); maps.forEach((texture) => texture.dispose()); });
|
||||
}, [geometry, materials]);
|
||||
useFrame((state) => {
|
||||
if (!active) return;
|
||||
const frame = wowModelFrame(graph, playbackRef.current.elapsedMs, loop, playbackRef.current.decaying);
|
||||
root.current?.getWorldQuaternion(scratch.world);
|
||||
state.camera.getWorldQuaternion(scratch.camera).premultiply(scratch.world.invert());
|
||||
// Authored billboard planes face source +X, whereas the camera faces +Z.
|
||||
scratch.camera.multiply(scratch.billboardAxis);
|
||||
updateWowBoneMatrices(graph, frame, scratch.bones, scratch.camera);
|
||||
if (!geometry || !graph.mesh) return;
|
||||
const mesh = graph.mesh;
|
||||
const positions = geometry.getAttribute("position") as BufferAttribute;
|
||||
const normals = geometry.getAttribute("normal") as BufferAttribute;
|
||||
for (let index = 0; index < mesh.positions.length / 3; index++) {
|
||||
scratch.position.set(0, 0, 0); scratch.normalSum.set(0, 0, 0);
|
||||
let total = 0;
|
||||
for (let slot = 0; slot < 4; slot++) {
|
||||
const weight = mesh.boneWeights[index * 4 + slot];
|
||||
if (!weight) continue;
|
||||
const matrix = scratch.bones[mesh.boneIndices[index * 4 + slot]];
|
||||
scratch.vertex.fromArray(mesh.positions, index * 3).applyMatrix4(matrix);
|
||||
scratch.normal.fromArray(mesh.normals, index * 3).transformDirection(matrix);
|
||||
scratch.position.addScaledVector(scratch.vertex, weight);
|
||||
scratch.normalSum.addScaledVector(scratch.normal, weight);
|
||||
total += weight;
|
||||
}
|
||||
if (!total) { scratch.position.fromArray(mesh.positions, index * 3); scratch.normalSum.fromArray(mesh.normals, index * 3); }
|
||||
positions.setXYZ(index, ...scratch.position.toArray());
|
||||
normals.setXYZ(index, ...scratch.normalSum.normalize().toArray());
|
||||
}
|
||||
positions.needsUpdate = true; normals.needsUpdate = true;
|
||||
mesh.layers.forEach((layer, index) => {
|
||||
const { material, maps } = materials[index];
|
||||
const color = mesh.colors[layer.colorIndex];
|
||||
const weight = mesh.textureWeights[layer.textureWeightIndex];
|
||||
const rgb = color ? sampleOriginalCombatMeshTrack(graph, color.color, frame) : undefined;
|
||||
material.color.setRGB(...(rgb ?? [1, 1, 1]));
|
||||
material.opacity = Math.max(0, Math.min(1, (color ? sampleOriginalCombatMeshTrack(graph, color.alpha, frame) ?? 32767 : 32767)
|
||||
* (weight ? sampleOriginalCombatMeshTrack(graph, weight, frame) ?? 32767 : 32767) / (32767 * 32767)));
|
||||
maps.forEach((texture, slot) => {
|
||||
const transform = graph.textureTransforms[layer.textureTransformIndices[slot]];
|
||||
if (!transform) return;
|
||||
const t = sampleOriginalCombatMeshTrack(graph, transform.translation, frame) ?? [0, 0, 0];
|
||||
const s = sampleOriginalCombatMeshTrack(graph, transform.scale, frame) ?? [1, 1, 1];
|
||||
const q = sampleOriginalCombatMeshTrack(graph, transform.rotation, frame) ?? [0, 0, 0, 1];
|
||||
texture.offset.set(t[0], t[1]); texture.repeat.set(s[0], s[1]); texture.center.set(0.5, 0.5);
|
||||
texture.rotation = 2 * Math.atan2(q[2], q[3]); texture.updateMatrix();
|
||||
});
|
||||
});
|
||||
});
|
||||
return <group ref={root} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{geometry && <mesh name="original-wow-animated-mesh" geometry={geometry}
|
||||
material={materials.map((entry) => entry.material)} frustumCulled={false} />}
|
||||
{graph.ribbons.map((ribbon, index) => <OriginalWowRibbon key={index} graph={graph} ribbon={ribbon}
|
||||
textures={textures} active={active} bones={scratch.bones} playbackRef={playbackRef} loop={loop} />)}
|
||||
</group>;
|
||||
});
|
||||
|
||||
const OriginalWowRibbon = memo(function OriginalWowRibbon({ graph, ribbon, textures, active, bones, playbackRef, loop }: {
|
||||
readonly graph: OriginalWowModelGraph; readonly ribbon: WowRibbon; readonly textures: ReadonlyMap<number, Texture>;
|
||||
readonly active: boolean; readonly bones: readonly Matrix4[];
|
||||
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; readonly loop: boolean;
|
||||
}) {
|
||||
const root = useRef<Group>(null);
|
||||
const capacity = Math.min(128, Math.max(4, Math.ceil(ribbon.edgesPerSecond * ribbon.edgeLifetime) + 2));
|
||||
const geometry = useMemo(() => {
|
||||
const g = new BufferGeometry();
|
||||
g.setAttribute("position", new BufferAttribute(new Float32Array(capacity * 6), 3));
|
||||
g.setAttribute("uv", new BufferAttribute(new Float32Array(capacity * 4), 2));
|
||||
g.setIndex(Array.from({ length: (capacity - 1) * 6 }, (_, i) => {
|
||||
const segment = Math.floor(i / 6) * 2;
|
||||
return segment + [0, 1, 2, 2, 1, 3][i % 6];
|
||||
}));
|
||||
g.setDrawRange(0, 0); return g;
|
||||
}, [capacity]);
|
||||
const material = useMemo(() => new MeshBasicMaterial({
|
||||
map: textures.get(ribbon.textureIndices[0]), transparent: true, depthWrite: false,
|
||||
side: DoubleSide, blending: blend(graph.materials[ribbon.materialIndices[0]]?.blendMode ?? 4), toneMapped: false,
|
||||
}), [graph, ribbon, textures]);
|
||||
const history = useMemo(() => ({
|
||||
edges: [] as { time: number; above: Vector3; below: Vector3 }[], inverse: new Matrix4(),
|
||||
top: new Vector3(), bottom: new Vector3(), local: new Vector3(), last: -Infinity,
|
||||
}), []);
|
||||
useEffect(() => () => { geometry.dispose(); material.dispose(); }, [geometry, material]);
|
||||
useFrame(() => {
|
||||
if (!active || !root.current) return;
|
||||
const elapsed = playbackRef.current.elapsedMs;
|
||||
const frame = wowModelFrame(graph, elapsed, loop, playbackRef.current.decaying);
|
||||
const sample = (key: "alpha" | "heightAbove" | "heightBelow" | "textureSlot" | "visibility") =>
|
||||
sampleOriginalCombatMeshTrack(graph, ribbon.tracks[key], frame);
|
||||
const lifetimeMs = Math.max(20, ribbon.edgeLifetime * 1000);
|
||||
if (elapsed < history.last) { history.edges.length = 0; history.last = -Infinity; }
|
||||
while (history.edges.length && elapsed - history.edges[0].time > lifetimeMs) history.edges.shift();
|
||||
root.current.updateWorldMatrix(true, false);
|
||||
if (elapsed - history.last >= 1000 / Math.max(1, ribbon.edgesPerSecond) && (sample("visibility") ?? 1) > 0) {
|
||||
const bone = bones[ribbon.parentBone];
|
||||
history.top.fromArray(ribbon.position); history.bottom.copy(history.top);
|
||||
history.top.z += sample("heightAbove") ?? 0.1;
|
||||
history.bottom.z -= sample("heightBelow") ?? 0.1;
|
||||
history.top.applyMatrix4(bone).applyMatrix4(root.current.matrixWorld);
|
||||
history.bottom.applyMatrix4(bone).applyMatrix4(root.current.matrixWorld);
|
||||
history.edges.push({ time: elapsed, above: history.top.clone(), below: history.bottom.clone() });
|
||||
if (history.edges.length > capacity) history.edges.shift();
|
||||
history.last = elapsed;
|
||||
}
|
||||
history.inverse.copy(root.current.matrixWorld).invert();
|
||||
const positions = geometry.getAttribute("position") as BufferAttribute;
|
||||
const uv = geometry.getAttribute("uv") as BufferAttribute;
|
||||
const columns = Math.max(1, ribbon.textureColumns), rows = Math.max(1, ribbon.textureRows);
|
||||
const slot = Math.max(0, Math.floor(sample("textureSlot") ?? 0)) % (columns * rows);
|
||||
history.edges.forEach((edge, index) => {
|
||||
for (const side of [0, 1]) {
|
||||
history.local.copy(side ? edge.below : edge.above);
|
||||
history.local.y -= ribbon.gravity * ((elapsed - edge.time) / 1000) ** 2 * 0.5;
|
||||
history.local.applyMatrix4(history.inverse);
|
||||
positions.setXYZ(index * 2 + side, ...history.local.toArray());
|
||||
uv.setXY(index * 2 + side, (slot % columns + index / Math.max(1, history.edges.length - 1)) / columns,
|
||||
(Math.floor(slot / columns) + side) / rows);
|
||||
}
|
||||
});
|
||||
material.color.setRGB(...(sampleOriginalCombatMeshTrack(graph, ribbon.tracks.color, frame) ?? [1, 1, 1]));
|
||||
material.opacity = Math.max(0, Math.min(1, (sample("alpha") ?? 32767) / 32767));
|
||||
positions.needsUpdate = true; uv.needsUpdate = true;
|
||||
geometry.setDrawRange(0, Math.max(0, history.edges.length - 1) * 6);
|
||||
});
|
||||
return <group ref={root}><mesh name="original-wow-ribbon" geometry={geometry} material={material} frustumCulled={false} /></group>;
|
||||
});
|
||||
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
import type { CombatPresentationEvent } from "../game/combatPresentation";
|
||||
import {
|
||||
COMBAT_EFFECT_SYSTEM_LIMIT,
|
||||
addOriginalInstantCastEffect,
|
||||
addOriginalOnlyEffect,
|
||||
advanceCombatEffectFrame,
|
||||
originalInstantCastEvent,
|
||||
originalInstantTerminalEvent,
|
||||
updateCombatEffectQueue,
|
||||
type ActiveCombatEffect,
|
||||
} from "./combatEffectRuntime";
|
||||
@@ -120,4 +124,48 @@ describe("combat-effect runtime budget", () => {
|
||||
], "high");
|
||||
expect(projectile).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("lets an instant source cast overlap its release while completing timed casts", () => {
|
||||
const release = event(2, {
|
||||
phase: "release",
|
||||
sourceActorId: "instant-caster",
|
||||
instantCast: true,
|
||||
});
|
||||
const sourceCast = originalInstantCastEvent(release);
|
||||
expect(sourceCast).toMatchObject({
|
||||
id: -2,
|
||||
phase: "cast-start",
|
||||
sourceActorId: "instant-caster",
|
||||
instantCast: true,
|
||||
});
|
||||
const instant = updateCombatEffectQueue(
|
||||
addOriginalInstantCastEffect([], release, "high"),
|
||||
release,
|
||||
"high",
|
||||
);
|
||||
expect(instant.map(({ event: active }) => active.phase)).toEqual(["cast-start", "release"]);
|
||||
expect(instant[0].originalOnly).toBe(true);
|
||||
|
||||
const timed = enqueue([
|
||||
event(3, { phase: "cast-start", sourceActorId: "timed-caster" }),
|
||||
event(4, { phase: "release", sourceActorId: "timed-caster" }),
|
||||
], "high");
|
||||
expect(timed.map(({ event: active }) => active.phase)).toEqual(["release"]);
|
||||
expect(originalInstantCastEvent(timed[0].event)).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves source terminal semantics for instant impact and aura fallbacks", () => {
|
||||
const impact = event(20, { phase: "impact", instantCast: true, delivery: "area" });
|
||||
const auraFallback = originalInstantTerminalEvent(impact);
|
||||
expect(auraFallback).toMatchObject({ id: -20, phase: "aura-start", instantCast: true });
|
||||
|
||||
const aura = event(21, { phase: "aura-start", instantCast: true, delivery: "aura" });
|
||||
const impactFallback = originalInstantTerminalEvent(aura);
|
||||
expect(impactFallback).toMatchObject({ id: -21, phase: "impact", instantCast: true });
|
||||
expect(addOriginalOnlyEffect([], impactFallback!, "high")[0]).toMatchObject({
|
||||
originalOnly: true,
|
||||
event: { phase: "impact" },
|
||||
});
|
||||
expect(originalInstantTerminalEvent(event(22, { phase: "impact" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
export interface ActiveCombatEffect {
|
||||
readonly key: string;
|
||||
readonly event: CombatPresentationEvent;
|
||||
readonly originalOnly?: boolean;
|
||||
}
|
||||
|
||||
export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
|
||||
@@ -106,6 +107,7 @@ export function updateCombatEffectQueue(
|
||||
effect.event.phase === "cast-start"
|
||||
&& effect.event.sourceActorId === event.sourceActorId
|
||||
&& effect.event.abilityId === event.abilityId
|
||||
&& (event.phase === "cast-cancel" || !effect.event.instantCast)
|
||||
))
|
||||
: current;
|
||||
if (quality === "off") return current.length === 0 ? current : [];
|
||||
@@ -135,3 +137,56 @@ export function updateCombatEffectQueue(
|
||||
) return current;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Add the source-side cast kit for an instant release without replaying audio or generated VFX. */
|
||||
export function addOriginalInstantCastEffect(
|
||||
current: readonly ActiveCombatEffect[],
|
||||
event: CombatPresentationEvent,
|
||||
quality: CombatVfxQuality,
|
||||
): readonly ActiveCombatEffect[] {
|
||||
const castEvent = originalInstantCastEvent(event);
|
||||
if (!castEvent || quality === "off") return current;
|
||||
return addOriginalOnlyEffect(current, castEvent, quality);
|
||||
}
|
||||
|
||||
export function originalInstantCastEvent(
|
||||
event: CombatPresentationEvent,
|
||||
): CombatPresentationEvent | null {
|
||||
if (event.phase !== "release" || !event.instantCast) return null;
|
||||
return {
|
||||
...event,
|
||||
id: -event.id,
|
||||
phase: "cast-start",
|
||||
durationMs: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map source state/impact kits when an instant ability's runtime terminal phase differs. */
|
||||
export function originalInstantTerminalEvent(
|
||||
event: CombatPresentationEvent,
|
||||
): CombatPresentationEvent | null {
|
||||
if (!event.instantCast) return null;
|
||||
const phase = event.phase === "impact"
|
||||
? "aura-start"
|
||||
: event.phase === "aura-start"
|
||||
? "impact"
|
||||
: null;
|
||||
if (!phase) return null;
|
||||
return {
|
||||
...event,
|
||||
id: -event.id,
|
||||
phase,
|
||||
};
|
||||
}
|
||||
|
||||
export function addOriginalOnlyEffect(
|
||||
current: readonly ActiveCombatEffect[],
|
||||
event: CombatPresentationEvent,
|
||||
quality: CombatVfxQuality,
|
||||
): readonly ActiveCombatEffect[] {
|
||||
if (quality === "off") return current;
|
||||
const updated = updateCombatEffectQueue(current, event, quality);
|
||||
return updated.map((effect) => effect.event === event
|
||||
? { ...effect, key: `${effect.key}:original-phase`, originalOnly: true }
|
||||
: effect);
|
||||
}
|
||||
@@ -401,7 +401,7 @@ export function parseOriginalCombatMeshGraph(value: unknown): OriginalCombatMesh
|
||||
};
|
||||
}
|
||||
|
||||
type OriginalCombatMeshSampleValue = number | readonly [number, number, number];
|
||||
type OriginalCombatMeshSampleValue = number | readonly [number, number, number] | readonly [number, number, number, number];
|
||||
|
||||
function baseTrackValue<T extends OriginalCombatMeshSampleValue>(
|
||||
value: OriginalCombatMeshTrackValue<T>,
|
||||
@@ -428,14 +428,14 @@ function combineTrackValues<T extends OriginalCombatMeshSampleValue>(
|
||||
total + (value as number) * coefficients[index]
|
||||
), 0) as T;
|
||||
}
|
||||
return [0, 1, 2].map((component) => values.reduce<number>((total, value, index) => (
|
||||
return (values[0] as readonly number[]).map((_, component) => values.reduce<number>((total, value, index) => (
|
||||
total + (value as readonly number[])[component] * coefficients[index]
|
||||
), 0)) as unknown as T;
|
||||
}
|
||||
|
||||
/** Sample one source M2 track for a selected animation sequence. */
|
||||
export function sampleOriginalCombatMeshTrack<T extends OriginalCombatMeshSampleValue>(
|
||||
graph: OriginalCombatMeshGraph,
|
||||
graph: Pick<OriginalCombatMeshGraph, "globalSequences">,
|
||||
track: OriginalCombatMeshTrack<T>,
|
||||
frame: OriginalCombatMeshPlaybackFrame,
|
||||
): T | undefined {
|
||||
@@ -467,6 +467,10 @@ export function sampleOriginalCombatMeshTrack<T extends OriginalCombatMeshSample
|
||||
const upperValue = baseTrackValue(upper);
|
||||
if (track.interpolation === 0) return lowerValue;
|
||||
if (track.interpolation === 1) {
|
||||
if (Array.isArray(lowerValue) && lowerValue.length === 4 && Array.isArray(upperValue)) {
|
||||
const dot = lowerValue.reduce((sum, value, index) => sum + value * upperValue[index], 0);
|
||||
return combineTrackValues([lowerValue as T, upperValue as T], [1 - progress, dot < 0 ? -progress : progress]);
|
||||
}
|
||||
return combineTrackValues([lowerValue, upperValue], [1 - progress, progress]);
|
||||
}
|
||||
const lowerSpline = splineTrackValue(lower);
|
||||
@@ -489,7 +493,7 @@ export function sampleOriginalCombatMeshTrack<T extends OriginalCombatMeshSample
|
||||
|
||||
/** Select Stand once, Hold in a loop, or Decay once from the source M2. */
|
||||
export function originalCombatMeshPlaybackFrame(
|
||||
graph: OriginalCombatMeshGraph,
|
||||
graph: Pick<OriginalCombatMeshGraph, "animations">,
|
||||
elapsedMs: number,
|
||||
decaying = false,
|
||||
): OriginalCombatMeshPlaybackFrame {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Matrix4, Quaternion, Vector3 } from "three";
|
||||
import { parseOriginalWowModelGraph, updateWowBoneMatrices, wowModelFrame, wowModelTextureUrls, wowParticleChild } from "./originalWowModelRuntime";
|
||||
|
||||
const manifest = JSON.parse(readFileSync(path.resolve("public/assets/game/combat-effects/manifest.json"), "utf8"));
|
||||
const urls = [...new Set<string>(manifest.entries.filter((e: { source: string }) => e.source === "wow335")
|
||||
.flatMap((e: { phases: Record<string, { effectGraphUrl?: string }[]> }) => Object.values(e.phases ?? {}).flat()
|
||||
.map((a) => a.effectGraphUrl).filter(Boolean)))];
|
||||
const graphs = urls.map((url) => JSON.parse(readFileSync(path.join("public", url), "utf8")));
|
||||
|
||||
describe("all-class original WoW models", () => {
|
||||
it("loads every shipped model through the runtime parser and samples finite bone poses", () => {
|
||||
expect(graphs.length).toBe(378);
|
||||
for (const raw of graphs) {
|
||||
let graph;
|
||||
try { graph = parseOriginalWowModelGraph(raw); }
|
||||
catch (error) { throw new Error(`${raw.sourceModel}: ${String(error)}`); }
|
||||
const bones: Matrix4[] = [];
|
||||
for (const elapsed of [0, 250, 950, 2500]) {
|
||||
updateWowBoneMatrices(graph, wowModelFrame(graph, elapsed, true), bones);
|
||||
expect(bones.every((bone) => bone.elements.every(Number.isFinite)), graph.sourceModel).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("applies animation around the source pivot and inherits parent transforms", () => {
|
||||
const graph = parseOriginalWowModelGraph(graphs[0]);
|
||||
const track = <T,>(value: T) => ({ interpolation: 1 as const, globalSequence: -1,
|
||||
sequences: [{ timestamps: [0], values: [value] }] });
|
||||
const root = { parent: -1, flags: 0, pivot: [1, 0, 0] as const,
|
||||
translation: track([2, 0, 0] as const), rotation: track([0, 0, 0, 1] as const), scale: track([2, 2, 2] as const) };
|
||||
const fixture = { ...graph, bones: [root, { ...root, parent: 0, translation: track([0, 1, 0] as const), scale: track([1, 1, 1] as const) }] };
|
||||
const matrices: Matrix4[] = [];
|
||||
updateWowBoneMatrices(fixture, wowModelFrame(fixture, 0), matrices);
|
||||
expect(new Vector3(1, 0, 0).applyMatrix4(matrices[0]).toArray()).toEqual([3, 0, 0]);
|
||||
expect(new Vector3(1, 0, 0).applyMatrix4(matrices[1]).toArray()).toEqual([3, 2, 0]);
|
||||
});
|
||||
|
||||
it("rejects out-of-range geometry and cyclic bone parents before rendering", () => {
|
||||
const raw = graphs.find((g) => g.mesh && g.bones.length);
|
||||
const broken = structuredClone(raw);
|
||||
broken.mesh.indices[0] = broken.mesh.positions.length;
|
||||
expect(() => parseOriginalWowModelGraph(broken)).toThrow(/geometry/);
|
||||
broken.mesh.indices[0] = 0;
|
||||
broken.bones[0].parent = 0;
|
||||
expect(() => parseOriginalWowModelGraph(broken)).toThrow(/parent/);
|
||||
});
|
||||
|
||||
it("keeps upright shield billboards facing the camera without tipping their up axis", () => {
|
||||
const graph = parseOriginalWowModelGraph(graphs.find((g) => g.sourceModel === "spells/holydivineshield_state_base.m2"));
|
||||
const bones: Matrix4[] = [];
|
||||
updateWowBoneMatrices(graph, wowModelFrame(graph, 500), bones,
|
||||
new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), Math.PI / 2));
|
||||
const up = new Vector3(0, 0, 1).transformDirection(bones[0]);
|
||||
const normal = new Vector3(1, 0, 0).transformDirection(bones[0]);
|
||||
expect(up.z).toBeCloseTo(1);
|
||||
expect(normal.y).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it("resolves source model particles and recursive emitters with their own textures", () => {
|
||||
const cone = parseOriginalWowModelGraph(graphs.find((g) => g.sourceModel === "spells/coneofcold_hand.m2"));
|
||||
const child = wowParticleChild(cone, "Spells\\ConeofCold_Geo.mdx")!;
|
||||
expect(child.mesh!.indices.length).toBeGreaterThan(0);
|
||||
expect(wowModelTextureUrls(cone)).toContain(child.textures[0].url);
|
||||
const dispel = parseOriginalWowModelGraph(graphs.find((g) => g.sourceModel === "spells/clense_base.m2"));
|
||||
expect(wowParticleChild(dispel, "Spells\\Dispel_Low_Recursive.mdx")!.particleGraph!.particles).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Matrix4, Quaternion, Vector3 } from "three";
|
||||
import { parseOriginalCombatEffectGraph, type OriginalCombatEffectGraph } from "./originalCombatEffectRuntime";
|
||||
import {
|
||||
originalCombatMeshPlaybackFrame, sampleOriginalCombatMeshTrack,
|
||||
type OriginalCombatMeshAnimation, type OriginalCombatMeshColor, type OriginalCombatMeshTrack,
|
||||
type OriginalCombatMeshPlaybackFrame,
|
||||
} from "./originalCombatMeshRuntime";
|
||||
|
||||
export type WowVector = readonly [number, number, number];
|
||||
export type WowQuaternion = readonly [number, number, number, number];
|
||||
interface WowTransform {
|
||||
readonly translation: OriginalCombatMeshTrack<WowVector>;
|
||||
readonly rotation: OriginalCombatMeshTrack<WowQuaternion>;
|
||||
readonly scale: OriginalCombatMeshTrack<WowVector>;
|
||||
}
|
||||
export interface WowBone extends WowTransform {
|
||||
readonly parent: number;
|
||||
readonly flags: number;
|
||||
readonly pivot: WowVector;
|
||||
}
|
||||
export interface WowMeshLayer {
|
||||
readonly indexStart: number;
|
||||
readonly indexCount: number;
|
||||
readonly colorIndex: number;
|
||||
readonly materialIndex: number;
|
||||
readonly textureIndices: readonly number[];
|
||||
readonly textureCoordinateIndices: readonly number[];
|
||||
readonly textureWeightIndex: number;
|
||||
readonly textureTransformIndices: readonly number[];
|
||||
}
|
||||
export interface WowRibbon {
|
||||
readonly id: number;
|
||||
readonly parentBone: number;
|
||||
readonly position: WowVector;
|
||||
readonly textureIndices: readonly number[];
|
||||
readonly materialIndices: readonly number[];
|
||||
readonly edgesPerSecond: number;
|
||||
readonly edgeLifetime: number;
|
||||
readonly gravity: number;
|
||||
readonly textureRows: number;
|
||||
readonly textureColumns: number;
|
||||
readonly tracks: {
|
||||
readonly color: OriginalCombatMeshTrack<WowVector>;
|
||||
readonly alpha: OriginalCombatMeshTrack<number>;
|
||||
readonly heightAbove: OriginalCombatMeshTrack<number>;
|
||||
readonly heightBelow: OriginalCombatMeshTrack<number>;
|
||||
readonly textureSlot: OriginalCombatMeshTrack<number>;
|
||||
readonly visibility: OriginalCombatMeshTrack<number>;
|
||||
};
|
||||
}
|
||||
export interface OriginalWowModelGraph {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: "wow-m2-model";
|
||||
readonly source: "wow335";
|
||||
readonly sourceModel: string;
|
||||
readonly sourceSha256: string;
|
||||
readonly sourceSkinSha256: string | null;
|
||||
readonly animations: readonly OriginalCombatMeshAnimation[];
|
||||
readonly globalSequences: readonly number[];
|
||||
readonly bones: readonly WowBone[];
|
||||
readonly textureTransforms: readonly WowTransform[];
|
||||
readonly materials: readonly { readonly flags: number; readonly blendMode: number }[];
|
||||
readonly textures: readonly { readonly filename: string; readonly flags: number; readonly url?: string }[];
|
||||
readonly particleGraph: OriginalCombatEffectGraph | null;
|
||||
readonly children: Readonly<Record<string, OriginalWowModelGraph>>;
|
||||
readonly ribbons: readonly WowRibbon[];
|
||||
readonly mesh: {
|
||||
readonly positions: readonly number[];
|
||||
readonly normals: readonly number[];
|
||||
readonly uvs: readonly number[];
|
||||
readonly uvs2: readonly number[];
|
||||
readonly boneIndices: readonly number[];
|
||||
readonly boneWeights: readonly number[];
|
||||
readonly indices: readonly number[];
|
||||
readonly colors: readonly OriginalCombatMeshColor[];
|
||||
readonly textureWeights: readonly OriginalCombatMeshTrack<number>[];
|
||||
readonly layers: readonly WowMeshLayer[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function parseOriginalWowModelGraph(value: unknown): OriginalWowModelGraph {
|
||||
if (!value || typeof value !== "object") throw new Error("Invalid original WoW model graph.");
|
||||
const graph = value as OriginalWowModelGraph;
|
||||
if (graph.schemaVersion !== 1 || graph.kind !== "wow-m2-model" || graph.source !== "wow335"
|
||||
|| !/^[a-f0-9]{64}$/.test(graph.sourceSha256)) throw new Error("Invalid WoW model schema/source.");
|
||||
const finiteTree = (node: unknown): void => {
|
||||
if (typeof node === "number" && !Number.isFinite(node)) throw new Error("Non-finite WoW model data.");
|
||||
if (node && typeof node === "object") Object.values(node).forEach(finiteTree);
|
||||
};
|
||||
finiteTree(graph);
|
||||
for (const key of ["bones", "textures", "materials", "ribbons", "animations", "globalSequences", "textureTransforms"] as const) {
|
||||
if (!Array.isArray(graph[key])) throw new Error(`WoW model ${key} must be an array.`);
|
||||
}
|
||||
const vector = (v: readonly number[], length: number) => {
|
||||
if (!Array.isArray(v) || v.length !== length || v.some((n) => typeof n !== "number")) throw new Error("Invalid WoW vector.");
|
||||
};
|
||||
const track = (t: OriginalCombatMeshTrack<number | WowVector | WowQuaternion>, size: number) => {
|
||||
if (!t || ![0, 1, 2, 3].includes(t.interpolation) || !Array.isArray(t.sequences)
|
||||
|| !Number.isInteger(t.globalSequence) || t.globalSequence < -1
|
||||
|| t.globalSequence >= graph.globalSequences.length) throw new Error("Invalid WoW animation track.");
|
||||
for (const s of t.sequences) {
|
||||
if (!Array.isArray(s.timestamps) || !Array.isArray(s.values) || s.timestamps.length !== s.values.length
|
||||
|| s.timestamps.some((n: number, i: number) => n < 0 || (i > 0 && n < s.timestamps[i - 1]))) throw new Error("Invalid WoW timeline.");
|
||||
for (const raw of s.values) {
|
||||
const values = raw && typeof raw === "object" && "value" in raw
|
||||
? [raw.value, raw.inTangent, raw.outTangent] : [raw];
|
||||
for (const v of values) {
|
||||
if (size === 1) { if (typeof v !== "number") throw new Error("Invalid scalar track."); }
|
||||
else vector(v as readonly number[], size);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const transform = (t: WowTransform) => { track(t.translation, 3); track(t.rotation, 4); track(t.scale, 3); };
|
||||
graph.bones.forEach((bone, index) => {
|
||||
if (!Number.isInteger(bone.parent) || bone.parent < -1 || bone.parent >= index) throw new Error("Invalid WoW bone parent.");
|
||||
vector(bone.pivot, 3); transform(bone);
|
||||
});
|
||||
graph.textureTransforms.forEach(transform);
|
||||
const textureIndex = (index: number) => {
|
||||
if (!Number.isInteger(index) || !graph.textures[index]?.url?.startsWith("/assets/game/combat-effects/original/")) {
|
||||
throw new Error("WoW model references an unpackaged texture.");
|
||||
}
|
||||
};
|
||||
const mesh = graph.mesh;
|
||||
if (mesh) {
|
||||
const count = mesh.positions.length / 3;
|
||||
if (!Number.isInteger(count) || count === 0 || mesh.normals.length !== count * 3
|
||||
|| mesh.uvs.length !== count * 2 || mesh.uvs2.length !== count * 2
|
||||
|| mesh.boneIndices.length !== count * 4 || mesh.boneWeights.length !== count * 4
|
||||
|| mesh.indices.some((i) => !Number.isInteger(i) || i < 0 || i >= count)) throw new Error("Invalid WoW mesh geometry.");
|
||||
mesh.boneIndices.forEach((bone, i) => {
|
||||
if (mesh.boneWeights[i] > 0 && !graph.bones[bone]) throw new Error("Invalid mesh bone reference.");
|
||||
});
|
||||
mesh.colors.forEach((c) => { track(c.color, 3); track(c.alpha, 1); });
|
||||
mesh.textureWeights.forEach((t) => track(t, 1));
|
||||
mesh.layers.forEach((layer) => {
|
||||
if (!graph.materials[layer.materialIndex] || layer.indexStart < 0 || layer.indexCount <= 0
|
||||
|| layer.indexStart + layer.indexCount > mesh.indices.length) throw new Error("Invalid WoW mesh layer.");
|
||||
layer.textureIndices.forEach(textureIndex);
|
||||
});
|
||||
}
|
||||
graph.ribbons.forEach((ribbon) => {
|
||||
vector(ribbon.position, 3);
|
||||
if (!graph.bones[ribbon.parentBone]) throw new Error("Invalid ribbon bone.");
|
||||
ribbon.textureIndices.forEach(textureIndex);
|
||||
Object.entries(ribbon.tracks).forEach(([key, t]) => track(t, key === "color" ? 3 : 1));
|
||||
});
|
||||
const children = Object.fromEntries(Object.entries(graph.children ?? {}).map(([name, child]) => [name, parseOriginalWowModelGraph(child)]));
|
||||
return { ...graph, children, particleGraph: graph.particleGraph ? parseOriginalCombatEffectGraph(graph.particleGraph) : null };
|
||||
}
|
||||
|
||||
export function wowModelTextureUrls(graph: OriginalWowModelGraph): string[] {
|
||||
return [...new Set([...graph.textures.flatMap((texture) => texture.url ? [texture.url] : []),
|
||||
...Object.values(graph.children).flatMap(wowModelTextureUrls)])];
|
||||
}
|
||||
|
||||
export function wowParticleChild(graph: OriginalWowModelGraph | undefined, model: string) {
|
||||
return graph?.children[model.replaceAll("\\", "/").replace(/\.mdx$/i, ".m2").toLowerCase()];
|
||||
}
|
||||
|
||||
export function wowModelFrame(graph: OriginalWowModelGraph, elapsedMs: number, loop = false, decay = false) {
|
||||
const frame = originalCombatMeshPlaybackFrame(graph, elapsedMs, decay);
|
||||
if (loop && !decay && !graph.animations.some((a) => a.id === 158)) {
|
||||
const duration = graph.animations[frame.animationIndex]?.durationMs ?? 1;
|
||||
return { ...frame, timeMs: elapsedMs % Math.max(1, duration) };
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
const translation = new Vector3(), pivot = new Vector3(), scale = new Vector3(), rotation = new Quaternion();
|
||||
const inversePivot = new Matrix4();
|
||||
const facing = new Vector3(), parentRotation = new Quaternion(), zAxis = new Vector3(0, 0, 1);
|
||||
export function updateWowBoneMatrices(
|
||||
graph: OriginalWowModelGraph, frame: OriginalCombatMeshPlaybackFrame, output: Matrix4[],
|
||||
billboard?: Quaternion,
|
||||
): void {
|
||||
graph.bones.forEach((bone, index) => {
|
||||
translation.fromArray(sampleOriginalCombatMeshTrack(graph, bone.translation, frame) ?? [0, 0, 0]);
|
||||
rotation.fromArray(sampleOriginalCombatMeshTrack(graph, bone.rotation, frame) ?? [0, 0, 0, 1]).normalize();
|
||||
scale.fromArray(sampleOriginalCombatMeshTrack(graph, bone.scale, frame) ?? [1, 1, 1]);
|
||||
pivot.fromArray(bone.pivot);
|
||||
if (billboard && (bone.flags & 0x48)) {
|
||||
if (bone.flags & 0x40) {
|
||||
facing.set(1, 0, 0).applyQuaternion(billboard);
|
||||
rotation.setFromAxisAngle(zAxis, Math.atan2(facing.y, facing.x));
|
||||
} else rotation.copy(billboard);
|
||||
if (bone.parent >= 0) {
|
||||
parentRotation.setFromRotationMatrix(output[bone.parent]).invert();
|
||||
rotation.premultiply(parentRotation);
|
||||
}
|
||||
}
|
||||
const matrix = output[index] ??= new Matrix4();
|
||||
matrix.compose(translation.add(pivot), rotation, scale);
|
||||
matrix.multiply(inversePivot.makeTranslation(-pivot.x, -pivot.y, -pivot.z));
|
||||
if (bone.parent >= 0) matrix.premultiply(output[bone.parent]);
|
||||
});
|
||||
}
|
||||
|
||||
export function wowModelDurationMs(graph: OriginalWowModelGraph): number {
|
||||
const stand = graph.animations.find((a) => a.id === 0) ?? graph.animations[0];
|
||||
return Math.max(120, stand?.durationMs ?? 1_000);
|
||||
}
|
||||
Reference in new issue
Block a user