136 lines
5.7 KiB
TypeScript
136 lines
5.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import type { ActiveAura, AuraDefinition, AuraDisposition } from "../game/combatAuras";
|
|
import { useCombatStore } from "../game/combatStore";
|
|
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
|
|
import type { StatusEffectInspectHandler, StatusEffectInspection } from "./statusEffectInspection";
|
|
|
|
export const LONG_TERM_AURA_DURATION_MS = 60_000;
|
|
|
|
interface AuraStripProps {
|
|
readonly targetId: string;
|
|
readonly targetIds?: readonly string[];
|
|
readonly dispositions?: readonly AuraDisposition[];
|
|
readonly className?: string;
|
|
readonly hideLongTerm?: boolean;
|
|
readonly showOnlyLongTerm?: boolean;
|
|
readonly targetLabels?: Readonly<Record<string, string>>;
|
|
readonly onInspect?: StatusEffectInspectHandler;
|
|
readonly tooltipId?: string;
|
|
}
|
|
|
|
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
|
|
return definition.hideFromAuraStrip !== true;
|
|
}
|
|
|
|
export function auraDefinitionIcon(definition: AuraDefinition): string | null {
|
|
return definition.icon?.trim() || null;
|
|
}
|
|
|
|
export function auraDisplayName(definition: AuraDefinition): string {
|
|
return definition.name.replace(/\s+\((?:DBC\s+\d+,\s*)?Aura\s+\d+\)$/i, "").trim();
|
|
}
|
|
|
|
/** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */
|
|
export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
|
|
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
|
|
}
|
|
|
|
export function auraDescription(aura: ActiveAura): string {
|
|
const authored = aura.definition.description?.trim();
|
|
if (authored) return authored;
|
|
|
|
const absorb = aura.absorbs.reduce((total, pool) => total + pool.remaining, 0);
|
|
if (absorb > 0) return `Absorbs up to ${Math.ceil(absorb)} incoming damage.`;
|
|
if (aura.definition.controlImmunities?.length) {
|
|
return `Grants immunity to ${aura.definition.controlImmunities.join(", ")}.`;
|
|
}
|
|
if (aura.definition.modifiers?.length) {
|
|
return aura.definition.disposition === "buff"
|
|
? "Improves combat attributes while active."
|
|
: "Reduces combat effectiveness while active.";
|
|
}
|
|
if (aura.definition.procs?.length) return "Can trigger an additional effect during combat.";
|
|
return aura.definition.disposition === "buff"
|
|
? "A beneficial effect is active."
|
|
: "A harmful effect is active.";
|
|
}
|
|
|
|
export function AuraStrip({
|
|
targetId,
|
|
targetIds,
|
|
dispositions = ["buff", "debuff"],
|
|
className = "",
|
|
hideLongTerm = false,
|
|
showOnlyLongTerm = false,
|
|
targetLabels,
|
|
onInspect,
|
|
tooltipId,
|
|
}: AuraStripProps) {
|
|
const allAuras = useCombatStore((state) => state.auras);
|
|
const [now, setNow] = useState(() => Date.now());
|
|
const requestedTargetIds = targetIds ?? [targetId];
|
|
const auras = allAuras.filter((aura) => (
|
|
requestedTargetIds.includes(aura.targetId)
|
|
&& dispositions.includes(aura.definition.disposition)
|
|
&& auraDefinitionDisplaysInStrip(aura.definition)
|
|
&& (!hideLongTerm || !auraDefinitionIsLongTerm(aura.definition))
|
|
&& (!showOnlyLongTerm || auraDefinitionIsLongTerm(aura.definition))
|
|
&& (aura.expiresAt === null || aura.expiresAt > now)
|
|
));
|
|
|
|
useEffect(() => {
|
|
if (!auras.some((aura) => aura.expiresAt !== null)) return undefined;
|
|
const timer = window.setInterval(() => setNow(Date.now()), 100);
|
|
return () => window.clearInterval(timer);
|
|
}, [auras]);
|
|
|
|
if (!auras.length) return null;
|
|
|
|
return (
|
|
<span className={`aura-strip ${className}`.trim()} aria-label="Active buffs and debuffs">
|
|
{auras.map((aura) => {
|
|
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
|
|
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining);
|
|
const displayName = auraDisplayName(aura.definition);
|
|
const targetLabel = targetLabels?.[aura.targetId];
|
|
const initial = displayName.charAt(0).toUpperCase() || "*";
|
|
const icon = auraDefinitionIcon(aura.definition);
|
|
const inspection: StatusEffectInspection = {
|
|
id: aura.instanceId,
|
|
name: displayName,
|
|
disposition: aura.definition.disposition,
|
|
description: auraDescription(aura),
|
|
remainingLabel: timer ? `${timer} remaining` : "Permanent",
|
|
stacks: aura.stacks,
|
|
...(targetLabel ? { targetLabel } : {}),
|
|
...(aura.definition.dispelCategory
|
|
? { dispelCategory: aura.definition.dispelCategory }
|
|
: {}),
|
|
};
|
|
return (
|
|
<span
|
|
key={aura.instanceId}
|
|
className={`aura aura--${aura.definition.disposition} ${onInspect ? "aura--inspectable" : ""}`.trim()}
|
|
tabIndex={onInspect ? 0 : undefined}
|
|
aria-label={`${displayName}${targetLabel ? ` on ${targetLabel}` : ""}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
|
|
aria-describedby={onInspect ? tooltipId : undefined}
|
|
title={onInspect ? undefined : `${displayName}${timer ? ` · ${timer} remaining` : ""}`}
|
|
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
|
|
onPointerLeave={onInspect ? (event) => {
|
|
if (event.currentTarget !== document.activeElement) onInspect(null);
|
|
} : undefined}
|
|
onFocus={onInspect ? () => onInspect(inspection) : undefined}
|
|
onBlur={onInspect ? () => onInspect(null) : undefined}
|
|
>
|
|
{icon
|
|
? <img className="aura__icon" src={icon} alt="" draggable={false} />
|
|
: <span className="aura__initial" aria-hidden="true">{initial}</span>}
|
|
{aura.stacks > 1 ? <b className="aura__stacks">{aura.stacks}</b> : null}
|
|
{timer ? <b className="aura__timer" aria-hidden="true">{timer}</b> : null}
|
|
</span>
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
}
|