Release Healer Man 0.1.30

This commit is contained in:
phenom committed 2026-09-11 18:49:33 -04:00
1 parent 544c1bee91
commit 7d7fd567f0
845 files changed
+2040 -87

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
// Development-only inspection surface. Loaded explicitly from scripts/combat-effects/preview.html.
import { useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Grid } from "@react-three/drei";
import { TextureLoader, SRGBColorSpace, type Texture } from "three";
import { OriginalWowModel } from "../scene/OriginalWowModel";
import { OriginalParticleEmitter } from "../scene/OriginalCombatEffect";
import { originalEmitterParticleBudget } from "../scene/originalCombatEffectRuntime";
import { parseOriginalWowModelGraph, wowModelTextureUrls, type OriginalWowModelGraph } from "../scene/originalWowModelRuntime";
interface ModelChoice { model: string; url: string }
function Effect({ graph, textures, sourceTextures, active, freeze, onFrozen }: { graph: OriginalWowModelGraph; textures: ReadonlyMap<number, Texture>; sourceTextures: ReadonlyMap<string, Texture>; active: boolean; freeze: boolean; onFrozen: (value: boolean) => void }) {
const playback = useRef({ elapsedMs: 0, decaying: false });
const [frozen, setFrozen] = useState(false);
useFrame((_, delta) => {
if (active && !frozen) playback.current.elapsedMs += Math.min(0.1, delta) * 1000;
if (freeze && !frozen && playback.current.elapsedMs >= 250) { setFrozen(true); onFrozen(true); }
});
const particleGraph = graph.particleGraph;
return <group position={[0, 0.15, 0]}>
<OriginalWowModel graph={graph} textures={textures} active={active && !frozen} playbackRef={playback} loop />
{particleGraph?.runtime.readyParticleIndices.map((index) => {
const particle = particleGraph.particles[index];
return <OriginalParticleEmitter key={index} graph={particleGraph} modelGraph={graph} particle={particle}
sourceTextures={sourceTextures}
texture={textures.get(particle.textureIndex)!} budget={Math.min(128, originalEmitterParticleBudget(particle, "high"))}
active={active && !frozen} eventId={1} emitterIndex={index} loopTimeline />;
})}
</group>;
}
function Preview() {
const [choices, setChoices] = useState<ModelChoice[]>([]);
const [selected, setSelected] = useState("");
const [ready, setReady] = useState<{ graph: OriginalWowModelGraph; textures: ReadonlyMap<number, Texture>; sourceTextures: ReadonlyMap<string, Texture> } | null>(null);
const [error, setError] = useState("");
const [active, setActive] = useState(true);
const [replay, setReplay] = useState(0);
const [freeze, setFreeze] = useState(false);
const [frozen, setFrozen] = useState(false);
const loader = useMemo(() => new TextureLoader(), []);
useEffect(() => {
void fetch("/assets/game/combat-effects/manifest.json").then((r) => r.json()).then((manifest) => {
const entries = manifest.entries as { source: string; phases: Record<string, { model: string; effectGraphUrl: string }[]> }[];
const models = new Map<string, string>();
entries.filter((e) => e.source === "wow335").forEach((e) => Object.values(e.phases ?? {}).flat().forEach((a) => {
if (a.effectGraphUrl) models.set(a.model.toLowerCase().replace(/\.mdx$/, ".m2"), a.effectGraphUrl);
}));
const list = [...models].sort(([a], [b]) => a.localeCompare(b)).map(([model, url]) => ({ model, url }));
setChoices(list); setSelected(list.find((c) => c.model.includes("fireball_missile"))?.url ?? list[0].url);
}).catch((e) => setError(String(e)));
}, []);
useEffect(() => {
if (!selected) return;
let cancelled = false;
const loaded: Texture[] = [];
setReady(null); setError(""); setFrozen(false);
void fetch(selected).then((r) => r.json()).then(async (value) => {
const graph = parseOriginalWowModelGraph(value);
const textures = new Map<number, Texture>();
const sourceTextures = new Map<string, Texture>();
await Promise.all(wowModelTextureUrls(graph).map(async (url) => {
const texture = await loader.loadAsync(url); loaded.push(texture);
texture.colorSpace = SRGBColorSpace; sourceTextures.set(url, texture);
}));
graph.textures.forEach((source, index) => { if (source.url) textures.set(index, sourceTextures.get(source.url)!); });
if (!cancelled) setReady({ graph, textures, sourceTextures });
else loaded.forEach((texture) => texture.dispose());
}).catch((e) => { if (!cancelled) setError(String(e)); });
return () => { cancelled = true; loaded.forEach((texture) => texture.dispose()); };
}, [selected, loader]);
return <>
<div style={{ padding: "14px 20px", display: "flex", gap: 16, alignItems: "center", flexWrap: "wrap" }}>
<strong>Original WoW effects · {choices.length} models</strong>
<label>Model <select aria-label="Model" value={selected} onChange={(e) => setSelected(e.target.value)}
style={{ maxWidth: "55vw", padding: 8 }}>{choices.map((c) => <option key={c.url} value={c.url}>{c.model}</option>)}</select></label>
<button onClick={() => setActive(!active)}>{active ? "Pause" : "Play"}</button>
<button onClick={() => { setReplay(replay + 1); setFrozen(false); }}>Replay</button>
<label><input type="checkbox" checked={freeze} onChange={(e) => { setFreeze(e.target.checked); setReplay(replay + 1); setFrozen(false); }} />Freeze at 250 ms</label>
</div>
<div role="status" style={{ padding: "0 20px 8px" }}>{error || (ready
? `Ready · ${ready.graph.mesh?.layers.length ?? 0} mesh layers · ${ready.graph.particleGraph?.runtime.readyParticleIndices.length ?? 0} particle emitters · ${ready.graph.ribbons.length} ribbons${frozen ? " · Frozen at 250 ms" : ""}`
: "Loading original media…")}</div>
<div style={{ height: "calc(100vh - 100px)" }}>
<Canvas camera={{ position: [6, 4, 8], fov: 45 }}>
<color attach="background" args={["#0b121b"]} />
<Grid args={[30, 30]} cellColor="#304051" sectionColor="#4a637d" fadeDistance={35} />
<mesh position={[-2, 0.9, 0]}><capsuleGeometry args={[0.25, 1.25, 6, 12]} /><meshBasicMaterial color="#35465c" /></mesh>
{ready && <Effect key={`${selected}:${replay}`} {...ready} active={active} freeze={freeze} onFrozen={setFrozen} />}
<OrbitControls target={[0, 1, 0]} />
</Canvas>
</div>
</>;
}
if (import.meta.env.DEV) {
const root = createRoot(document.getElementById("root")!);
root.render(<Preview />);
if (import.meta.hot) import.meta.hot.dispose(() => root.unmount());
}
+14 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const content = vi.hoisted(() => ({
usable: false,
@@ -35,13 +35,26 @@ import {
} from "./combatMediaPack";
beforeEach(() => {
vi.stubEnv("DEV", false);
content.usable = false;
content.install.mockReset();
content.remove.mockClear();
vi.unstubAllGlobals();
});
afterEach(() => vi.unstubAllEnvs());
describe("original combat media pack", () => {
it("uses the locally served originals in development without downloading a release pack", async () => {
vi.stubEnv("DEV", true);
const response = new Response("original graph");
const fetch = vi.fn(async () => response);
vi.stubGlobal("fetch", fetch);
await expect(ensureCombatMediaPack()).resolves.toBe(true);
await expect(fetchCombatMediaAsset("/assets/game/combat-effects/manifest.json")).resolves.toBe(response);
expect(content.install).not.toHaveBeenCalled();
});
it("uses the shared verified content-pack installer", async () => {
const progress = vi.fn();
await expect(downloadCombatMediaPack(progress)).resolves.toEqual({ fileCount: 12 });
+3 -1
View File
@@ -80,5 +80,7 @@ export async function removeCombatMediaPack(): Promise<void> {
}
export function combatMediaPackReady(): boolean {
return contentPackIsUsable(COMBAT_MEDIA_CONTENT_PACK_ID);
// Vite serves the complete source media tree, just as it does dungeon assets.
// Shipped builds still require the verified optional content pack.
return import.meta.env.DEV || contentPackIsUsable(COMBAT_MEDIA_CONTENT_PACK_ID);
}
+46
View File
@@ -1,5 +1,8 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import {
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
combatEffectManifestEntry,
@@ -12,6 +15,8 @@ import {
presentationSchoolForAbility,
resetCombatPresentationSession,
subscribeCombatPresentation,
type CombatEffectManifestEntry,
type CombatPresentationPhase,
type CombatPresentationMessage,
} from "./combatPresentation";
@@ -134,4 +139,45 @@ describe("combat presentation", () => {
]);
expect(originalCombatPhaseAssets(entry, "release")).toEqual([]);
});
it("keeps every source-backed instant WoW rank on a renderable phase", () => {
const manifest = JSON.parse(readFileSync(
path.resolve("public/assets/game/combat-effects/manifest.json"),
"utf8",
)) as { readonly entries: readonly CombatEffectManifestEntry[] };
const entriesBySpellId = new Map(manifest.entries
.filter((entry) => entry.source === "wow335")
.map((entry) => [entry.spellId, entry]));
const reachablePhases: readonly CombatPresentationPhase[] = [
"cast-start",
"release",
"impact",
"aura-start",
];
const sourceBackedInstantRanks = Object.entries(WOW335_ABILITIES_BY_CLASS)
.flatMap(([classId, abilities]) => abilities
.flatMap((ability) => (ability.ranks ?? []).map((rank) => ({ ability, classId, rank }))))
.filter(({ rank }) => rank.castMode === "instant")
.flatMap(({ ability, classId, rank }) => {
const entry = entriesBySpellId.get(rank.spellId);
if (!entry) return [];
const hasOriginal = Object.keys(entry.phases ?? {}).some((phase) => (
originalCombatPhaseAssets(entry, phase as CombatPresentationPhase).length > 0
));
return hasOriginal ? [{
abilityId: ability.id,
classId,
spellId: rank.spellId,
entry,
}] : [];
});
const unreachable = sourceBackedInstantRanks.filter(({ entry }) => (
!reachablePhases.some((phase) => originalCombatPhaseAssets(entry, phase).length > 0)
));
expect(unreachable.map(({ abilityId, spellId }) => `${abilityId}:${spellId}`)).toEqual([]);
expect([...new Set(sourceBackedInstantRanks.map(({ classId }) => classId))].sort())
.toEqual(Object.keys(WOW335_ABILITIES_BY_CLASS).sort());
expect(sourceBackedInstantRanks.length).toBeGreaterThan(1_000);
});
});
+4 -1
View File
@@ -124,6 +124,8 @@ export interface CombatPresentationEvent {
readonly targetPosition?: CombatPresentationPosition;
readonly radius?: number;
readonly durationMs?: number;
/** The cast resolved immediately, so its source cast visual may overlap release. */
readonly instantCast?: boolean;
}
export type CombatPresentationEventInput = Omit<CombatPresentationEvent, "id" | "sessionId">;
@@ -175,7 +177,7 @@ export interface CombatEffectPhaseAsset {
readonly scale?: number;
/** Browser-ready graph generated from the original client asset. */
readonly effectGraphUrl?: string;
readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "runewaker-particles";
readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "wow-m2-model" | "runewaker-particles";
readonly originalSourceSha256?: string;
}
@@ -251,6 +253,7 @@ export function originalCombatPhaseAssets(
&& asset.effectGraphUrl.startsWith("/assets/game/combat-effects/original/")
&& (
asset.effectKind === "wow-m2-particles"
|| asset.effectKind === "wow-m2-model"
|| asset.effectKind === "wow-m2-static-mesh"
|| asset.effectKind === "runewaker-particles"
)
+20
View File
@@ -172,6 +172,26 @@ describe("combat store", () => {
unsubscribe();
});
it("marks an instant area resolution for its original source cast phase", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 2 });
registerTarget("instant-presentation-target", [3, 0, 0], 500, 0);
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.abilityId === "mage-frost-nova") {
events.push(message.event);
}
});
expect(useCombatStore.getState().castAbility("mage-frost-nova", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
expect(events.every((event) => event.occurredAt === 1_000)).toBe(true);
expect(events.every((event) => event.instantCast)).toBe(true);
expect(events.every((event) => event.durationMs === undefined)).toBe(true);
unsubscribe();
});
it("identifies the player as the target of a self-cast aura presentation", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const events: CombatPresentationEvent[] = [];
+14 -1
View File
@@ -869,6 +869,7 @@ function emitPlayerAbilityPresentation(
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
instantCast = false,
): void {
const delivery = presentationDeliveryForAbility(ability);
const presentationTargetId = targetId ?? (delivery === "aura" ? PLAYER_AURA_ENTITY_ID : null);
@@ -888,6 +889,7 @@ function emitPlayerAbilityPresentation(
targetPosition: targetPosition ?? origin,
...(ability.radius === undefined ? {} : { radius: ability.radius }),
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
...(instantCast ? { instantCast: true } : {}),
});
}
@@ -903,8 +905,9 @@ function emitPlayerAbilityResolutionPresentation(
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
instantCast: boolean,
): void {
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition);
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition, instantCast);
emitPlayerAbilityPresentation(
ability,
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
@@ -912,6 +915,7 @@ function emitPlayerAbilityResolutionPresentation(
origin,
targetId,
targetPosition,
instantCast,
);
}
@@ -923,6 +927,7 @@ function emitEnemyAttackPresentation(
origin: CombatPosition,
targetActorId: AggroActorId | null,
targetPosition: CombatPosition | null,
instantCast = false,
): void {
emitCombatPresentation({
occurredAt,
@@ -940,6 +945,7 @@ function emitEnemyAttackPresentation(
targetPosition: targetPosition ?? origin,
...(attack.radius === undefined ? {} : { radius: attack.radius }),
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
...(instantCast ? { instantCast: true } : {}),
});
}
@@ -5492,6 +5498,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
playerOrigin,
targetId,
targetPosition,
!completingCast,
);
return {
@@ -6458,6 +6465,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
readonly mob: MobCombatState;
readonly attack: EnemyAttackDefinition;
readonly attackRevision: number;
readonly instantCast: boolean;
}> = [];
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null;
@@ -6657,6 +6665,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|| isPositionInFacingArc(enemySpatial, candidate, Math.PI);
});
const presentationTargetId = targets[0] ?? resolution.targetActorId;
const instantSourceCast = !resolvedEnemyCast && attack.spellId !== undefined;
emitEnemyAttackPresentation(
mob,
attack,
@@ -6665,6 +6674,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
position,
presentationTargetId,
actorPosition(runtimeState, presentationTargetId),
instantSourceCast,
);
if (effect.kind === "damage") {
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
@@ -6681,6 +6691,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
mob,
attack,
attackRevision,
instantCast: instantSourceCast,
});
}
} else if (effect.kind === "heal") {
@@ -6772,6 +6783,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
mob,
attack,
attackRevision,
instantCast: instantSourceCast,
});
}
}
@@ -6949,6 +6961,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
impactOrigin,
hit.actorId,
impactTarget,
hit.instantCast,
);
}
const defendingAuras = aurasForEntity(get().auras, hit.actorId);
+20
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import {
normalizeEnemyAttack,
type EnemyAttackDefinition,
@@ -81,6 +82,25 @@ describe("enemy casting and interrupts", () => {
expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull();
});
it("marks an instant source spell release without publishing a synthetic cast event", () => {
registerCaster([spell("shadow-bolt", "shadow", 0)]);
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === "caster") {
events.push(message.event);
}
});
expect(useCombatStore.getState().advanceMobCombat(1_000)).toBeGreaterThan(0);
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
expect(events[0]).toMatchObject({
sourceSpellId: 686,
instantCast: true,
});
unsubscribe();
});
it("interrupts the active cast and locks only that spell school", () => {
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
registerCaster([
+16
View File
@@ -19,6 +19,7 @@ import {
} from "./partyRuntime";
import { usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
@@ -53,6 +54,10 @@ describe("party combat runtime", () => {
}
const beforeMobHealth = useCombatStore.getState().mobs["pack:a"].health;
const fullPlayerHealth = useCombatStore.getState().health;
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.actorGroup === "party") events.push(message.event);
});
advanceDungeonPartyCombat(1_000);
const party = usePartyStore.getState();
@@ -69,10 +74,21 @@ describe("party combat runtime", () => {
const woundedTank = usePartyStore.getState().members.find((member) => member.id === tank.id)!;
expect(woundedTank.health).toBeLessThan(woundedTank.maxHealth);
expect(useCombatStore.getState().health).toBe(fullPlayerHealth);
const instantReleases = events.filter((event) => event.phase === "release" && event.instantCast);
expect(instantReleases.length).toBeGreaterThan(0);
expect(instantReleases.every((event) => (
events.some((impact) => (
impact.phase === "impact"
&& impact.sourceActorId === event.sourceActorId
&& impact.abilityId === event.abilityId
&& impact.instantCast
))
))).toBe(true);
const actionRevisions = party.members.map((member) => member.nextActionAt);
advanceDungeonPartyCombat(1_100);
expect(usePartyStore.getState().members.map((member) => member.nextActionAt)).toEqual(actionRevisions);
unsubscribe();
});
it("keeps living companions in the fight while the player waits for resurrection", () => {
+14 -3
View File
@@ -613,6 +613,7 @@ function emitPartyAbility(
targetId: string,
phase: "cast-start" | "release" | "impact",
now: number,
instantCast = false,
): void {
const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition;
const targetPosition = targetId === PLAYER_AGGRO_ID
@@ -633,6 +634,7 @@ function emitPartyAbility(
origin,
targetPosition,
...(ability.radius === undefined ? {} : { radius: ability.radius }),
...(instantCast ? { instantCast: true } : {}),
});
}
@@ -842,6 +844,7 @@ function executePartyAbility(
now: number,
members: readonly PartyMember[],
depth = 0,
castStartAlreadyEmitted = false,
): PartyAbilityExecutionResult {
const combat = useCombatStore.getState();
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
@@ -1025,8 +1028,8 @@ function executePartyAbility(
}
}
}
emitPartyAbility(member, ability, targetId, "release", now);
emitPartyAbility(member, ability, targetId, "impact", now);
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount);
}
@@ -1158,7 +1161,15 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
member.activeCast.origin,
);
if (valid) {
const execution = executePartyAbility(completedMember, castingAbility, castTargetId, now, nextMembers);
const execution = executePartyAbility(
completedMember,
castingAbility,
castTargetId,
now,
nextMembers,
0,
true,
);
nextMembers[index] = {
...execution.member,
status: desiredStatus,
+18 -4
View File
@@ -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}
+187 -46
View File
@@ -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
));
+79
View File
@@ -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>;
});
+202
View File
@@ -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>;
});
+48
View File
@@ -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();
});
});
+55
View File
@@ -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);
}
+8 -4
View File
@@ -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 {
+70
View File
@@ -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);
});
});
+203
View File
@@ -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);
}