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
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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