Files
healer-man/src/ui/PlayerStatusPanel.tsx
T
2026-09-25 21:37:10 -04:00

195 lines
8.0 KiB
TypeScript

import { useEffect, useState } from "react";
import { abilityById } from "../game/abilityCatalog";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer, groupTimedEffectsForDisplay, timedEffectRemainingMs } from "../game/timedEffectDisplay";
import { AuraStrip } from "./AuraStrip";
import {
auraDisplaysInPlayerStatusPanel,
auraDescription,
auraDisplayName,
groupAurasForDisplay,
} from "./auraDisplay";
import { TimedEffectStrip } from "./TimedEffectStrip";
import {
timedEffectDescription,
type StatusEffectInspection,
} from "./statusEffectInspection";
const PLAYER_EFFECT_TARGETS: readonly (string | null)[] = [null, PLAYER_AGGRO_ID];
const TOOLTIP_ID = "player-status-effect-tooltip";
function statusLabel(disposition: StatusEffectInspection["disposition"]): string {
return disposition === "buff" ? "Buff" : "Debuff";
}
export function PlayerStatusPanel({ now }: { readonly now: number }) {
const auras = useCombatStore((state) => state.auras);
const effects = useCombatStore((state) => state.effects);
const shield = useCombatStore((state) => state.shield);
const shieldExpiresAt = useCombatStore((state) => state.shieldExpiresAt);
const [inspected, setInspected] = useState<StatusEffectInspection | null>(null);
const activeAuras = groupAurasForDisplay(auras.filter((aura) => (
auraDisplaysInPlayerStatusPanel(aura, now)
)));
const activeEffects = groupTimedEffectsForDisplay(effects.filter((effect) => (
PLAYER_EFFECT_TARGETS.includes(effect.targetId)
)), now);
const shieldActive = shield > 0 && (shieldExpiresAt <= 0 || shieldExpiresAt > now);
const buffCount = activeAuras.filter((aura) => aura.definition.disposition === "buff").length
+ activeEffects.filter(({ effect }) => effect.kind === "hot").length
+ (shieldActive ? 1 : 0);
const debuffCount = activeAuras.filter((aura) => aura.definition.disposition === "debuff").length
+ activeEffects.filter(({ effect }) => effect.kind === "dot").length;
const activeInspectionIds = new Set([
...activeAuras.map((aura) => aura.instanceId),
...activeEffects.map(({ effect }) => effect.id),
...(shieldActive ? ["player-absorb-shield"] : []),
]);
const inspectedIsActive = inspected ? activeInspectionIds.has(inspected.id) : true;
const shieldInspection: StatusEffectInspection = {
id: "player-absorb-shield",
name: "Absorb Shield",
disposition: "buff",
description: `Absorbs up to ${Math.ceil(shield)} incoming damage before health is lost.`,
remainingLabel: shieldExpiresAt > now
? `${formatTimedEffectTimer(shieldExpiresAt - now)} remaining`
: "Until depleted",
stacks: 1,
};
const liveInspection = inspected && inspectedIsActive
? (() => {
if (inspected.id === shieldInspection.id) return shieldInspection;
const aura = activeAuras.find((candidate) => candidate.instanceId === inspected.id);
if (aura) {
const remaining = aura.expiresAt === null
? "Permanent"
: `${formatTimedEffectTimer(Math.max(0, aura.expiresAt - now))} remaining`;
return {
...inspected,
name: auraDisplayName(aura.definition),
disposition: aura.definition.disposition,
description: auraDescription(aura),
remainingLabel: remaining,
stacks: aura.stacks,
...(aura.definition.dispelCategory
? { dispelCategory: aura.definition.dispelCategory }
: {}),
} satisfies StatusEffectInspection;
}
const displayedEffect = activeEffects.find(({ effect }) => effect.id === inspected.id);
if (displayedEffect) {
const { effect, stacks } = displayedEffect;
const ability = abilityById(effect.sourceAbilityId);
return {
...inspected,
name: ability?.name ?? effect.sourceAbilityId,
disposition: effect.kind === "hot" ? "buff" : "debuff",
description: timedEffectDescription(effect.kind, ability?.description),
remainingLabel: `${formatTimedEffectTimer(timedEffectRemainingMs(effect, now))} remaining`,
stacks,
} satisfies StatusEffectInspection;
}
return inspected;
})()
: null;
useEffect(() => {
if (inspected && !inspectedIsActive) setInspected(null);
}, [inspected, inspectedIsActive]);
return (
<section className="player-status-panel" aria-label="Active buffs and debuffs">
<div
className="player-status-panel__section player-status-panel__section--buffs"
aria-label={`${buffCount} active ${buffCount === 1 ? "buff" : "buffs"}`}
>
<header><strong>Buffs</strong><b>{buffCount}</b></header>
<div className="player-status-panel__icons">
{shieldActive ? (
<span
className="aura aura--buff aura--inspectable player-status-panel__synthetic"
tabIndex={0}
aria-label={`Absorb Shield, buff, ${shieldInspection.remainingLabel}`}
aria-describedby={TOOLTIP_ID}
onPointerEnter={() => setInspected(shieldInspection)}
onPointerLeave={(event) => {
if (event.currentTarget !== document.activeElement) setInspected(null);
}}
onFocus={() => setInspected(shieldInspection)}
onBlur={() => setInspected(null)}
>
<span className="aura__initial" aria-hidden="true">S</span>
<b className="aura__timer" aria-hidden="true">
{shieldExpiresAt > now ? formatTimedEffectTimer(shieldExpiresAt - now) : ""}
</b>
</span>
) : null}
<AuraStrip
targetId={PLAYER_AGGRO_ID}
dispositions={["buff"]}
className="player-status-panel__strip"
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
<TimedEffectStrip
targetId={null}
targetIds={PLAYER_EFFECT_TARGETS}
kinds={["hot"]}
className="player-status-panel__strip"
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
{!buffCount ? <span className="player-status-panel__empty">None active</span> : null}
</div>
</div>
<div
className="player-status-panel__section player-status-panel__section--debuffs"
aria-label={`${debuffCount} active ${debuffCount === 1 ? "debuff" : "debuffs"}`}
>
<header><strong>Debuffs</strong><b>{debuffCount}</b></header>
<div className="player-status-panel__icons">
<AuraStrip
targetId={PLAYER_AGGRO_ID}
dispositions={["debuff"]}
className="player-status-panel__strip"
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
<TimedEffectStrip
targetId={null}
targetIds={PLAYER_EFFECT_TARGETS}
kinds={["dot"]}
className="player-status-panel__strip"
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
{!debuffCount ? <span className="player-status-panel__empty">None active</span> : null}
</div>
</div>
{liveInspection ? (
<div
id={TOOLTIP_ID}
className={`player-status-tooltip player-status-tooltip--${liveInspection.disposition}`}
role="tooltip"
>
<header>
<strong>{liveInspection.name}</strong>
<span>{statusLabel(liveInspection.disposition)}</span>
</header>
<p>{liveInspection.description}</p>
<footer>
<span>{liveInspection.remainingLabel}</span>
{liveInspection.stacks > 1 ? <span>{liveInspection.stacks} stacks</span> : null}
{liveInspection.dispelCategory ? <span>{liveInspection.dispelCategory}</span> : null}
{liveInspection.targetLabel ? <span>Target: {liveInspection.targetLabel}</span> : null}
</footer>
</div>
) : null}
</section>
);
}