Files
healer-man/src/game/combatPresentation.ts
T
2026-09-11 14:26:15 -04:00

347 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: true,
vfxMode: "original",
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 vfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
? record.vfxMode
: fallback.vfxMode;
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" || !combatMediaPackReady()) 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(() => { manifestRequest = null; });
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 };
}