forked from phenom/healer-man
685 lines
25 KiB
TypeScript
685 lines
25 KiB
TypeScript
import type {
|
|
CombatPresentationEvent,
|
|
CombatVfxMode,
|
|
CombatVfxQuality,
|
|
} from "../game/combatPresentation";
|
|
|
|
export interface OriginalCombatAuraIdentity {
|
|
readonly targetId: string;
|
|
readonly definition: {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
};
|
|
}
|
|
|
|
export function originalAuraMatchesPresentationEvent(
|
|
aura: OriginalCombatAuraIdentity,
|
|
event: CombatPresentationEvent,
|
|
): boolean {
|
|
if (aura.targetId !== event.targetActorId) return false;
|
|
if (event.sourceSpellId !== undefined
|
|
&& aura.definition.id.startsWith(`wow335:${event.sourceSpellId}:`)) return true;
|
|
return aura.definition.id.includes(event.abilityId)
|
|
|| aura.definition.name === event.name
|
|
|| aura.definition.name.startsWith(`${event.name} (`);
|
|
}
|
|
|
|
export type OriginalCombatEffectVector2 = readonly [number, number];
|
|
export type OriginalCombatEffectVector3 = readonly [number, number, number];
|
|
|
|
export interface OriginalCombatEffectTimeSample<T> {
|
|
readonly timeMs: number;
|
|
readonly value: T;
|
|
}
|
|
|
|
export interface OriginalCombatEffectLifetimeSample<T> {
|
|
readonly normalizedTime: number;
|
|
readonly value: T;
|
|
}
|
|
|
|
export type OriginalCombatEffectBlend =
|
|
| "opaque"
|
|
| "alpha-key"
|
|
| "alpha"
|
|
| "no-alpha-add"
|
|
| "add"
|
|
| "mod"
|
|
| "mod-2x"
|
|
| "blend-add";
|
|
|
|
export type OriginalCombatEffectEmitter = "none" | "plane" | "sphere" | "spline" | "bone";
|
|
|
|
export interface OriginalCombatEffectAtlas {
|
|
readonly rows: number;
|
|
readonly columns: number;
|
|
readonly rotation: number;
|
|
}
|
|
|
|
export interface OriginalCombatEffectTexture {
|
|
readonly type: number;
|
|
readonly flags: number;
|
|
readonly filename: string;
|
|
readonly sourcePath?: string;
|
|
readonly sourceSha256?: string;
|
|
readonly url?: string;
|
|
}
|
|
|
|
export interface OriginalCombatEffectAnimation {
|
|
readonly index: number;
|
|
readonly id: number;
|
|
readonly variationIndex: number;
|
|
readonly durationMs: number;
|
|
readonly moveSpeed: number;
|
|
readonly flags: number;
|
|
}
|
|
|
|
export interface OriginalCombatEffectParticleSamples {
|
|
readonly emissionSpeed: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly speedVariation: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly verticalRange: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly horizontalRange: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly gravity: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly lifespan: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly emissionRate: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly emissionAreaLength: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly emissionAreaWidth: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly zSource: readonly OriginalCombatEffectTimeSample<number>[];
|
|
readonly color: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector3>[];
|
|
readonly alpha: readonly OriginalCombatEffectLifetimeSample<number>[];
|
|
readonly scale: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector2>[];
|
|
readonly headCell: readonly OriginalCombatEffectLifetimeSample<number>[];
|
|
readonly tailCell: readonly OriginalCombatEffectLifetimeSample<number>[];
|
|
readonly enabled: readonly OriginalCombatEffectTimeSample<number>[];
|
|
}
|
|
|
|
export type OriginalCombatEffectInterpolationMode = 0 | 1 | 2 | 3;
|
|
export type OriginalCombatEffectAnimatedTrackName =
|
|
| "emissionSpeed"
|
|
| "speedVariation"
|
|
| "verticalRange"
|
|
| "horizontalRange"
|
|
| "gravity"
|
|
| "lifespan"
|
|
| "emissionRate"
|
|
| "emissionAreaLength"
|
|
| "emissionAreaWidth"
|
|
| "zSource"
|
|
| "enabled";
|
|
|
|
export interface OriginalCombatEffectParticle {
|
|
readonly id: number;
|
|
readonly flags: number;
|
|
readonly position: OriginalCombatEffectVector3;
|
|
readonly parentBone: number;
|
|
readonly textureIndex: number;
|
|
readonly geometryModel: string;
|
|
readonly recursionModel: string;
|
|
readonly blendMode: number;
|
|
readonly emitterType: number;
|
|
readonly particleColorIndex: number;
|
|
readonly particleType: number;
|
|
readonly headOrTail: number;
|
|
readonly textureTileRotation: number;
|
|
readonly textureRows: number;
|
|
readonly textureColumns: number;
|
|
readonly lifespanVariation: number;
|
|
readonly emissionRateVariation: number;
|
|
readonly scaleVariation: OriginalCombatEffectVector2;
|
|
readonly tailLength: number;
|
|
readonly interpolation: Readonly<Record<
|
|
OriginalCombatEffectAnimatedTrackName,
|
|
OriginalCombatEffectInterpolationMode
|
|
>>;
|
|
readonly globalSequence: Readonly<Record<OriginalCombatEffectAnimatedTrackName, number>>;
|
|
readonly samples: OriginalCombatEffectParticleSamples;
|
|
readonly blend: OriginalCombatEffectBlend;
|
|
readonly emitter: OriginalCombatEffectEmitter;
|
|
readonly atlas: OriginalCombatEffectAtlas;
|
|
}
|
|
|
|
export interface OriginalCombatEffectRuntimeMetadata {
|
|
readonly renderer: "wow-m2-particles-v1";
|
|
readonly readyParticleIndices: readonly number[];
|
|
readonly unsupportedRibbonCount: number;
|
|
readonly unsupportedGeometryVertexCount: number;
|
|
}
|
|
|
|
export interface OriginalCombatEffectGraph {
|
|
readonly schemaVersion: 1;
|
|
readonly kind: "wow-m2-particle-effect";
|
|
readonly source: "wow335";
|
|
readonly sourceModel: string;
|
|
readonly normalizedSourceModel: string;
|
|
readonly sourceSha256: string;
|
|
readonly durationMs: number;
|
|
readonly animations: readonly OriginalCombatEffectAnimation[];
|
|
readonly textures: readonly OriginalCombatEffectTexture[];
|
|
readonly particles: readonly OriginalCombatEffectParticle[];
|
|
readonly runtime: OriginalCombatEffectRuntimeMetadata;
|
|
}
|
|
|
|
const PARTICLE_TIME_SAMPLE_KEYS = [
|
|
"emissionSpeed",
|
|
"speedVariation",
|
|
"verticalRange",
|
|
"horizontalRange",
|
|
"gravity",
|
|
"lifespan",
|
|
"emissionRate",
|
|
"emissionAreaLength",
|
|
"emissionAreaWidth",
|
|
"zSource",
|
|
] as const;
|
|
|
|
const PARTICLE_LIFETIME_SAMPLE_KEYS = ["color", "alpha", "scale"] as const;
|
|
const PARTICLE_ANIMATED_TRACK_KEYS = [...PARTICLE_TIME_SAMPLE_KEYS, "enabled"] as const;
|
|
|
|
const BLEND_BY_CODE: readonly OriginalCombatEffectBlend[] = [
|
|
"opaque",
|
|
"alpha-key",
|
|
"alpha",
|
|
"no-alpha-add",
|
|
"add",
|
|
"mod",
|
|
"mod-2x",
|
|
"blend-add",
|
|
];
|
|
|
|
const EMITTER_BY_CODE: readonly OriginalCombatEffectEmitter[] = [
|
|
"none",
|
|
"plane",
|
|
"sphere",
|
|
"spline",
|
|
"bone",
|
|
];
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type InterpolatedValue = number | OriginalCombatEffectVector2 | OriginalCombatEffectVector3;
|
|
|
|
function exactObject(
|
|
value: unknown,
|
|
label: string,
|
|
requiredKeys: readonly string[],
|
|
optionalKeys: readonly string[] = [],
|
|
): JsonRecord {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new TypeError(`${label} must be an object.`);
|
|
}
|
|
const prototype = Object.getPrototypeOf(value);
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
throw new TypeError(`${label} must be a plain JSON object.`);
|
|
}
|
|
const record = value as JsonRecord;
|
|
const allowed = new Set([...requiredKeys, ...optionalKeys]);
|
|
for (const key of requiredKeys) {
|
|
if (!Object.prototype.hasOwnProperty.call(record, key)) {
|
|
throw new TypeError(`${label}.${key} is required.`);
|
|
}
|
|
}
|
|
for (const key of Object.keys(record)) {
|
|
if (!allowed.has(key)) throw new TypeError(`${label}.${key} is not part of schema version 1.`);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function finiteNumber(value: unknown, label: string): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
throw new TypeError(`${label} must be a finite number.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function integerInRange(value: unknown, label: string, minimum: number, maximum: number): number {
|
|
const parsed = finiteNumber(value, label);
|
|
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
throw new TypeError(`${label} must be an integer from ${minimum} through ${maximum}.`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function literalString<T extends string>(value: unknown, expected: T, label: string): T {
|
|
if (value !== expected) throw new TypeError(`${label} must be ${JSON.stringify(expected)}.`);
|
|
return expected;
|
|
}
|
|
|
|
function stringValue(value: unknown, label: string, allowEmpty = true): string {
|
|
if (typeof value !== "string" || (!allowEmpty && value.trim().length === 0)) {
|
|
throw new TypeError(`${label} must be ${allowEmpty ? "a string" : "a non-empty string"}.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function sha256(value: unknown, label: string): string {
|
|
const digest = stringValue(value, label, false);
|
|
if (!/^[0-9a-f]{64}$/.test(digest)) {
|
|
throw new TypeError(`${label} must be a lowercase SHA-256 digest.`);
|
|
}
|
|
return digest;
|
|
}
|
|
|
|
function vector2(value: unknown, label: string): OriginalCombatEffectVector2 {
|
|
if (!Array.isArray(value) || value.length !== 2) {
|
|
throw new TypeError(`${label} must contain exactly two numbers.`);
|
|
}
|
|
return [finiteNumber(value[0], `${label}[0]`), finiteNumber(value[1], `${label}[1]`)];
|
|
}
|
|
|
|
function vector3(value: unknown, label: string): OriginalCombatEffectVector3 {
|
|
if (!Array.isArray(value) || value.length !== 3) {
|
|
throw new TypeError(`${label} must contain exactly three numbers.`);
|
|
}
|
|
return [
|
|
finiteNumber(value[0], `${label}[0]`),
|
|
finiteNumber(value[1], `${label}[1]`),
|
|
finiteNumber(value[2], `${label}[2]`),
|
|
];
|
|
}
|
|
|
|
function parseTimeSamples(value: unknown, label: string): readonly OriginalCombatEffectTimeSample<number>[] {
|
|
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
|
|
let previousTime = -Infinity;
|
|
return value.map((entry, index) => {
|
|
const sampleLabel = `${label}[${index}]`;
|
|
const sample = exactObject(entry, sampleLabel, ["timeMs", "value"]);
|
|
const timeMs = finiteNumber(sample.timeMs, `${sampleLabel}.timeMs`);
|
|
if (timeMs < 0 || timeMs < previousTime) {
|
|
throw new TypeError(`${sampleLabel}.timeMs must be non-negative and ordered.`);
|
|
}
|
|
previousTime = timeMs;
|
|
return { timeMs, value: finiteNumber(sample.value, `${sampleLabel}.value`) };
|
|
});
|
|
}
|
|
|
|
function parseLifetimeSamples<T extends InterpolatedValue>(
|
|
value: unknown,
|
|
label: string,
|
|
parseValue: (raw: unknown, valueLabel: string) => T,
|
|
): readonly OriginalCombatEffectLifetimeSample<T>[] {
|
|
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
|
|
let previousTime = -Infinity;
|
|
return value.map((entry, index) => {
|
|
const sampleLabel = `${label}[${index}]`;
|
|
const sample = exactObject(entry, sampleLabel, ["normalizedTime", "value"], ["timestamp"]);
|
|
const normalizedTime = finiteNumber(sample.normalizedTime, `${sampleLabel}.normalizedTime`);
|
|
if (normalizedTime < 0 || normalizedTime > 1 || normalizedTime < previousTime) {
|
|
throw new TypeError(`${sampleLabel}.normalizedTime must be ordered from zero through one.`);
|
|
}
|
|
previousTime = normalizedTime;
|
|
if (sample.timestamp !== undefined) {
|
|
const timestamp = integerInRange(sample.timestamp, `${sampleLabel}.timestamp`, 0, 32_767);
|
|
if (Math.abs(normalizedTime - timestamp / 32_767) > 1e-9) {
|
|
throw new TypeError(`${sampleLabel}.normalizedTime does not match its timestamp.`);
|
|
}
|
|
}
|
|
return {
|
|
normalizedTime,
|
|
value: parseValue(sample.value, `${sampleLabel}.value`),
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseAnimation(value: unknown, index: number): OriginalCombatEffectAnimation {
|
|
const label = `effect graph.animations[${index}]`;
|
|
const record = exactObject(value, label, ["index", "id", "variationIndex", "durationMs", "moveSpeed", "flags"]);
|
|
return {
|
|
index: integerInRange(record.index, `${label}.index`, 0, 0xffff_ffff),
|
|
id: integerInRange(record.id, `${label}.id`, 0, 0xffff),
|
|
variationIndex: integerInRange(record.variationIndex, `${label}.variationIndex`, 0, 0xffff),
|
|
durationMs: integerInRange(record.durationMs, `${label}.durationMs`, 0, 0xffff_ffff),
|
|
moveSpeed: finiteNumber(record.moveSpeed, `${label}.moveSpeed`),
|
|
flags: integerInRange(record.flags, `${label}.flags`, 0, 0xffff_ffff),
|
|
};
|
|
}
|
|
|
|
function parseTexture(value: unknown, index: number): OriginalCombatEffectTexture {
|
|
const label = `effect graph.textures[${index}]`;
|
|
const record = exactObject(
|
|
value,
|
|
label,
|
|
["type", "flags", "filename"],
|
|
["sourcePath", "sourceSha256", "url"],
|
|
);
|
|
const texture: OriginalCombatEffectTexture = {
|
|
type: integerInRange(record.type, `${label}.type`, 0, 0xffff_ffff),
|
|
flags: integerInRange(record.flags, `${label}.flags`, 0, 0xffff_ffff),
|
|
filename: stringValue(record.filename, `${label}.filename`),
|
|
};
|
|
const hasSourcePath = record.sourcePath !== undefined;
|
|
const hasDigest = record.sourceSha256 !== undefined;
|
|
const hasUrl = record.url !== undefined;
|
|
if (hasDigest !== hasUrl || ((hasDigest || hasUrl) && !hasSourcePath)) {
|
|
throw new TypeError(`${label} packaged texture metadata must be complete.`);
|
|
}
|
|
if (!hasSourcePath) return texture;
|
|
const locatedTexture: OriginalCombatEffectTexture = {
|
|
...texture,
|
|
sourcePath: stringValue(record.sourcePath, `${label}.sourcePath`, false),
|
|
};
|
|
if (!hasDigest) return locatedTexture;
|
|
return {
|
|
...locatedTexture,
|
|
sourceSha256: sha256(record.sourceSha256, `${label}.sourceSha256`),
|
|
url: stringValue(record.url, `${label}.url`, false),
|
|
};
|
|
}
|
|
|
|
function parseParticleSamples(value: unknown, label: string): OriginalCombatEffectParticleSamples {
|
|
const record = exactObject(value, label, [
|
|
...PARTICLE_TIME_SAMPLE_KEYS,
|
|
...PARTICLE_LIFETIME_SAMPLE_KEYS,
|
|
"headCell",
|
|
"tailCell",
|
|
"enabled",
|
|
]);
|
|
const timeSamples = Object.fromEntries(PARTICLE_TIME_SAMPLE_KEYS.map((key) => [
|
|
key,
|
|
parseTimeSamples(record[key], `${label}.${key}`),
|
|
])) as unknown as Pick<OriginalCombatEffectParticleSamples, typeof PARTICLE_TIME_SAMPLE_KEYS[number]>;
|
|
return {
|
|
...timeSamples,
|
|
color: parseLifetimeSamples(record.color, `${label}.color`, vector3),
|
|
alpha: parseLifetimeSamples(record.alpha, `${label}.alpha`, finiteNumber),
|
|
scale: parseLifetimeSamples(record.scale, `${label}.scale`, vector2),
|
|
headCell: parseLifetimeSamples(record.headCell, `${label}.headCell`, finiteNumber),
|
|
tailCell: parseLifetimeSamples(record.tailCell, `${label}.tailCell`, finiteNumber),
|
|
enabled: parseTimeSamples(record.enabled, `${label}.enabled`),
|
|
};
|
|
}
|
|
|
|
function parseParticleTrackMetadata(
|
|
value: unknown,
|
|
label: string,
|
|
minimum: number,
|
|
maximum: number,
|
|
): Readonly<Record<OriginalCombatEffectAnimatedTrackName, number>> {
|
|
const record = exactObject(value, label, PARTICLE_ANIMATED_TRACK_KEYS);
|
|
return Object.fromEntries(PARTICLE_ANIMATED_TRACK_KEYS.map((key) => [
|
|
key,
|
|
integerInRange(record[key], `${label}.${key}`, minimum, maximum),
|
|
])) as Readonly<Record<OriginalCombatEffectAnimatedTrackName, number>>;
|
|
}
|
|
|
|
function parseParticle(
|
|
value: unknown,
|
|
index: number,
|
|
textureCount: number,
|
|
): OriginalCombatEffectParticle {
|
|
const label = `effect graph.particles[${index}]`;
|
|
const record = exactObject(value, label, [
|
|
"id",
|
|
"flags",
|
|
"position",
|
|
"parentBone",
|
|
"textureIndex",
|
|
"geometryModel",
|
|
"recursionModel",
|
|
"blendMode",
|
|
"emitterType",
|
|
"particleColorIndex",
|
|
"particleType",
|
|
"headOrTail",
|
|
"textureTileRotation",
|
|
"textureRows",
|
|
"textureColumns",
|
|
"lifespanVariation",
|
|
"emissionRateVariation",
|
|
"scaleVariation",
|
|
"tailLength",
|
|
"interpolation",
|
|
"globalSequence",
|
|
"samples",
|
|
]);
|
|
const textureIndex = integerInRange(record.textureIndex, `${label}.textureIndex`, 0, 0xffff);
|
|
if (textureIndex >= textureCount) throw new TypeError(`${label}.textureIndex has no matching texture.`);
|
|
const blendMode = integerInRange(record.blendMode, `${label}.blendMode`, 0, BLEND_BY_CODE.length - 1);
|
|
const emitterType = integerInRange(record.emitterType, `${label}.emitterType`, 0, EMITTER_BY_CODE.length - 1);
|
|
const textureRows = integerInRange(record.textureRows, `${label}.textureRows`, 1, 0xffff);
|
|
const textureColumns = integerInRange(record.textureColumns, `${label}.textureColumns`, 1, 0xffff);
|
|
const textureTileRotation = integerInRange(record.textureTileRotation, `${label}.textureTileRotation`, 0, 0xffff);
|
|
return {
|
|
id: integerInRange(record.id, `${label}.id`, 0, 0xffff_ffff),
|
|
flags: integerInRange(record.flags, `${label}.flags`, 0, 0xffff_ffff),
|
|
position: vector3(record.position, `${label}.position`),
|
|
parentBone: integerInRange(record.parentBone, `${label}.parentBone`, 0, 0xffff),
|
|
textureIndex,
|
|
geometryModel: stringValue(record.geometryModel, `${label}.geometryModel`),
|
|
recursionModel: stringValue(record.recursionModel, `${label}.recursionModel`),
|
|
blendMode,
|
|
emitterType,
|
|
particleColorIndex: integerInRange(record.particleColorIndex, `${label}.particleColorIndex`, 0, 0xffff),
|
|
particleType: integerInRange(record.particleType, `${label}.particleType`, 0, 0xff),
|
|
headOrTail: integerInRange(record.headOrTail, `${label}.headOrTail`, 0, 0xff),
|
|
textureTileRotation,
|
|
textureRows,
|
|
textureColumns,
|
|
lifespanVariation: finiteNumber(record.lifespanVariation, `${label}.lifespanVariation`),
|
|
emissionRateVariation: finiteNumber(record.emissionRateVariation, `${label}.emissionRateVariation`),
|
|
scaleVariation: vector2(record.scaleVariation, `${label}.scaleVariation`),
|
|
tailLength: finiteNumber(record.tailLength, `${label}.tailLength`),
|
|
interpolation: parseParticleTrackMetadata(
|
|
record.interpolation,
|
|
`${label}.interpolation`,
|
|
0,
|
|
3,
|
|
) as OriginalCombatEffectParticle["interpolation"],
|
|
globalSequence: parseParticleTrackMetadata(
|
|
record.globalSequence,
|
|
`${label}.globalSequence`,
|
|
-32_768,
|
|
32_767,
|
|
),
|
|
samples: parseParticleSamples(record.samples, `${label}.samples`),
|
|
blend: BLEND_BY_CODE[blendMode],
|
|
emitter: EMITTER_BY_CODE[emitterType],
|
|
atlas: { rows: textureRows, columns: textureColumns, rotation: textureTileRotation },
|
|
};
|
|
}
|
|
|
|
function parseRuntime(
|
|
value: unknown,
|
|
particles: readonly OriginalCombatEffectParticle[],
|
|
textures: readonly OriginalCombatEffectTexture[],
|
|
): OriginalCombatEffectRuntimeMetadata {
|
|
const label = "effect graph.runtime";
|
|
const record = exactObject(value, label, [
|
|
"renderer",
|
|
"readyParticleIndices",
|
|
"unsupportedRibbonCount",
|
|
"unsupportedGeometryVertexCount",
|
|
]);
|
|
literalString(record.renderer, "wow-m2-particles-v1", `${label}.renderer`);
|
|
if (!Array.isArray(record.readyParticleIndices)) {
|
|
throw new TypeError(`${label}.readyParticleIndices must be an array.`);
|
|
}
|
|
let previousIndex = -1;
|
|
const readyParticleIndices = record.readyParticleIndices.map((entry, index) => {
|
|
const particleIndex = integerInRange(entry, `${label}.readyParticleIndices[${index}]`, 0, 0xffff_ffff);
|
|
if (particleIndex >= particles.length) {
|
|
throw new TypeError(`${label}.readyParticleIndices[${index}] has no matching particle.`);
|
|
}
|
|
if (particleIndex <= previousIndex) {
|
|
throw new TypeError(`${label}.readyParticleIndices must be unique and ordered.`);
|
|
}
|
|
previousIndex = particleIndex;
|
|
const texture = textures[particles[particleIndex].textureIndex];
|
|
if (!texture?.url) {
|
|
throw new TypeError(`${label}.readyParticleIndices[${index}] references an unpackaged texture.`);
|
|
}
|
|
return particleIndex;
|
|
});
|
|
return {
|
|
renderer: "wow-m2-particles-v1",
|
|
readyParticleIndices,
|
|
unsupportedRibbonCount: integerInRange(
|
|
record.unsupportedRibbonCount,
|
|
`${label}.unsupportedRibbonCount`,
|
|
0,
|
|
0xffff_ffff,
|
|
),
|
|
unsupportedGeometryVertexCount: integerInRange(
|
|
record.unsupportedGeometryVertexCount,
|
|
`${label}.unsupportedGeometryVertexCount`,
|
|
0,
|
|
0xffff_ffff,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function parseOriginalCombatEffectGraph(value: unknown): OriginalCombatEffectGraph {
|
|
const graph = exactObject(value, "effect graph", [
|
|
"schemaVersion",
|
|
"kind",
|
|
"source",
|
|
"sourceModel",
|
|
"normalizedSourceModel",
|
|
"sourceSha256",
|
|
"durationMs",
|
|
"animations",
|
|
"textures",
|
|
"particles",
|
|
"runtime",
|
|
]);
|
|
integerInRange(graph.schemaVersion, "effect graph.schemaVersion", 1, 1);
|
|
literalString(graph.kind, "wow-m2-particle-effect", "effect graph.kind");
|
|
literalString(graph.source, "wow335", "effect graph.source");
|
|
if (!Array.isArray(graph.animations)) throw new TypeError("effect graph.animations must be an array.");
|
|
if (!Array.isArray(graph.textures)) throw new TypeError("effect graph.textures must be an array.");
|
|
if (!Array.isArray(graph.particles)) throw new TypeError("effect graph.particles must be an array.");
|
|
const textures = graph.textures.map(parseTexture);
|
|
const particles = graph.particles.map((particle, index) => parseParticle(particle, index, textures.length));
|
|
return {
|
|
schemaVersion: 1,
|
|
kind: "wow-m2-particle-effect",
|
|
source: "wow335",
|
|
sourceModel: stringValue(graph.sourceModel, "effect graph.sourceModel", false),
|
|
normalizedSourceModel: stringValue(
|
|
graph.normalizedSourceModel,
|
|
"effect graph.normalizedSourceModel",
|
|
false,
|
|
),
|
|
sourceSha256: sha256(graph.sourceSha256, "effect graph.sourceSha256"),
|
|
durationMs: integerInRange(graph.durationMs, "effect graph.durationMs", 0, 0xffff_ffff),
|
|
animations: graph.animations.map(parseAnimation),
|
|
textures,
|
|
particles,
|
|
runtime: parseRuntime(graph.runtime, particles, textures),
|
|
};
|
|
}
|
|
|
|
export function isOriginalCombatEffectGraph(value: unknown): boolean {
|
|
try {
|
|
parseOriginalCombatEffectGraph(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function interpolateOriginalScalar(
|
|
samples: readonly OriginalCombatEffectTimeSample<number>[],
|
|
timeMs: number,
|
|
interpolation: OriginalCombatEffectInterpolationMode = 1,
|
|
): number | null {
|
|
if (samples.length === 0) return null;
|
|
const position = Number.isFinite(timeMs) ? Math.max(0, timeMs) : 0;
|
|
if (position <= samples[0].timeMs) return samples[0].value;
|
|
for (let index = 1; index < samples.length; index += 1) {
|
|
const upper = samples[index];
|
|
if (position > upper.timeMs) continue;
|
|
const lower = samples[index - 1];
|
|
const span = upper.timeMs - lower.timeMs;
|
|
if (span <= 0) return upper.value;
|
|
if (interpolation === 0) return position < upper.timeMs ? lower.value : upper.value;
|
|
const progress = (position - lower.timeMs) / span;
|
|
return lower.value + (upper.value - lower.value) * progress;
|
|
}
|
|
return samples[samples.length - 1].value;
|
|
}
|
|
|
|
function interpolateValue<T extends InterpolatedValue>(lower: T, upper: T, progress: number): T {
|
|
if (typeof lower === "number" && typeof upper === "number") {
|
|
return (lower + (upper - lower) * progress) as T;
|
|
}
|
|
if (!Array.isArray(lower) || !Array.isArray(upper) || lower.length !== upper.length) {
|
|
throw new TypeError("Lifetime sample values must have a consistent shape.");
|
|
}
|
|
return lower.map((component, index) => (
|
|
component + (upper[index] - component) * progress
|
|
)) as unknown as T;
|
|
}
|
|
|
|
export function interpolateOriginalLifetime(
|
|
samples: readonly OriginalCombatEffectLifetimeSample<number>[],
|
|
normalizedTime: number,
|
|
): number | null;
|
|
export function interpolateOriginalLifetime(
|
|
samples: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector2>[],
|
|
normalizedTime: number,
|
|
): OriginalCombatEffectVector2 | null;
|
|
export function interpolateOriginalLifetime(
|
|
samples: readonly OriginalCombatEffectLifetimeSample<OriginalCombatEffectVector3>[],
|
|
normalizedTime: number,
|
|
): OriginalCombatEffectVector3 | null;
|
|
export function interpolateOriginalLifetime(
|
|
samples: readonly OriginalCombatEffectLifetimeSample<InterpolatedValue>[],
|
|
normalizedTime: number,
|
|
): InterpolatedValue | null {
|
|
if (samples.length === 0) return null;
|
|
const position = Number.isFinite(normalizedTime) ? Math.min(1, Math.max(0, normalizedTime)) : 0;
|
|
if (position <= samples[0].normalizedTime) return samples[0].value;
|
|
for (let index = 1; index < samples.length; index += 1) {
|
|
const upper = samples[index];
|
|
if (position > upper.normalizedTime) continue;
|
|
const lower = samples[index - 1];
|
|
const span = upper.normalizedTime - lower.normalizedTime;
|
|
if (span <= 0) return upper.value;
|
|
return interpolateValue(lower.value, upper.value, (position - lower.normalizedTime) / span);
|
|
}
|
|
return samples[samples.length - 1].value;
|
|
}
|
|
|
|
export const ORIGINAL_PARTICLE_LIMIT_PER_EMITTER: Readonly<
|
|
Record<Exclude<CombatVfxQuality, "off">, number>
|
|
> = Object.freeze({ low: 48, high: 128 });
|
|
|
|
function maximumPositiveSample(samples: readonly OriginalCombatEffectTimeSample<number>[]): number {
|
|
return samples.reduce((maximum, sample) => (
|
|
Number.isFinite(sample.value) && sample.value > maximum ? sample.value : maximum
|
|
), 0);
|
|
}
|
|
|
|
export function originalEmitterParticleBudget(
|
|
particle: Pick<OriginalCombatEffectParticle, "samples">,
|
|
quality: CombatVfxQuality,
|
|
): number {
|
|
if (quality === "off") return 0;
|
|
// Animated emitters commonly begin at zero, so readiness and capacity must
|
|
// consider the whole track rather than treating the first key as authoritative.
|
|
const peakEmissionRate = maximumPositiveSample(particle.samples.emissionRate);
|
|
const peakLifespanSeconds = maximumPositiveSample(particle.samples.lifespan);
|
|
if (peakEmissionRate <= 0 || peakLifespanSeconds <= 0) return 0;
|
|
const requested = Math.max(1, Math.ceil(peakEmissionRate * peakLifespanSeconds));
|
|
return Math.min(requested, ORIGINAL_PARTICLE_LIMIT_PER_EMITTER[quality]);
|
|
}
|
|
|
|
export interface OriginalCombatEffectPhaseAsset {
|
|
readonly effectGraphUrl?: string;
|
|
}
|
|
|
|
export type OriginalCombatEffectRenderSelection =
|
|
| { readonly kind: "generated" }
|
|
| { readonly kind: "original"; readonly effectGraphUrl: string };
|
|
|
|
export function selectCombatEffectRender(
|
|
mode: CombatVfxMode,
|
|
phaseAssets: readonly OriginalCombatEffectPhaseAsset[] | null | undefined,
|
|
): OriginalCombatEffectRenderSelection | null {
|
|
if (mode === "generated") return { kind: "generated" };
|
|
const authentic = phaseAssets?.find((asset) => (
|
|
typeof asset.effectGraphUrl === "string" && asset.effectGraphUrl.trim().length > 0
|
|
));
|
|
return authentic?.effectGraphUrl
|
|
? { kind: "original", effectGraphUrl: authentic.effectGraphUrl }
|
|
: null;
|
|
}
|