forked from phenom/healer-man
Release Healer Man 0.1.12
This commit is contained in:
1 parent
13970d03c8
commit
65e24f644e
201 files changed
+5061
-123
No files matched your search
@@ -0,0 +1,684 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user