Release Healer Man 0.1.13
This commit is contained in:
1 parent
65e24f644e
commit
67d7a8ebe7
324 files changed
+917
-48
No files matched your search
@@ -117,6 +117,25 @@ describe("combat store", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("identifies the player as the target of a self-cast aura presentation", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event") events.push(message.event);
|
||||
});
|
||||
|
||||
expect(useCombatStore.getState().castAbility(
|
||||
"wow335-priest-power-word-shield",
|
||||
undefined,
|
||||
1_000,
|
||||
).ok).toBe(true);
|
||||
expect(events.find((event) => (
|
||||
event.abilityId === "wow335-priest-power-word-shield" && event.phase === "aura-start"
|
||||
))?.targetActorId).toBe(PLAYER_AURA_ENTITY_ID);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not cast or bind Priest Holy Concentration", () => {
|
||||
const abilityId = "wow335-priest-holy-concentration";
|
||||
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({
|
||||
|
||||
@@ -834,18 +834,20 @@ function emitPlayerAbilityPresentation(
|
||||
targetId: string | null,
|
||||
targetPosition: CombatPosition | null,
|
||||
): void {
|
||||
const delivery = presentationDeliveryForAbility(ability);
|
||||
const presentationTargetId = targetId ?? (delivery === "aura" ? PLAYER_AURA_ENTITY_ID : null);
|
||||
emitCombatPresentation({
|
||||
occurredAt,
|
||||
phase,
|
||||
actorGroup: "player",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
...(targetId ? { targetActorId: targetId } : {}),
|
||||
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
|
||||
abilityId: ability.id,
|
||||
sourceSpellId: ability.dbcSpellId,
|
||||
name: ability.name,
|
||||
source: presentationSourceForAbility(ability),
|
||||
school: presentationSchoolForAbility(ability),
|
||||
delivery: presentationDeliveryForAbility(ability),
|
||||
delivery,
|
||||
origin,
|
||||
targetPosition: targetPosition ?? origin,
|
||||
...(ability.radius === undefined ? {} : { radius: ability.radius }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
@@ -30,6 +30,7 @@ import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
|
||||
import {
|
||||
interpolateOriginalLifetime,
|
||||
interpolateOriginalScalar,
|
||||
originalCombatAnchorActorId,
|
||||
originalAuraMatchesPresentationEvent,
|
||||
originalEmitterParticleBudget,
|
||||
parseOriginalCombatEffectGraph,
|
||||
@@ -38,6 +39,8 @@ import {
|
||||
type OriginalCombatEffectParticle,
|
||||
} from "./originalCombatEffectRuntime";
|
||||
import {
|
||||
originalCombatMeshLayerAppearance,
|
||||
originalCombatMeshPlaybackFrame,
|
||||
parseOriginalCombatMeshGraph,
|
||||
type OriginalCombatMeshGraph,
|
||||
} from "./originalCombatMeshRuntime";
|
||||
@@ -180,6 +183,18 @@ function effectScale(asset: CombatEffectPhaseAsset): number {
|
||||
: 1;
|
||||
}
|
||||
|
||||
function effectPlacementHeight(
|
||||
asset: CombatEffectPhaseAsset,
|
||||
graph: OriginalSourceEffectGraph,
|
||||
event: CombatPresentationEvent,
|
||||
): number {
|
||||
const fallback = placementHeight(asset.model, event);
|
||||
if (graph.kind !== "wow-m2-static-mesh") return fallback;
|
||||
// The converted mesh remains in WoW's source scale. Lift its lowest Z-up
|
||||
// vertex to the actor's feet after applying the packaged presentation scale.
|
||||
return Math.max(fallback, -graph.bounds.minimum[2] * effectScale(asset));
|
||||
}
|
||||
|
||||
async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
|
||||
const existing = graphRequestCache.get(url);
|
||||
if (existing) return existing;
|
||||
@@ -424,10 +439,12 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
|
||||
graph,
|
||||
textures,
|
||||
active,
|
||||
playbackRef,
|
||||
}: {
|
||||
readonly graph: OriginalCombatMeshGraph;
|
||||
readonly textures: ReadonlyMap<number, Texture>;
|
||||
readonly active: boolean;
|
||||
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
|
||||
}) {
|
||||
const billboardRef = useRef<Group>(null);
|
||||
const geometry = useMemo(() => {
|
||||
@@ -459,16 +476,25 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
|
||||
next.computeBoundingSphere();
|
||||
return next;
|
||||
}, [graph]);
|
||||
const materials = useMemo(() => graph.layers.map((layer) => new MeshBasicMaterial({
|
||||
name: `wow-m2-static-layer-${layer.submeshIndex}`,
|
||||
map: textures.get(layer.textureIndex) ?? null,
|
||||
blending: meshBlendMode(layer.blendMode),
|
||||
transparent: layer.blendMode !== 0,
|
||||
alphaTest: layer.blendMode === 1 ? 0.5 : 0,
|
||||
depthWrite: layer.blendMode === 0,
|
||||
side: DoubleSide,
|
||||
toneMapped: false,
|
||||
})), [graph.layers, textures]);
|
||||
const materials = useMemo(() => {
|
||||
const initialFrame = originalCombatMeshPlaybackFrame(graph, 0);
|
||||
return graph.layers.map((layer) => {
|
||||
const appearance = originalCombatMeshLayerAppearance(graph, layer, initialFrame);
|
||||
const material = new MeshBasicMaterial({
|
||||
name: `wow-m2-static-layer-${layer.submeshIndex}`,
|
||||
map: textures.get(layer.textureIndex) ?? null,
|
||||
blending: meshBlendMode(layer.blendMode),
|
||||
transparent: layer.blendMode !== 0,
|
||||
opacity: appearance.opacity,
|
||||
alphaTest: layer.blendMode === 1 ? 0.5 : 0,
|
||||
depthWrite: layer.blendMode === 0,
|
||||
side: DoubleSide,
|
||||
toneMapped: false,
|
||||
});
|
||||
material.color.setRGB(...appearance.color);
|
||||
return material;
|
||||
});
|
||||
}, [graph, textures]);
|
||||
|
||||
useEffect(() => () => {
|
||||
geometry.dispose();
|
||||
@@ -476,7 +502,18 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
|
||||
}, [geometry, materials]);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
if (active && billboardRef.current) billboardRef.current.lookAt(camera.position);
|
||||
if (!active) return;
|
||||
if (billboardRef.current) billboardRef.current.lookAt(camera.position);
|
||||
const playback = originalCombatMeshPlaybackFrame(
|
||||
graph,
|
||||
playbackRef.current.elapsedMs,
|
||||
playbackRef.current.decaying,
|
||||
);
|
||||
for (const [index, layer] of graph.layers.entries()) {
|
||||
const appearance = originalCombatMeshLayerAppearance(graph, layer, playback);
|
||||
materials[index].color.setRGB(...appearance.color);
|
||||
materials[index].opacity = appearance.opacity;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -496,6 +533,8 @@ function LoadedOriginalCombatEffect({
|
||||
const elapsedRef = useRef(0);
|
||||
const doneRef = useRef(false);
|
||||
const groupRef = useRef<Group>(null);
|
||||
const auraDecayStartedAtRef = useRef<number | null>(null);
|
||||
const staticMeshPlaybackRef = useRef({ elapsedMs: 0, decaying: false });
|
||||
const particleBudgets = useMemo(() => {
|
||||
const emitters = loaded.flatMap((item, loadedIndex) => {
|
||||
const graph = item.graph;
|
||||
@@ -532,6 +571,14 @@ function LoadedOriginalCombatEffect({
|
||||
if (event.phase === "cast-start") return Math.max(120, event.durationMs ?? longestGraph);
|
||||
return Math.max(120, Math.min(6_000, longestGraph));
|
||||
}, [event.durationMs, event.phase, loaded]);
|
||||
const staticMeshDecayDurationMs = useMemo(() => loaded.reduce((maximum, item) => (
|
||||
item.graph.kind === "wow-m2-static-mesh"
|
||||
? Math.max(
|
||||
maximum,
|
||||
item.graph.animations.find((animation) => animation.id === 159)?.durationMs ?? 0,
|
||||
)
|
||||
: maximum
|
||||
), 0), [loaded]);
|
||||
const projectile = event.phase === "release"
|
||||
&& event.delivery === "projectile"
|
||||
&& event.targetPosition !== undefined;
|
||||
@@ -550,6 +597,16 @@ function LoadedOriginalCombatEffect({
|
||||
useFrame((_state, delta) => {
|
||||
if (!active || doneRef.current) return;
|
||||
elapsedRef.current += Math.min(100, Math.max(0, delta * 1_000));
|
||||
if (auraDecayStartedAtRef.current === null) {
|
||||
staticMeshPlaybackRef.current.elapsedMs = elapsedRef.current;
|
||||
staticMeshPlaybackRef.current.decaying = false;
|
||||
} else {
|
||||
staticMeshPlaybackRef.current.elapsedMs = Math.max(
|
||||
0,
|
||||
elapsedRef.current - auraDecayStartedAtRef.current,
|
||||
);
|
||||
staticMeshPlaybackRef.current.decaying = true;
|
||||
}
|
||||
if (groupRef.current) {
|
||||
if (projectile && event.targetPosition) {
|
||||
const progress = Math.min(1, elapsedRef.current / projectileDurationMs);
|
||||
@@ -559,7 +616,7 @@ function LoadedOriginalCombatEffect({
|
||||
event.origin[2] + (event.targetPosition[2] - event.origin[2]) * progress,
|
||||
);
|
||||
} else {
|
||||
const actorId = event.phase === "cast-start" ? event.sourceActorId : event.targetActorId;
|
||||
const actorId = originalCombatAnchorActorId(event);
|
||||
const live = liveActorPosition(actorId);
|
||||
if (live) groupRef.current.position.set(live[0], live[1], live[2]);
|
||||
}
|
||||
@@ -569,9 +626,21 @@ function LoadedOriginalCombatEffect({
|
||||
: event.phase === "aura-start"
|
||||
? auraDurationMs(event)
|
||||
: durationMs;
|
||||
if (auraDecayStartedAtRef.current !== null) {
|
||||
if (staticMeshPlaybackRef.current.elapsedMs < staticMeshDecayDurationMs) return;
|
||||
doneRef.current = true;
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
if (event.phase === "aura-start"
|
||||
&& elapsedRef.current > 350
|
||||
&& !auraStillActive(event)) {
|
||||
if (staticMeshDecayDurationMs > 0) {
|
||||
auraDecayStartedAtRef.current = elapsedRef.current;
|
||||
staticMeshPlaybackRef.current.elapsedMs = 0;
|
||||
staticMeshPlaybackRef.current.decaying = true;
|
||||
return;
|
||||
}
|
||||
doneRef.current = true;
|
||||
onDone();
|
||||
return;
|
||||
@@ -585,14 +654,20 @@ function LoadedOriginalCombatEffect({
|
||||
<group ref={groupRef} position={anchor}>
|
||||
{loaded.map((item, loadedIndex) => {
|
||||
const graph = item.graph;
|
||||
const scale = effectScale(item.asset);
|
||||
return (
|
||||
<group
|
||||
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
|
||||
position={[0, placementHeight(item.asset.model, event), 0]}
|
||||
scale={effectScale(item.asset)}
|
||||
position={[0, effectPlacementHeight(item.asset, graph, event), 0]}
|
||||
scale={scale}
|
||||
>
|
||||
{graph.kind === "wow-m2-static-mesh" ? (
|
||||
<OriginalStaticMesh graph={graph} textures={item.textures} active={active} />
|
||||
<OriginalStaticMesh
|
||||
graph={graph}
|
||||
textures={item.textures}
|
||||
active={active}
|
||||
playbackRef={staticMeshPlaybackRef}
|
||||
/>
|
||||
) : graph.runtime.readyParticleIndices.map((particleIndex) => {
|
||||
const particle = graph.particles[particleIndex];
|
||||
const texture = item.textures.get(particle?.textureIndex ?? -1);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
interpolateOriginalLifetime,
|
||||
interpolateOriginalScalar,
|
||||
isOriginalCombatEffectGraph,
|
||||
originalCombatAnchorActorId,
|
||||
originalAuraMatchesPresentationEvent,
|
||||
originalEmitterParticleBudget,
|
||||
parseOriginalCombatEffectGraph,
|
||||
@@ -136,6 +137,26 @@ function cloneFixture(): Record<string, unknown> {
|
||||
}
|
||||
|
||||
describe("original combat-effect graph contract", () => {
|
||||
it("anchors self-cast aura visuals to the moving player actor", () => {
|
||||
expect(originalCombatAnchorActorId({
|
||||
phase: "aura-start",
|
||||
sourceActorId: "__player__",
|
||||
delivery: "aura",
|
||||
})).toBe("__player__");
|
||||
expect(originalCombatAnchorActorId({
|
||||
phase: "aura-start",
|
||||
sourceActorId: "__player__",
|
||||
targetActorId: "party-tank",
|
||||
delivery: "aura",
|
||||
})).toBe("party-tank");
|
||||
expect(originalCombatAnchorActorId({
|
||||
phase: "cast-start",
|
||||
sourceActorId: "__player__",
|
||||
targetActorId: "party-tank",
|
||||
delivery: "aura",
|
||||
})).toBe("__player__");
|
||||
});
|
||||
|
||||
it("keeps an original aura visual attached to its active ranked WoW aura", () => {
|
||||
const activeShield = {
|
||||
targetId: "party-tank",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CombatVfxMode,
|
||||
CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
|
||||
export interface OriginalCombatAuraIdentity {
|
||||
readonly targetId: string;
|
||||
@@ -12,6 +13,14 @@ export interface OriginalCombatAuraIdentity {
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the live actor that an Original effect should follow after spawn. */
|
||||
export function originalCombatAnchorActorId(
|
||||
event: Pick<CombatPresentationEvent, "phase" | "sourceActorId" | "targetActorId" | "delivery">,
|
||||
): string | undefined {
|
||||
if (event.phase === "cast-start") return event.sourceActorId;
|
||||
return event.targetActorId ?? (event.delivery === "aura" ? PLAYER_AGGRO_ID : undefined);
|
||||
}
|
||||
|
||||
export function originalAuraMatchesPresentationEvent(
|
||||
aura: OriginalCombatAuraIdentity,
|
||||
event: CombatPresentationEvent,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseOriginalCombatMeshGraph } from "./originalCombatMeshRuntime";
|
||||
import {
|
||||
originalCombatMeshLayerAppearance,
|
||||
originalCombatMeshPlaybackFrame,
|
||||
parseOriginalCombatMeshGraph,
|
||||
} from "./originalCombatMeshRuntime";
|
||||
|
||||
const graphUrl = new URL(
|
||||
"../../public/assets/game/combat-effects/original/wow335/priest/graphs/holydivineshield-state-base-83b519d78c12.json",
|
||||
@@ -15,9 +19,64 @@ describe("original combat static-mesh contract", () => {
|
||||
expect(graph.geometry.indices).toHaveLength(18);
|
||||
expect(graph.textures).toHaveLength(3);
|
||||
expect(graph.layers.map((layer) => layer.blendMode)).toEqual([4, 4, 4]);
|
||||
expect(graph.animations.map(({ id, durationMs }) => ({ id, durationMs }))).toEqual([
|
||||
{ id: 0, durationMs: 700 },
|
||||
{ id: 158, durationMs: 633 },
|
||||
{ id: 159, durationMs: 1_100 },
|
||||
]);
|
||||
expect(graph.colors).toHaveLength(3);
|
||||
expect(graph.textureWeights).toHaveLength(3);
|
||||
expect(graph.colors[0].color.sequences[0].values[0]).toEqual([
|
||||
0.9686275124549866,
|
||||
0.9921569228172302,
|
||||
0.3176470696926117,
|
||||
]);
|
||||
expect(graph.textureWeights[1].sequences[1]).toEqual({
|
||||
timestamps: [0, 300, 633],
|
||||
values: [4_915, 1_966, 4_915],
|
||||
});
|
||||
expect(graph.runtime.staticFrame).toBe("source-bind-pose");
|
||||
});
|
||||
|
||||
it("preserves spline tangents for future animated static meshes", () => {
|
||||
const splineGraph = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
|
||||
const colors = splineGraph.colors as Record<string, unknown>[];
|
||||
const color = colors[0].color as Record<string, unknown>;
|
||||
color.interpolation = 2;
|
||||
const sequences = color.sequences as Record<string, unknown>[];
|
||||
sequences[0].values = (sequences[0].values as unknown[]).map((value) => ({
|
||||
value,
|
||||
inTangent: value,
|
||||
outTangent: value,
|
||||
}));
|
||||
|
||||
const parsed = parseOriginalCombatMeshGraph(splineGraph);
|
||||
expect(parsed.colors[0].color.sequences[0].values[0]).toEqual({
|
||||
value: [0.9686275124549866, 0.9921569228172302, 0.3176470696926117],
|
||||
inTangent: [0.9686275124549866, 0.9921569228172302, 0.3176470696926117],
|
||||
outTangent: [0.9686275124549866, 0.9921569228172302, 0.3176470696926117],
|
||||
});
|
||||
});
|
||||
|
||||
it("plays the authentic shield intro, hold loop, and decay weights", () => {
|
||||
const graph = parseOriginalCombatMeshGraph(JSON.parse(readFileSync(graphUrl, "utf8")));
|
||||
const intro = originalCombatMeshPlaybackFrame(graph, 300);
|
||||
expect(intro).toMatchObject({ animationIndex: 0, timeMs: 300, decaying: false });
|
||||
expect(originalCombatMeshLayerAppearance(graph, graph.layers[0], intro).opacity).toBe(1);
|
||||
|
||||
const hold = originalCombatMeshPlaybackFrame(graph, 700 + 533);
|
||||
expect(hold).toMatchObject({ animationIndex: 1, timeMs: 533, decaying: false });
|
||||
expect(originalCombatMeshLayerAppearance(graph, graph.layers[0], hold).opacity)
|
||||
.toBeCloseTo(6_553 / 32_767, 5);
|
||||
expect(originalCombatMeshLayerAppearance(graph, graph.layers[1], hold).color)
|
||||
.toEqual([1, 0.9098039865493774, 0.3176470696926117]);
|
||||
|
||||
const decay = originalCombatMeshPlaybackFrame(graph, 1_000, true);
|
||||
expect(decay).toMatchObject({ animationIndex: 2, timeMs: 1_000, decaying: true });
|
||||
expect(originalCombatMeshLayerAppearance(graph, graph.layers[1], decay).opacity).toBe(0);
|
||||
expect(originalCombatMeshPlaybackFrame(graph, 1_100, true).complete).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects geometry and draw references that leave the packaged source", () => {
|
||||
const invalidIndex = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
|
||||
((invalidIndex.geometry as Record<string, unknown>).indices as number[])[0] = 12;
|
||||
@@ -26,5 +85,15 @@ describe("original combat static-mesh contract", () => {
|
||||
const invalidLayer = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
|
||||
(((invalidLayer.layers as Record<string, unknown>[])[0])).indexCount = 19;
|
||||
expect(() => parseOriginalCombatMeshGraph(invalidLayer)).toThrow(/draw range/);
|
||||
|
||||
const invalidWeight = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
|
||||
(((invalidWeight.layers as Record<string, unknown>[])[0])).textureWeightIndex = 3;
|
||||
expect(() => parseOriginalCombatMeshGraph(invalidWeight)).toThrow(/matching texture weight/);
|
||||
|
||||
const mismatchedTrack = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
|
||||
const weights = mismatchedTrack.textureWeights as Record<string, unknown>[];
|
||||
const sequences = weights[0].sequences as Record<string, unknown>[];
|
||||
(sequences[0].values as unknown[]).pop();
|
||||
expect(() => parseOriginalCombatMeshGraph(mismatchedTrack)).toThrow(/counts must match/);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,38 @@
|
||||
export type OriginalCombatMeshInterpolation = 0 | 1 | 2 | 3;
|
||||
|
||||
export interface OriginalCombatMeshSplineValue<T> {
|
||||
readonly value: T;
|
||||
readonly inTangent: T;
|
||||
readonly outTangent: T;
|
||||
}
|
||||
|
||||
export type OriginalCombatMeshTrackValue<T> = T | OriginalCombatMeshSplineValue<T>;
|
||||
|
||||
export interface OriginalCombatMeshTrackSequence<T> {
|
||||
readonly timestamps: readonly number[];
|
||||
readonly values: readonly OriginalCombatMeshTrackValue<T>[];
|
||||
}
|
||||
|
||||
export interface OriginalCombatMeshTrack<T> {
|
||||
readonly interpolation: OriginalCombatMeshInterpolation;
|
||||
readonly globalSequence: number;
|
||||
readonly sequences: readonly OriginalCombatMeshTrackSequence<T>[];
|
||||
}
|
||||
|
||||
export interface OriginalCombatMeshAnimation {
|
||||
readonly index: number;
|
||||
readonly id: number;
|
||||
readonly variationIndex: number;
|
||||
readonly durationMs: number;
|
||||
readonly moveSpeed: number;
|
||||
readonly flags: number;
|
||||
}
|
||||
|
||||
export interface OriginalCombatMeshColor {
|
||||
readonly color: OriginalCombatMeshTrack<readonly [number, number, number]>;
|
||||
readonly alpha: OriginalCombatMeshTrack<number>;
|
||||
}
|
||||
|
||||
export interface OriginalCombatMeshTexture {
|
||||
readonly type: number;
|
||||
readonly flags: number;
|
||||
@@ -32,6 +67,8 @@ export interface OriginalCombatMeshGraph {
|
||||
readonly sourceSha256: string;
|
||||
readonly sourceSkin: string;
|
||||
readonly sourceSkinSha256: string;
|
||||
readonly globalSequences: readonly number[];
|
||||
readonly animations: readonly OriginalCombatMeshAnimation[];
|
||||
readonly bounds: {
|
||||
readonly minimum: readonly [number, number, number];
|
||||
readonly maximum: readonly [number, number, number];
|
||||
@@ -43,7 +80,9 @@ export interface OriginalCombatMeshGraph {
|
||||
readonly uvs: readonly number[];
|
||||
readonly indices: readonly number[];
|
||||
};
|
||||
readonly colors: readonly OriginalCombatMeshColor[];
|
||||
readonly textures: readonly OriginalCombatMeshTexture[];
|
||||
readonly textureWeights: readonly OriginalCombatMeshTrack<number>[];
|
||||
readonly layers: readonly OriginalCombatMeshLayer[];
|
||||
readonly runtime: {
|
||||
readonly renderer: "wow-m2-static-mesh-v1";
|
||||
@@ -52,6 +91,19 @@ export interface OriginalCombatMeshGraph {
|
||||
};
|
||||
}
|
||||
|
||||
export interface OriginalCombatMeshPlaybackFrame {
|
||||
readonly animationIndex: number;
|
||||
readonly timeMs: number;
|
||||
readonly elapsedMs: number;
|
||||
readonly decaying: boolean;
|
||||
readonly complete: boolean;
|
||||
}
|
||||
|
||||
export interface OriginalCombatMeshLayerAppearance {
|
||||
readonly color: readonly [number, number, number];
|
||||
readonly opacity: number;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function exactObject(value: unknown, label: string, keys: readonly string[]): JsonRecord {
|
||||
@@ -80,6 +132,12 @@ function integerValue(value: unknown, label: string, minimum = 0): number {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function integerInRange(value: unknown, label: string, minimum: number, maximum: number): number {
|
||||
const resolved = integerValue(value, label, minimum);
|
||||
if (resolved > maximum) throw new TypeError(`${label} must be an integer <= ${maximum}.`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new TypeError(`${label} must be a non-empty string.`);
|
||||
return value;
|
||||
@@ -107,6 +165,97 @@ function vector3(value: unknown, label: string): readonly [number, number, numbe
|
||||
return [resolved[0], resolved[1], resolved[2]];
|
||||
}
|
||||
|
||||
type TrackValueParser<T> = (value: unknown, label: string) => T;
|
||||
|
||||
function parseTrackValue<T>(
|
||||
value: unknown,
|
||||
label: string,
|
||||
parseValue: TrackValueParser<T>,
|
||||
spline: boolean,
|
||||
): OriginalCombatMeshTrackValue<T> {
|
||||
if (!spline) return parseValue(value, label);
|
||||
const record = exactObject(value, label, ["value", "inTangent", "outTangent"]);
|
||||
return {
|
||||
value: parseValue(record.value, `${label}.value`),
|
||||
inTangent: parseValue(record.inTangent, `${label}.inTangent`),
|
||||
outTangent: parseValue(record.outTangent, `${label}.outTangent`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTrack<T>(
|
||||
value: unknown,
|
||||
label: string,
|
||||
parseValue: TrackValueParser<T>,
|
||||
globalSequenceCount: number,
|
||||
): OriginalCombatMeshTrack<T> {
|
||||
const record = exactObject(value, label, ["interpolation", "globalSequence", "sequences"]);
|
||||
const interpolation = integerInRange(record.interpolation, `${label}.interpolation`, 0, 3) as OriginalCombatMeshInterpolation;
|
||||
const globalSequence = integerValue(record.globalSequence, `${label}.globalSequence`, -1);
|
||||
if (globalSequence >= globalSequenceCount) {
|
||||
throw new TypeError(`${label}.globalSequence has no matching global sequence.`);
|
||||
}
|
||||
if (!Array.isArray(record.sequences)) throw new TypeError(`${label}.sequences must be an array.`);
|
||||
const spline = interpolation === 2 || interpolation === 3;
|
||||
const sequences = record.sequences.map((sequenceValue, sequenceIndex) => {
|
||||
const sequenceLabel = `${label}.sequences[${sequenceIndex}]`;
|
||||
const sequence = exactObject(sequenceValue, sequenceLabel, ["timestamps", "values"]);
|
||||
if (!Array.isArray(sequence.timestamps)) throw new TypeError(`${sequenceLabel}.timestamps must be an array.`);
|
||||
if (!Array.isArray(sequence.values)) throw new TypeError(`${sequenceLabel}.values must be an array.`);
|
||||
const timestamps = sequence.timestamps.map((timestamp, timestampIndex) => (
|
||||
integerValue(timestamp, `${sequenceLabel}.timestamps[${timestampIndex}]`)
|
||||
));
|
||||
for (let index = 1; index < timestamps.length; index += 1) {
|
||||
if (timestamps[index] < timestamps[index - 1]) {
|
||||
throw new TypeError(`${sequenceLabel}.timestamps must be ordered.`);
|
||||
}
|
||||
}
|
||||
const values = sequence.values.map((trackValue, trackValueIndex) => parseTrackValue(
|
||||
trackValue,
|
||||
`${sequenceLabel}.values[${trackValueIndex}]`,
|
||||
parseValue,
|
||||
spline,
|
||||
));
|
||||
if (timestamps.length !== values.length) {
|
||||
throw new TypeError(`${sequenceLabel} timestamp and value counts must match.`);
|
||||
}
|
||||
return { timestamps, values };
|
||||
});
|
||||
return { interpolation, globalSequence, sequences };
|
||||
}
|
||||
|
||||
function parseAnimation(value: unknown, index: number): OriginalCombatMeshAnimation {
|
||||
const label = `static mesh.animations[${index}]`;
|
||||
const record = exactObject(value, label, [
|
||||
"index", "id", "variationIndex", "durationMs", "moveSpeed", "flags",
|
||||
]);
|
||||
return {
|
||||
index: integerValue(record.index, `${label}.index`),
|
||||
id: integerInRange(record.id, `${label}.id`, 0, 65_535),
|
||||
variationIndex: integerInRange(record.variationIndex, `${label}.variationIndex`, 0, 65_535),
|
||||
durationMs: integerValue(record.durationMs, `${label}.durationMs`),
|
||||
moveSpeed: numberValue(record.moveSpeed, `${label}.moveSpeed`),
|
||||
flags: integerInRange(record.flags, `${label}.flags`, 0, 4_294_967_295),
|
||||
};
|
||||
}
|
||||
|
||||
function parseColor(
|
||||
value: unknown,
|
||||
index: number,
|
||||
globalSequenceCount: number,
|
||||
): OriginalCombatMeshColor {
|
||||
const label = `static mesh.colors[${index}]`;
|
||||
const record = exactObject(value, label, ["color", "alpha"]);
|
||||
return {
|
||||
color: parseTrack(record.color, `${label}.color`, vector3, globalSequenceCount),
|
||||
alpha: parseTrack(
|
||||
record.alpha,
|
||||
`${label}.alpha`,
|
||||
(entry, entryLabel) => integerInRange(entry, entryLabel, -32_768, 32_767),
|
||||
globalSequenceCount,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTexture(value: unknown, index: number): OriginalCombatMeshTexture {
|
||||
const label = `static mesh.textures[${index}]`;
|
||||
const record = exactObject(value, label, [
|
||||
@@ -127,6 +276,8 @@ function parseLayer(
|
||||
index: number,
|
||||
indices: readonly number[],
|
||||
textureCount: number,
|
||||
colorCount: number,
|
||||
textureWeightCount: number,
|
||||
): OriginalCombatMeshLayer {
|
||||
const label = `static mesh.layers[${index}]`;
|
||||
const record = exactObject(value, label, [
|
||||
@@ -139,27 +290,34 @@ function parseLayer(
|
||||
if (indexStart + indexCount > indices.length) throw new TypeError(`${label} draw range exceeds geometry indices.`);
|
||||
const textureIndex = integerValue(record.textureIndex, `${label}.textureIndex`);
|
||||
if (textureIndex >= textureCount) throw new TypeError(`${label}.textureIndex has no matching texture.`);
|
||||
const colorIndex = integerValue(record.colorIndex, `${label}.colorIndex`, -1);
|
||||
if (colorIndex >= colorCount) throw new TypeError(`${label}.colorIndex has no matching color.`);
|
||||
const textureWeightIndex = integerValue(record.textureWeightIndex, `${label}.textureWeightIndex`, -1);
|
||||
if (textureWeightIndex >= textureWeightCount) {
|
||||
throw new TypeError(`${label}.textureWeightIndex has no matching texture weight.`);
|
||||
}
|
||||
return {
|
||||
submeshIndex: integerValue(record.submeshIndex, `${label}.submeshIndex`),
|
||||
indexStart,
|
||||
indexCount,
|
||||
priorityPlane: integerValue(record.priorityPlane, `${label}.priorityPlane`, -128),
|
||||
shaderId: integerValue(record.shaderId, `${label}.shaderId`),
|
||||
colorIndex: integerValue(record.colorIndex, `${label}.colorIndex`, -32_768),
|
||||
colorIndex,
|
||||
materialLayer: integerValue(record.materialLayer, `${label}.materialLayer`),
|
||||
materialFlags: integerValue(record.materialFlags, `${label}.materialFlags`),
|
||||
blendMode: integerValue(record.blendMode, `${label}.blendMode`),
|
||||
textureIndex,
|
||||
textureCoordinateIndex: integerValue(record.textureCoordinateIndex, `${label}.textureCoordinateIndex`, -32_768),
|
||||
textureWeightIndex: integerValue(record.textureWeightIndex, `${label}.textureWeightIndex`, -32_768),
|
||||
textureTransformIndex: integerValue(record.textureTransformIndex, `${label}.textureTransformIndex`, -32_768),
|
||||
textureCoordinateIndex: integerValue(record.textureCoordinateIndex, `${label}.textureCoordinateIndex`, -1),
|
||||
textureWeightIndex,
|
||||
textureTransformIndex: integerValue(record.textureTransformIndex, `${label}.textureTransformIndex`, -1),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOriginalCombatMeshGraph(value: unknown): OriginalCombatMeshGraph {
|
||||
const graph = exactObject(value, "static mesh", [
|
||||
"schemaVersion", "kind", "source", "sourceModel", "normalizedSourceModel", "sourceSha256",
|
||||
"sourceSkin", "sourceSkinSha256", "bounds", "geometry", "textures", "layers", "runtime",
|
||||
"sourceSkin", "sourceSkinSha256", "globalSequences", "animations", "bounds", "geometry",
|
||||
"colors", "textures", "textureWeights", "layers", "runtime",
|
||||
]);
|
||||
literal(graph.schemaVersion, 1, "static mesh.schemaVersion");
|
||||
literal(graph.kind, "wow-m2-static-mesh", "static mesh.kind");
|
||||
@@ -179,10 +337,35 @@ export function parseOriginalCombatMeshGraph(value: unknown): OriginalCombatMesh
|
||||
if (resolved >= vertexCount) throw new TypeError(`static mesh.geometry.indices[${index}] exceeds its vertices.`);
|
||||
return resolved;
|
||||
});
|
||||
if (!Array.isArray(graph.globalSequences)) throw new TypeError("static mesh.globalSequences must be an array.");
|
||||
const globalSequences = graph.globalSequences.map((entry, index) => (
|
||||
integerInRange(entry, `static mesh.globalSequences[${index}]`, 0, 4_294_967_295)
|
||||
));
|
||||
if (!Array.isArray(graph.animations)) throw new TypeError("static mesh.animations must be an array.");
|
||||
const animations = graph.animations.map(parseAnimation);
|
||||
animations.forEach((animation, index) => {
|
||||
if (animation.index !== index) throw new TypeError(`static mesh.animations[${index}].index must equal ${index}.`);
|
||||
});
|
||||
if (!Array.isArray(graph.colors)) throw new TypeError("static mesh.colors must be an array.");
|
||||
const colors = graph.colors.map((color, index) => parseColor(color, index, globalSequences.length));
|
||||
if (!Array.isArray(graph.textures) || !graph.textures.length) throw new TypeError("static mesh textures must be populated.");
|
||||
const textures = graph.textures.map(parseTexture);
|
||||
if (!Array.isArray(graph.textureWeights)) throw new TypeError("static mesh.textureWeights must be an array.");
|
||||
const textureWeights = graph.textureWeights.map((track, index) => parseTrack(
|
||||
track,
|
||||
`static mesh.textureWeights[${index}]`,
|
||||
(entry, entryLabel) => integerInRange(entry, entryLabel, -32_768, 32_767),
|
||||
globalSequences.length,
|
||||
));
|
||||
if (!Array.isArray(graph.layers) || !graph.layers.length) throw new TypeError("static mesh layers must be populated.");
|
||||
const layers = graph.layers.map((layer, index) => parseLayer(layer, index, indices, textures.length));
|
||||
const layers = graph.layers.map((layer, index) => parseLayer(
|
||||
layer,
|
||||
index,
|
||||
indices,
|
||||
textures.length,
|
||||
colors.length,
|
||||
textureWeights.length,
|
||||
));
|
||||
const runtime = exactObject(graph.runtime, "static mesh.runtime", [
|
||||
"renderer", "coordinateSpace", "staticFrame",
|
||||
]);
|
||||
@@ -198,13 +381,17 @@ export function parseOriginalCombatMeshGraph(value: unknown): OriginalCombatMesh
|
||||
sourceSha256: digest(graph.sourceSha256, "static mesh.sourceSha256"),
|
||||
sourceSkin: stringValue(graph.sourceSkin, "static mesh.sourceSkin"),
|
||||
sourceSkinSha256: digest(graph.sourceSkinSha256, "static mesh.sourceSkinSha256"),
|
||||
globalSequences,
|
||||
animations,
|
||||
bounds: {
|
||||
minimum: vector3(bounds.minimum, "static mesh.bounds.minimum"),
|
||||
maximum: vector3(bounds.maximum, "static mesh.bounds.maximum"),
|
||||
radius: numberValue(bounds.radius, "static mesh.bounds.radius"),
|
||||
},
|
||||
geometry: { positions, normals, uvs, indices },
|
||||
colors,
|
||||
textures,
|
||||
textureWeights,
|
||||
layers,
|
||||
runtime: {
|
||||
renderer: "wow-m2-static-mesh-v1",
|
||||
@@ -213,3 +400,162 @@ export function parseOriginalCombatMeshGraph(value: unknown): OriginalCombatMesh
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type OriginalCombatMeshSampleValue = number | readonly [number, number, number];
|
||||
|
||||
function baseTrackValue<T extends OriginalCombatMeshSampleValue>(
|
||||
value: OriginalCombatMeshTrackValue<T>,
|
||||
): T {
|
||||
return typeof value === "object" && !Array.isArray(value) && "value" in value
|
||||
? value.value
|
||||
: value as T;
|
||||
}
|
||||
|
||||
function splineTrackValue<T extends OriginalCombatMeshSampleValue>(
|
||||
value: OriginalCombatMeshTrackValue<T>,
|
||||
): OriginalCombatMeshSplineValue<T> | null {
|
||||
return typeof value === "object" && !Array.isArray(value) && "value" in value
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function combineTrackValues<T extends OriginalCombatMeshSampleValue>(
|
||||
values: readonly T[],
|
||||
coefficients: readonly number[],
|
||||
): T {
|
||||
if (typeof values[0] === "number") {
|
||||
return values.reduce<number>((total, value, index) => (
|
||||
total + (value as number) * coefficients[index]
|
||||
), 0) as T;
|
||||
}
|
||||
return [0, 1, 2].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,
|
||||
track: OriginalCombatMeshTrack<T>,
|
||||
frame: OriginalCombatMeshPlaybackFrame,
|
||||
): T | undefined {
|
||||
const globalDuration = track.globalSequence >= 0
|
||||
? graph.globalSequences[track.globalSequence] ?? 0
|
||||
: 0;
|
||||
const preferredIndex = globalDuration > 0 ? 0 : frame.animationIndex;
|
||||
const sequence = track.sequences[preferredIndex]?.values.length
|
||||
? track.sequences[preferredIndex]
|
||||
: track.sequences.find((candidate) => candidate.values.length > 0);
|
||||
if (!sequence?.values.length) return undefined;
|
||||
const timeMs = globalDuration > 0 ? frame.elapsedMs % globalDuration : frame.timeMs;
|
||||
if (sequence.values.length === 1 || timeMs <= sequence.timestamps[0]) {
|
||||
return baseTrackValue(sequence.values[0]);
|
||||
}
|
||||
const lastIndex = sequence.values.length - 1;
|
||||
if (timeMs >= sequence.timestamps[lastIndex]) return baseTrackValue(sequence.values[lastIndex]);
|
||||
let upperIndex = 1;
|
||||
while (upperIndex < sequence.timestamps.length && timeMs >= sequence.timestamps[upperIndex]) {
|
||||
upperIndex += 1;
|
||||
}
|
||||
const lowerIndex = upperIndex - 1;
|
||||
const lowerTime = sequence.timestamps[lowerIndex];
|
||||
const upperTime = sequence.timestamps[upperIndex];
|
||||
const progress = upperTime > lowerTime ? (timeMs - lowerTime) / (upperTime - lowerTime) : 0;
|
||||
const lower = sequence.values[lowerIndex];
|
||||
const upper = sequence.values[upperIndex];
|
||||
const lowerValue = baseTrackValue(lower);
|
||||
const upperValue = baseTrackValue(upper);
|
||||
if (track.interpolation === 0) return lowerValue;
|
||||
if (track.interpolation === 1) {
|
||||
return combineTrackValues([lowerValue, upperValue], [1 - progress, progress]);
|
||||
}
|
||||
const lowerSpline = splineTrackValue(lower);
|
||||
const upperSpline = splineTrackValue(upper);
|
||||
if (!lowerSpline || !upperSpline) return lowerValue;
|
||||
if (track.interpolation === 2) {
|
||||
const p2 = progress * progress;
|
||||
const p3 = p2 * progress;
|
||||
return combineTrackValues(
|
||||
[lowerSpline.value, lowerSpline.outTangent, upperSpline.value, upperSpline.inTangent],
|
||||
[2 * p3 - 3 * p2 + 1, p3 - 2 * p2 + progress, -2 * p3 + 3 * p2, p3 - p2],
|
||||
);
|
||||
}
|
||||
const inverse = 1 - progress;
|
||||
return combineTrackValues(
|
||||
[lowerSpline.value, lowerSpline.outTangent, upperSpline.inTangent, upperSpline.value],
|
||||
[inverse ** 3, 3 * inverse * inverse * progress, 3 * inverse * progress * progress, progress ** 3],
|
||||
);
|
||||
}
|
||||
|
||||
/** Select Stand once, Hold in a loop, or Decay once from the source M2. */
|
||||
export function originalCombatMeshPlaybackFrame(
|
||||
graph: OriginalCombatMeshGraph,
|
||||
elapsedMs: number,
|
||||
decaying = false,
|
||||
): OriginalCombatMeshPlaybackFrame {
|
||||
const safeElapsed = Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0);
|
||||
const stand = graph.animations.find((animation) => animation.id === 0) ?? graph.animations[0];
|
||||
const hold = graph.animations.find((animation) => animation.id === 158);
|
||||
const decay = graph.animations.find((animation) => animation.id === 159);
|
||||
if (decaying && decay) {
|
||||
return {
|
||||
animationIndex: decay.index,
|
||||
timeMs: Math.min(safeElapsed, decay.durationMs),
|
||||
elapsedMs: safeElapsed,
|
||||
decaying: true,
|
||||
complete: safeElapsed >= decay.durationMs,
|
||||
};
|
||||
}
|
||||
if (stand && safeElapsed < stand.durationMs) {
|
||||
return {
|
||||
animationIndex: stand.index,
|
||||
timeMs: safeElapsed,
|
||||
elapsedMs: safeElapsed,
|
||||
decaying: false,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (hold) {
|
||||
const holdElapsed = Math.max(0, safeElapsed - (stand?.durationMs ?? 0));
|
||||
return {
|
||||
animationIndex: hold.index,
|
||||
timeMs: hold.durationMs > 0 ? holdElapsed % hold.durationMs : 0,
|
||||
elapsedMs: safeElapsed,
|
||||
decaying: false,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
animationIndex: stand?.index ?? 0,
|
||||
timeMs: stand ? Math.min(safeElapsed, stand.durationMs) : 0,
|
||||
elapsedMs: safeElapsed,
|
||||
decaying: false,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the source-authentic material color and animated layer weight. */
|
||||
export function originalCombatMeshLayerAppearance(
|
||||
graph: OriginalCombatMeshGraph,
|
||||
layer: OriginalCombatMeshLayer,
|
||||
frame: OriginalCombatMeshPlaybackFrame,
|
||||
): OriginalCombatMeshLayerAppearance {
|
||||
const colorTrack = layer.colorIndex >= 0 ? graph.colors[layer.colorIndex] : undefined;
|
||||
const color = colorTrack
|
||||
? sampleOriginalCombatMeshTrack(graph, colorTrack.color, frame) ?? [1, 1, 1]
|
||||
: [1, 1, 1] as const;
|
||||
const alpha = colorTrack
|
||||
? sampleOriginalCombatMeshTrack(graph, colorTrack.alpha, frame) ?? 32_767
|
||||
: 32_767;
|
||||
const weightTrack = layer.textureWeightIndex >= 0
|
||||
? graph.textureWeights[layer.textureWeightIndex]
|
||||
: undefined;
|
||||
const weight = weightTrack
|
||||
? sampleOriginalCombatMeshTrack(graph, weightTrack, frame) ?? 32_767
|
||||
: 32_767;
|
||||
const normalized = (value: number) => Math.max(0, Math.min(1, value / 32_767));
|
||||
return {
|
||||
color,
|
||||
opacity: normalized(alpha) * normalized(weight),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user