566 lines
23 KiB
TypeScript
566 lines
23 KiB
TypeScript
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;
|
|
readonly filename: string;
|
|
readonly sourcePath: string;
|
|
readonly sourceSha256: string;
|
|
readonly url: string;
|
|
}
|
|
|
|
export interface OriginalCombatMeshLayer {
|
|
readonly submeshIndex: number;
|
|
readonly indexStart: number;
|
|
readonly indexCount: number;
|
|
readonly priorityPlane: number;
|
|
readonly shaderId: number;
|
|
readonly colorIndex: number;
|
|
readonly materialLayer: number;
|
|
readonly materialFlags: number;
|
|
readonly blendMode: number;
|
|
readonly textureIndex: number;
|
|
readonly textureCoordinateIndex: number;
|
|
readonly textureWeightIndex: number;
|
|
readonly textureTransformIndex: number;
|
|
}
|
|
|
|
export interface OriginalCombatMeshGraph {
|
|
readonly schemaVersion: 1;
|
|
readonly kind: "wow-m2-static-mesh";
|
|
readonly source: "wow335";
|
|
readonly sourceModel: string;
|
|
readonly normalizedSourceModel: string;
|
|
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];
|
|
readonly radius: number;
|
|
};
|
|
readonly geometry: {
|
|
readonly positions: readonly number[];
|
|
readonly normals: readonly number[];
|
|
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";
|
|
readonly coordinateSpace: "wow-m2-source";
|
|
readonly staticFrame: "source-bind-pose";
|
|
};
|
|
}
|
|
|
|
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 {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new TypeError(`${label} must be an object.`);
|
|
}
|
|
const record = value as JsonRecord;
|
|
const expected = new Set(keys);
|
|
for (const key of keys) {
|
|
if (!Object.prototype.hasOwnProperty.call(record, key)) throw new TypeError(`${label}.${key} is required.`);
|
|
}
|
|
for (const key of Object.keys(record)) {
|
|
if (!expected.has(key)) throw new TypeError(`${label}.${key} is not part of the static-mesh schema.`);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function numberValue(value: unknown, label: string): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${label} must be finite.`);
|
|
return value;
|
|
}
|
|
|
|
function integerValue(value: unknown, label: string, minimum = 0): number {
|
|
const resolved = numberValue(value, label);
|
|
if (!Number.isSafeInteger(resolved) || resolved < minimum) throw new TypeError(`${label} must be an integer >= ${minimum}.`);
|
|
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;
|
|
}
|
|
|
|
function digest(value: unknown, label: string): string {
|
|
const resolved = stringValue(value, label);
|
|
if (!/^[0-9a-f]{64}$/.test(resolved)) throw new TypeError(`${label} must be a lowercase SHA-256 digest.`);
|
|
return resolved;
|
|
}
|
|
|
|
function literal<T extends string | number>(value: unknown, expected: T, label: string): T {
|
|
if (value !== expected) throw new TypeError(`${label} must be ${JSON.stringify(expected)}.`);
|
|
return expected;
|
|
}
|
|
|
|
function numberArray(value: unknown, label: string): readonly number[] {
|
|
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
|
|
return value.map((entry, index) => numberValue(entry, `${label}[${index}]`));
|
|
}
|
|
|
|
function vector3(value: unknown, label: string): readonly [number, number, number] {
|
|
const resolved = numberArray(value, label);
|
|
if (resolved.length !== 3) throw new TypeError(`${label} must contain three numbers.`);
|
|
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, [
|
|
"type", "flags", "filename", "sourcePath", "sourceSha256", "url",
|
|
]);
|
|
return {
|
|
type: integerValue(record.type, `${label}.type`),
|
|
flags: integerValue(record.flags, `${label}.flags`),
|
|
filename: stringValue(record.filename, `${label}.filename`),
|
|
sourcePath: stringValue(record.sourcePath, `${label}.sourcePath`),
|
|
sourceSha256: digest(record.sourceSha256, `${label}.sourceSha256`),
|
|
url: stringValue(record.url, `${label}.url`),
|
|
};
|
|
}
|
|
|
|
function parseLayer(
|
|
value: unknown,
|
|
index: number,
|
|
indices: readonly number[],
|
|
textureCount: number,
|
|
colorCount: number,
|
|
textureWeightCount: number,
|
|
): OriginalCombatMeshLayer {
|
|
const label = `static mesh.layers[${index}]`;
|
|
const record = exactObject(value, label, [
|
|
"submeshIndex", "indexStart", "indexCount", "priorityPlane", "shaderId", "colorIndex",
|
|
"materialLayer", "materialFlags", "blendMode", "textureIndex", "textureCoordinateIndex",
|
|
"textureWeightIndex", "textureTransformIndex",
|
|
]);
|
|
const indexStart = integerValue(record.indexStart, `${label}.indexStart`);
|
|
const indexCount = integerValue(record.indexCount, `${label}.indexCount`);
|
|
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,
|
|
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`, -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", "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");
|
|
literal(graph.source, "wow335", "static mesh.source");
|
|
const bounds = exactObject(graph.bounds, "static mesh.bounds", ["minimum", "maximum", "radius"]);
|
|
const geometry = exactObject(graph.geometry, "static mesh.geometry", ["positions", "normals", "uvs", "indices"]);
|
|
const positions = numberArray(geometry.positions, "static mesh.geometry.positions");
|
|
const normals = numberArray(geometry.normals, "static mesh.geometry.normals");
|
|
const uvs = numberArray(geometry.uvs, "static mesh.geometry.uvs");
|
|
if (!positions.length || positions.length % 3 !== 0) throw new TypeError("static mesh positions must contain vertices.");
|
|
if (normals.length !== positions.length) throw new TypeError("static mesh normals must match positions.");
|
|
if (uvs.length !== positions.length / 3 * 2) throw new TypeError("static mesh UVs must match positions.");
|
|
if (!Array.isArray(geometry.indices) || !geometry.indices.length) throw new TypeError("static mesh indices must be populated.");
|
|
const vertexCount = positions.length / 3;
|
|
const indices = geometry.indices.map((entry, index) => {
|
|
const resolved = integerValue(entry, `static mesh.geometry.indices[${index}]`);
|
|
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,
|
|
colors.length,
|
|
textureWeights.length,
|
|
));
|
|
const runtime = exactObject(graph.runtime, "static mesh.runtime", [
|
|
"renderer", "coordinateSpace", "staticFrame",
|
|
]);
|
|
literal(runtime.renderer, "wow-m2-static-mesh-v1", "static mesh.runtime.renderer");
|
|
literal(runtime.coordinateSpace, "wow-m2-source", "static mesh.runtime.coordinateSpace");
|
|
literal(runtime.staticFrame, "source-bind-pose", "static mesh.runtime.staticFrame");
|
|
return {
|
|
schemaVersion: 1,
|
|
kind: "wow-m2-static-mesh",
|
|
source: "wow335",
|
|
sourceModel: stringValue(graph.sourceModel, "static mesh.sourceModel"),
|
|
normalizedSourceModel: stringValue(graph.normalizedSourceModel, "static mesh.normalizedSourceModel"),
|
|
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",
|
|
coordinateSpace: "wow-m2-source",
|
|
staticFrame: "source-bind-pose",
|
|
},
|
|
};
|
|
}
|
|
|
|
type OriginalCombatMeshSampleValue = number | readonly [number, number, number] | readonly [number, 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 (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: Pick<OriginalCombatMeshGraph, "globalSequences">,
|
|
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) {
|
|
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);
|
|
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: Pick<OriginalCombatMeshGraph, "animations">,
|
|
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),
|
|
};
|
|
}
|