350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
|
import {
|
|
COMBAT_MEDIA_MANIFEST_URL,
|
|
combatMediaPackReady,
|
|
fetchCombatMediaAsset,
|
|
} from "./combatMediaPack";
|
|
|
|
export type CombatActorGroup = "player" | "party" | "enemy";
|
|
export type CombatVfxMode = "generated" | "original";
|
|
export type CombatVfxQuality = "off" | "low" | "high";
|
|
export type CombatPresentationPhase =
|
|
| "cast-start"
|
|
| "cast-cancel"
|
|
| "release"
|
|
| "impact"
|
|
| "tick"
|
|
| "aura-start"
|
|
| "aura-end";
|
|
export type CombatPresentationDelivery = "melee" | "projectile" | "area" | "channel" | "aura";
|
|
export type CombatPresentationSchool =
|
|
| "physical"
|
|
| "arcane"
|
|
| "fire"
|
|
| "frost"
|
|
| "nature"
|
|
| "shadow"
|
|
| "holy";
|
|
export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "fallback";
|
|
export type CombatPresentationPosition = readonly [number, number, number];
|
|
|
|
export interface CombatPresentationSettings {
|
|
readonly originalMediaEnabled: boolean;
|
|
readonly vfxMode: CombatVfxMode;
|
|
readonly vfxQuality: CombatVfxQuality;
|
|
readonly vfxEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
|
readonly audioEnabled: Readonly<Record<CombatActorGroup, boolean>>;
|
|
readonly audioVolume: Readonly<Record<CombatActorGroup, number>>;
|
|
}
|
|
|
|
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
|
|
|
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
|
|
originalMediaEnabled: false,
|
|
vfxMode: "generated",
|
|
vfxQuality: "high",
|
|
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
|
audioEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
|
audioVolume: Object.freeze({ player: 1, party: 1, enemy: 1 }),
|
|
});
|
|
|
|
function finiteVolume(value: unknown, fallback: number): number {
|
|
return typeof value === "number" && Number.isFinite(value)
|
|
? Math.max(0, Math.min(1, value))
|
|
: fallback;
|
|
}
|
|
|
|
function booleanGroups(
|
|
value: unknown,
|
|
fallback: Readonly<Record<CombatActorGroup, boolean>>,
|
|
): Readonly<Record<CombatActorGroup, boolean>> {
|
|
const record = value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Partial<Record<CombatActorGroup, unknown>>
|
|
: {};
|
|
return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [
|
|
group,
|
|
typeof record[group] === "boolean" ? record[group] : fallback[group],
|
|
])) as Record<CombatActorGroup, boolean>);
|
|
}
|
|
|
|
function volumeGroups(
|
|
value: unknown,
|
|
fallback: Readonly<Record<CombatActorGroup, number>>,
|
|
): Readonly<Record<CombatActorGroup, number>> {
|
|
const record = value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Partial<Record<CombatActorGroup, unknown>>
|
|
: {};
|
|
return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [
|
|
group,
|
|
finiteVolume(record[group], fallback[group]),
|
|
])) as Record<CombatActorGroup, number>);
|
|
}
|
|
|
|
export function normalizeCombatPresentationSettings(
|
|
value: unknown,
|
|
fallback: CombatPresentationSettings = DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
|
): CombatPresentationSettings {
|
|
const record = value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Partial<Record<keyof CombatPresentationSettings, unknown>>
|
|
: {};
|
|
const originalMediaEnabled = typeof record.originalMediaEnabled === "boolean"
|
|
? record.originalMediaEnabled
|
|
: fallback.originalMediaEnabled;
|
|
const requestedVfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
|
|
? record.vfxMode
|
|
: fallback.vfxMode;
|
|
const vfxMode = requestedVfxMode === "original" && !originalMediaEnabled
|
|
? "generated"
|
|
: requestedVfxMode;
|
|
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
|
|
? record.vfxQuality
|
|
: fallback.vfxQuality;
|
|
return Object.freeze({
|
|
originalMediaEnabled,
|
|
vfxMode,
|
|
vfxQuality,
|
|
vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled),
|
|
audioEnabled: booleanGroups(record.audioEnabled, fallback.audioEnabled),
|
|
audioVolume: volumeGroups(record.audioVolume, fallback.audioVolume),
|
|
});
|
|
}
|
|
|
|
export interface CombatPresentationEvent {
|
|
readonly id: number;
|
|
readonly sessionId: number;
|
|
readonly occurredAt: number;
|
|
readonly phase: CombatPresentationPhase;
|
|
readonly actorGroup: CombatActorGroup;
|
|
readonly sourceActorId: string;
|
|
readonly targetActorId?: string;
|
|
readonly abilityId: string;
|
|
readonly sourceSpellId?: number;
|
|
readonly name: string;
|
|
readonly source: CombatPresentationSource;
|
|
readonly school: CombatPresentationSchool;
|
|
readonly delivery: CombatPresentationDelivery;
|
|
readonly origin: CombatPresentationPosition;
|
|
readonly targetPosition?: CombatPresentationPosition;
|
|
readonly radius?: number;
|
|
readonly durationMs?: number;
|
|
}
|
|
|
|
export type CombatPresentationEventInput = Omit<CombatPresentationEvent, "id" | "sessionId">;
|
|
|
|
export type CombatPresentationMessage =
|
|
| { readonly kind: "event"; readonly event: CombatPresentationEvent }
|
|
| { readonly kind: "reset"; readonly sessionId: number };
|
|
|
|
type CombatPresentationListener = (message: CombatPresentationMessage) => void;
|
|
const presentationListeners = new Set<CombatPresentationListener>();
|
|
let presentationSequence = 0;
|
|
let presentationSessionId = 1;
|
|
|
|
export function subscribeCombatPresentation(listener: CombatPresentationListener): () => void {
|
|
presentationListeners.add(listener);
|
|
return () => presentationListeners.delete(listener);
|
|
}
|
|
|
|
export function emitCombatPresentation(input: CombatPresentationEventInput): CombatPresentationEvent {
|
|
presentationSequence += 1;
|
|
const event: CombatPresentationEvent = Object.freeze({
|
|
...input,
|
|
id: presentationSequence,
|
|
sessionId: presentationSessionId,
|
|
origin: [...input.origin] as CombatPresentationPosition,
|
|
...(input.targetPosition
|
|
? { targetPosition: [...input.targetPosition] as CombatPresentationPosition }
|
|
: {}),
|
|
});
|
|
for (const listener of presentationListeners) listener({ kind: "event", event });
|
|
return event;
|
|
}
|
|
|
|
export function resetCombatPresentationSession(): number {
|
|
presentationSessionId += 1;
|
|
for (const listener of presentationListeners) {
|
|
listener({ kind: "reset", sessionId: presentationSessionId });
|
|
}
|
|
return presentationSessionId;
|
|
}
|
|
|
|
export function combatPresentationSessionId(): number {
|
|
return presentationSessionId;
|
|
}
|
|
|
|
export interface CombatEffectPhaseAsset {
|
|
readonly model?: string;
|
|
readonly attachment?: string;
|
|
readonly scale?: number;
|
|
/** Browser-ready graph generated from the original client asset. */
|
|
readonly effectGraphUrl?: string;
|
|
readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "runewaker-particles";
|
|
readonly originalSourceSha256?: string;
|
|
}
|
|
|
|
export interface CombatEffectSoundAsset {
|
|
readonly url?: string;
|
|
readonly sourcePath?: string;
|
|
readonly weight?: number;
|
|
readonly volume?: number;
|
|
readonly minimumDistance?: number;
|
|
readonly maximumDistance?: number;
|
|
}
|
|
|
|
export interface CombatEffectManifestEntry {
|
|
readonly key: string;
|
|
readonly source: Exclude<CombatPresentationSource, "fallback">;
|
|
readonly spellId: number;
|
|
readonly visualIds?: readonly number[];
|
|
readonly phases?: Readonly<Partial<Record<CombatPresentationPhase, readonly CombatEffectPhaseAsset[]>>>;
|
|
readonly sounds?: readonly CombatEffectSoundAsset[];
|
|
}
|
|
|
|
interface GeneratedCombatEffectManifest {
|
|
readonly schemaVersion: number;
|
|
readonly entries: readonly CombatEffectManifestEntry[];
|
|
}
|
|
|
|
const manifestByKey = new Map<string, CombatEffectManifestEntry>();
|
|
const manifestBySpellId = new Map<number, CombatEffectManifestEntry>();
|
|
let manifestRequest: Promise<void> | null = null;
|
|
|
|
function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): void {
|
|
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.entries)) return;
|
|
for (const entry of manifest.entries) {
|
|
manifestByKey.set(entry.key, entry);
|
|
if (!manifestBySpellId.has(entry.spellId)) manifestBySpellId.set(entry.spellId, entry);
|
|
}
|
|
}
|
|
|
|
export function preloadCombatEffectManifest(): Promise<void> {
|
|
if (manifestRequest) return manifestRequest;
|
|
if (typeof fetch === "undefined") return Promise.resolve();
|
|
manifestRequest = fetchCombatMediaAsset(COMBAT_MEDIA_MANIFEST_URL)
|
|
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
|
|
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
|
|
.catch(() => undefined);
|
|
return manifestRequest;
|
|
}
|
|
|
|
export function effectiveCombatVfxMode(settings: CombatPresentationSettings): CombatVfxMode {
|
|
return settings.originalMediaEnabled && combatMediaPackReady()
|
|
? settings.vfxMode
|
|
: "generated";
|
|
}
|
|
|
|
export function combatEffectManifestEntry(
|
|
spellId: number | undefined,
|
|
source?: CombatPresentationSource,
|
|
): CombatEffectManifestEntry | null {
|
|
if (!spellId) return null;
|
|
if (source && source !== "fallback") {
|
|
const exact = manifestByKey.get(`${source}:${spellId}`);
|
|
if (exact) return exact;
|
|
}
|
|
return manifestBySpellId.get(spellId) ?? null;
|
|
}
|
|
|
|
export function originalCombatPhaseAssets(
|
|
entry: CombatEffectManifestEntry | null | undefined,
|
|
phase: CombatPresentationPhase,
|
|
): readonly CombatEffectPhaseAsset[] {
|
|
return (entry?.phases?.[phase] ?? []).filter((asset) => (
|
|
typeof asset.effectGraphUrl === "string"
|
|
&& asset.effectGraphUrl.startsWith("/assets/game/combat-effects/original/")
|
|
&& (
|
|
asset.effectKind === "wow-m2-particles"
|
|
|| asset.effectKind === "wow-m2-static-mesh"
|
|
|| asset.effectKind === "runewaker-particles"
|
|
)
|
|
));
|
|
}
|
|
|
|
export function combatOriginalPhaseAssets(
|
|
event: Pick<CombatPresentationEvent, "phase" | "source" | "sourceSpellId">,
|
|
): readonly CombatEffectPhaseAsset[] {
|
|
return originalCombatPhaseAssets(
|
|
combatEffectManifestEntry(event.sourceSpellId, event.source),
|
|
event.phase,
|
|
);
|
|
}
|
|
|
|
function abilityHasEffect(ability: AbilityDefinition, kind: AbilityEffect["kind"]): boolean {
|
|
return ability.effects.some((effect) => effect.kind === kind);
|
|
}
|
|
|
|
export function presentationSourceForAbility(ability: AbilityDefinition): CombatPresentationSource {
|
|
if (ability.source === "runewaker") return "runewaker";
|
|
if (ability.source?.startsWith("ascension") || ability.source?.startsWith("coa")) return "ascension";
|
|
return "wow335";
|
|
}
|
|
|
|
export function presentationSchoolForAbility(ability: AbilityDefinition): CombatPresentationSchool {
|
|
const name = `${ability.name} ${ability.description}`.toLowerCase();
|
|
if (/fire|flame|burn|inferno|pyro|molten/.test(name)) return "fire";
|
|
if (/frost|ice|rime|chill|snow/.test(name)) return "frost";
|
|
if (/shadow|void|curse|corrupt|death|plague|fel|blood/.test(name)) return "shadow";
|
|
if (/arcane|mana|time|chron|runic|astral/.test(name)) return "arcane";
|
|
if (/holy|light|smite|divine|sun|heal|renew|rejuven|salvation|blessing/.test(name)) return "holy";
|
|
if (/nature|earth|storm|lightning|thunder|wind|wrath|root|poison|venom/.test(name)) return "nature";
|
|
if (abilityHasEffect(ability, "heal") || abilityHasEffect(ability, "hot") || abilityHasEffect(ability, "resurrection")) {
|
|
return ["druid", "shaman", "rom-druid", "rom-warden"].includes(ability.classId) ? "nature" : "holy";
|
|
}
|
|
return "physical";
|
|
}
|
|
|
|
export function presentationDeliveryForAbility(ability: AbilityDefinition): CombatPresentationDelivery {
|
|
if (ability.castMode === "channel") return "channel";
|
|
if (ability.radius !== undefined && ability.radius > 0) return "area";
|
|
if (ability.target === "hostile") return ability.range.max <= 4.5 ? "melee" : "projectile";
|
|
if (abilityHasEffect(ability, "apply-aura") || abilityHasEffect(ability, "shield") || abilityHasEffect(ability, "hot")) {
|
|
return "aura";
|
|
}
|
|
return "projectile";
|
|
}
|
|
|
|
export function presentationSourceForSpellId(spellId: number | undefined): CombatPresentationSource {
|
|
if (!spellId) return "fallback";
|
|
const mapped = manifestBySpellId.get(spellId);
|
|
if (mapped) return mapped.source;
|
|
return spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "wow335";
|
|
}
|
|
|
|
export interface CombatEffectStyle {
|
|
readonly primary: string;
|
|
readonly secondary: string;
|
|
readonly emissive: number;
|
|
readonly authentic: boolean;
|
|
readonly sourceModelNames: readonly string[];
|
|
}
|
|
|
|
const SCHOOL_STYLE: Readonly<Record<CombatPresentationSchool, Omit<CombatEffectStyle, "authentic" | "sourceModelNames">>> = {
|
|
physical: { primary: "#f3d18a", secondary: "#ffffff", emissive: 1.2 },
|
|
arcane: { primary: "#b774ff", secondary: "#76d8ff", emissive: 1.8 },
|
|
fire: { primary: "#ff6a20", secondary: "#ffd24a", emissive: 2.2 },
|
|
frost: { primary: "#72d8ff", secondary: "#e8fbff", emissive: 1.8 },
|
|
nature: { primary: "#64e06e", secondary: "#d4ff73", emissive: 1.7 },
|
|
shadow: { primary: "#8a4de8", secondary: "#ed5cff", emissive: 1.9 },
|
|
holy: { primary: "#ffe67a", secondary: "#fff7d0", emissive: 2 },
|
|
};
|
|
|
|
export function combatEffectStyle(event: CombatPresentationEvent): CombatEffectStyle {
|
|
const entry = combatEffectManifestEntry(event.sourceSpellId, event.source);
|
|
const sourceModelNames = Object.values(entry?.phases ?? {})
|
|
.flatMap((assets) => assets ?? [])
|
|
.flatMap((asset) => asset.model ? [asset.model] : []);
|
|
const inferred = sourceModelNames.join(" ").toLowerCase();
|
|
const school = /fire|flame|molten/.test(inferred)
|
|
? "fire"
|
|
: /frost|ice|snow/.test(inferred)
|
|
? "frost"
|
|
: /shadow|void|dark/.test(inferred)
|
|
? "shadow"
|
|
: /holy|light|heal/.test(inferred)
|
|
? "holy"
|
|
: /nature|earth|lightning|wind/.test(inferred)
|
|
? "nature"
|
|
: event.school;
|
|
return { ...SCHOOL_STYLE[school], authentic: Boolean(entry), sourceModelNames };
|
|
}
|