Files
healer-man/src/scene/originalCombatEffectRuntime.ts
T
2026-09-01 14:35:56 -04:00

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;
}