Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5901
-334
No files matched your search
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuraDefinition } from "../game/combatAuras";
|
||||
import { auraDefinitionDisplaysInStrip, auraDefinitionIcon } from "./AuraStrip";
|
||||
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
|
||||
import {
|
||||
LONG_TERM_AURA_DURATION_MS,
|
||||
auraDisplayName,
|
||||
auraDefinitionDisplaysInStrip,
|
||||
auraDefinitionIcon,
|
||||
auraDefinitionIsLongTerm,
|
||||
auraDescription,
|
||||
} from "./AuraStrip";
|
||||
|
||||
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
|
||||
return {
|
||||
@@ -12,6 +19,22 @@ function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function activeAura(definition: AuraDefinition, remainingAbsorb = 0): ActiveAura {
|
||||
return {
|
||||
instanceId: "test-aura-instance",
|
||||
definition,
|
||||
sourceId: "source",
|
||||
targetId: "target",
|
||||
appliedAt: 1_000,
|
||||
expiresAt: definition.durationMs === null ? null : 1_000 + definition.durationMs,
|
||||
stacks: 1,
|
||||
absorbs: remainingAbsorb > 0
|
||||
? [{ id: "test-absorb", remaining: remainingAbsorb }]
|
||||
: [],
|
||||
procStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("aura strip visibility", () => {
|
||||
it("shows ordinary auras and suppresses periodic shadow auras with dedicated timers", () => {
|
||||
expect(auraDefinitionDisplaysInStrip(aura())).toBe(true);
|
||||
@@ -24,4 +47,28 @@ describe("aura strip visibility", () => {
|
||||
);
|
||||
expect(auraDefinitionIcon(aura())).toBeNull();
|
||||
});
|
||||
|
||||
it("removes internal DBC and aura identifiers from player-facing names", () => {
|
||||
expect(auraDisplayName(aura({ name: "Power Word: Fortitude (DBC 1243, Aura 29)" }))).toBe(
|
||||
"Power Word: Fortitude",
|
||||
);
|
||||
expect(auraDisplayName(aura({ name: "Arcane Intellect (Aura 13)" }))).toBe(
|
||||
"Arcane Intellect",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies permanent and one-minute effects as long-term without using remaining time", () => {
|
||||
expect(auraDefinitionIsLongTerm(aura({ durationMs: null }))).toBe(true);
|
||||
expect(auraDefinitionIsLongTerm(aura({ durationMs: LONG_TERM_AURA_DURATION_MS }))).toBe(true);
|
||||
expect(auraDefinitionIsLongTerm(aura({ durationMs: LONG_TERM_AURA_DURATION_MS - 1 }))).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers authored tooltip copy and explains absorb-only fallback auras", () => {
|
||||
expect(auraDescription(activeAura(aura({ description: "Raises armor by 10%." })))).toBe(
|
||||
"Raises armor by 10%.",
|
||||
);
|
||||
expect(auraDescription(activeAura(aura({
|
||||
absorbs: [{ id: "test-absorb", amount: 250 }],
|
||||
}), 175))).toBe("Absorbs up to 175 incoming damage.");
|
||||
});
|
||||
});
|
||||
+75
-6
@@ -1,12 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AuraDefinition, AuraDisposition } from "../game/combatAuras";
|
||||
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 {
|
||||
@@ -17,17 +26,55 @@ 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) => (
|
||||
aura.targetId === targetId
|
||||
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)
|
||||
));
|
||||
|
||||
@@ -44,14 +91,36 @@ export function AuraStrip({
|
||||
{auras.map((aura) => {
|
||||
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
|
||||
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining);
|
||||
const initial = aura.definition.name.trim().charAt(0).toUpperCase() || "*";
|
||||
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}`}
|
||||
aria-label={`${aura.definition.name}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
|
||||
title={`${aura.definition.name}${timer ? ` · ${timer} remaining` : ""}`}
|
||||
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} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, type CSSProperties } from "react";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import type { CharacterDraft, CharacterProfile } from "../app/types";
|
||||
import { normalizePreviewFrameRateLimit } from "../game/frameRateSettings";
|
||||
|
||||
const CharacterPreview3D = lazy(() => import("./CharacterPreview3D"));
|
||||
|
||||
@@ -33,6 +34,9 @@ export function CharacterPreview({ character, draft, compact = false }: {
|
||||
const characterClass = classById(source.classId);
|
||||
const name = "name" in source && source.name ? source.name : "New adventurer";
|
||||
const rotation = "rotation" in source ? source.rotation : -8;
|
||||
const previewFrameRate = normalizePreviewFrameRateLimit(
|
||||
"settings" in source ? source.settings?.previewFrameRate : undefined,
|
||||
);
|
||||
const style = {
|
||||
"--race-accent": race.accent,
|
||||
"--class-color": characterClass.color,
|
||||
@@ -45,7 +49,7 @@ export function CharacterPreview({ character, draft, compact = false }: {
|
||||
<AvatarSilhouette gender={source.gender} raceSigil={race.sigil} />
|
||||
) : (
|
||||
<Suspense fallback={<AvatarSilhouette gender={source.gender} raceSigil={race.sigil} />}>
|
||||
<CharacterPreview3D source={source} />
|
||||
<CharacterPreview3D source={source} frameRateLimit={previewFrameRate} />
|
||||
</Suspense>
|
||||
)}
|
||||
<span className="sr-only">{source.gender} {race.name} {characterClass.name} preview</span>
|
||||
|
||||
@@ -5,6 +5,12 @@ import type { CharacterDraft, CharacterProfile } from "../app/types";
|
||||
import { CharacterModel } from "../avatar/CharacterModel";
|
||||
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import {
|
||||
numericFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "../game/frameRateSettings";
|
||||
import { useDocumentVisibility } from "../platform/useDocumentVisibility";
|
||||
import { CappedFrameLoop } from "../scene/CappedFrameLoop";
|
||||
|
||||
function FittedCharacter({
|
||||
characterClassColor,
|
||||
@@ -30,19 +36,34 @@ function FittedCharacter({
|
||||
);
|
||||
}
|
||||
|
||||
export default function CharacterPreview3D({ source }: {
|
||||
export default function CharacterPreview3D({ frameRateLimit, source }: {
|
||||
frameRateLimit: PreviewFrameRateLimit;
|
||||
source: CharacterProfile | CharacterDraft;
|
||||
}) {
|
||||
const race = raceById(source.raceId);
|
||||
const characterClass = classById(source.classId);
|
||||
const turnDegrees = "rotation" in source ? source.rotation : -8;
|
||||
const documentVisible = useDocumentVisibility();
|
||||
const targetFps = numericFrameRateLimit(frameRateLimit);
|
||||
const frameloop = !documentVisible
|
||||
? "demand"
|
||||
: targetFps === null ? "always" : "never";
|
||||
return (
|
||||
<div className="character-preview__viewport" aria-hidden="true">
|
||||
<div
|
||||
className="character-preview__viewport"
|
||||
aria-hidden="true"
|
||||
data-frame-rate-limit={frameRateLimit}
|
||||
data-frame-loop={frameloop}
|
||||
>
|
||||
<Canvas
|
||||
dpr={[1, 1.25]}
|
||||
frameloop={frameloop}
|
||||
camera={{ fov: 32, near: 0.05, far: 40, position: [3.2, 1.8, 4.6] }}
|
||||
gl={{ alpha: true, antialias: true, powerPreference: "high-performance" }}
|
||||
gl={{ alpha: true, antialias: true, powerPreference: "default" }}
|
||||
>
|
||||
{targetFps !== null && frameloop === "never" && (
|
||||
<CappedFrameLoop active={documentVisible} targetFps={targetFps} />
|
||||
)}
|
||||
<ambientLight intensity={1.15} color="#d8e2ca" />
|
||||
<hemisphereLight args={["#d8e9ce", "#142019", 1.8]} />
|
||||
<directionalLight position={[3, 5, 4]} intensity={2.1} color="#f0e5bf" />
|
||||
|
||||
+12
-8
@@ -17,6 +17,7 @@ import { useGameStore } from "../game/store";
|
||||
import { ActionBar, type ActionBarItem, type ActionBarItems } from "./ActionBar";
|
||||
import { ExperienceBar } from "./ExperienceBar";
|
||||
import { Minimap } from "./Minimap";
|
||||
import { PlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
import { PlayerFrame } from "./PlayerFrame";
|
||||
import { PartyFrames } from "./PartyFrames";
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
@@ -344,14 +345,17 @@ export function Hud() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Minimap
|
||||
position={playerPosition}
|
||||
yaw={cameraYaw}
|
||||
areaName={stagePresentation.areaName}
|
||||
distanceTravelled={distance}
|
||||
rotationMode={settings.minimapRotation}
|
||||
onOpenMap={toggleMap}
|
||||
/>
|
||||
<div className="hud-top-right">
|
||||
<Minimap
|
||||
position={playerPosition}
|
||||
yaw={cameraYaw}
|
||||
areaName={stagePresentation.areaName}
|
||||
distanceTravelled={distance}
|
||||
rotationMode={settings.minimapRotation}
|
||||
onOpenMap={toggleMap}
|
||||
/>
|
||||
<PlayerStatusPanel now={clock} />
|
||||
</div>
|
||||
|
||||
{target && settings.showTargetFrame && (
|
||||
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}>
|
||||
|
||||
+37
-1
@@ -11,6 +11,14 @@ import {
|
||||
type CombatPresentationSettings,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import {
|
||||
GAMEPLAY_FRAME_RATE_LIMITS,
|
||||
PREVIEW_FRAME_RATE_LIMITS,
|
||||
frameRateLimitLabel,
|
||||
nextFrameRateLimit,
|
||||
type GameplayFrameRateLimit,
|
||||
type PreviewFrameRateLimit,
|
||||
} from "../game/frameRateSettings";
|
||||
|
||||
export interface OptionsPanelSettings {
|
||||
showMobHealthBars: boolean;
|
||||
@@ -24,6 +32,8 @@ export interface OptionsPanelSettings {
|
||||
masterVolume: number;
|
||||
uiScale: number;
|
||||
minimapRotation: "north-up" | "player-up";
|
||||
gameplayFrameRate: GameplayFrameRateLimit;
|
||||
previewFrameRate: PreviewFrameRateLimit;
|
||||
}
|
||||
|
||||
export interface OptionsPanelProps {
|
||||
@@ -87,7 +97,15 @@ function OptionsDialog({
|
||||
const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality);
|
||||
onPresentationChange({ vfxQuality: VFX_QUALITY_ORDER[(index + 1) % VFX_QUALITY_ORDER.length] });
|
||||
};
|
||||
const cycleGameplayFrameRate = () => onChange({
|
||||
gameplayFrameRate: nextFrameRateLimit(settings.gameplayFrameRate, GAMEPLAY_FRAME_RATE_LIMITS),
|
||||
});
|
||||
const cyclePreviewFrameRate = () => onChange({
|
||||
previewFrameRate: nextFrameRateLimit(settings.previewFrameRate, PREVIEW_FRAME_RATE_LIMITS),
|
||||
});
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "option-gameplay-frame-rate", run: cycleGameplayFrameRate },
|
||||
{ id: "option-preview-frame-rate", run: cyclePreviewFrameRate },
|
||||
...TOGGLES.map((option) => ({
|
||||
id: `option-${option.id}`,
|
||||
run: () => onChange({ [option.id]: !settings[option.id] }),
|
||||
@@ -141,6 +159,24 @@ function OptionsDialog({
|
||||
</header>
|
||||
|
||||
<div className="options-panel__list">
|
||||
<h3 className="options-panel__section-title">Performance</h3>
|
||||
<div className="option-row">
|
||||
<span><strong>Gameplay frame rate</strong><small>Limit 3D gameplay rendering without changing combat or physics timing.</small></span>
|
||||
{optionButton(
|
||||
"option-gameplay-frame-rate",
|
||||
frameRateLimitLabel(settings.gameplayFrameRate),
|
||||
cycleGameplayFrameRate,
|
||||
)}
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<span><strong>Character preview frame rate</strong><small>Limit animated character previews shown in menus.</small></span>
|
||||
{optionButton(
|
||||
"option-preview-frame-rate",
|
||||
frameRateLimitLabel(settings.previewFrameRate),
|
||||
cyclePreviewFrameRate,
|
||||
)}
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Gameplay and interface</h3>
|
||||
{TOGGLES.map((option) => (
|
||||
<div key={option.id} className="option-row">
|
||||
<span><strong>{option.label}</strong><small>{option.description}</small></span>
|
||||
@@ -185,7 +221,7 @@ function OptionsDialog({
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Combat presentation</h3>
|
||||
<div className="option-row">
|
||||
<span><strong>VFX quality</strong><small>High uses the full particle budget; Low reduces particles and lights; Off skips combat VFX.</small></span>
|
||||
<span><strong>VFX quality</strong><small>High uses the full effect budget; Low reduces particles and simultaneous effects; Off skips combat VFX.</small></span>
|
||||
{optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}
|
||||
</div>
|
||||
{COMBAT_ACTOR_GROUPS.map((group) => (
|
||||
|
||||
@@ -149,7 +149,7 @@ function MemberFrame({ member, selected, onSelect }: {
|
||||
<i style={{ width: `${resourcePercent}%` }} />
|
||||
</span>
|
||||
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
|
||||
<AuraStrip targetId={member.id} className="aura-strip--party" />
|
||||
<AuraStrip targetId={member.id} className="aura-strip--party" hideLongTerm />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -273,8 +273,12 @@ export function PartyFrames() {
|
||||
<span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${playerMaxResource} ${playerResourceName}`}>
|
||||
<i style={{ width: `${healthPercent(playerResource, playerMaxResource)}%` }} />
|
||||
</span>
|
||||
<TimedEffectStrip targetId={null} className="timed-effect-strip--party" />
|
||||
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" />
|
||||
<TimedEffectStrip
|
||||
targetId={null}
|
||||
targetIds={[null, PLAYER_AGGRO_ID]}
|
||||
className="timed-effect-strip--party"
|
||||
/>
|
||||
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" hideLongTerm />
|
||||
</span>
|
||||
</button>
|
||||
{onlineMembers.map((member) => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
|
||||
import { auraDisplaysInPlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
|
||||
function activeAura(
|
||||
targetId: string,
|
||||
durationMs: number | null,
|
||||
expiresAt: number | null = durationMs === null ? null : 10_000 + durationMs,
|
||||
hideFromAuraStrip = false,
|
||||
): ActiveAura {
|
||||
const definition: AuraDefinition = {
|
||||
id: `aura-${targetId}`,
|
||||
name: "Test Aura",
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
...(hideFromAuraStrip ? { hideFromAuraStrip: true } : {}),
|
||||
};
|
||||
return {
|
||||
instanceId: `instance-${targetId}`,
|
||||
definition,
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId,
|
||||
appliedAt: 10_000,
|
||||
expiresAt,
|
||||
stacks: 1,
|
||||
absorbs: [],
|
||||
procStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("top-right status visibility", () => {
|
||||
const now = 20_000;
|
||||
const partyTargets = new Set(["party-1"]);
|
||||
|
||||
it("shows every player aura and centralizes long-term party auras", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 15_000), now, partyTargets)).toBe(true);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 60_000), now, partyTargets)).toBe(true);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", null), now, partyTargets)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves short party effects on party frames and rejects unrelated, hidden, or expired auras", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 30_000), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("stranger", 60_000), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 60_000, 70_000, true), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 5_000, 15_000), now, partyTargets)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { abilityById } from "../game/abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import type { ActiveAura } from "../game/combatAuras";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay";
|
||||
import {
|
||||
AuraStrip,
|
||||
auraDefinitionDisplaysInStrip,
|
||||
auraDefinitionIsLongTerm,
|
||||
auraDescription,
|
||||
auraDisplayName,
|
||||
} from "./AuraStrip";
|
||||
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 auraDisplaysInPlayerStatusPanel(
|
||||
aura: ActiveAura,
|
||||
now: number,
|
||||
partyTargetIds: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const belongsInPanel = aura.targetId === PLAYER_AGGRO_ID
|
||||
|| (partyTargetIds.has(aura.targetId) && auraDefinitionIsLongTerm(aura.definition));
|
||||
return belongsInPanel
|
||||
&& auraDefinitionDisplaysInStrip(aura.definition)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now);
|
||||
}
|
||||
|
||||
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 partyMembers = usePartyStore((state) => state.members);
|
||||
const [inspected, setInspected] = useState<StatusEffectInspection | null>(null);
|
||||
|
||||
const partyAuraTargetIds = partyMembers.map((member) => member.id);
|
||||
const partyAuraTargetIdSet = new Set(partyAuraTargetIds);
|
||||
const partyAuraTargetLabels = Object.fromEntries(
|
||||
partyMembers.map((member) => [member.id, member.name]),
|
||||
);
|
||||
const activeAuras = auras.filter((aura) => (
|
||||
auraDisplaysInPlayerStatusPanel(aura, now, partyAuraTargetIdSet)
|
||||
));
|
||||
const activeEffects = effects.filter((effect) => (
|
||||
PLAYER_EFFECT_TARGETS.includes(effect.targetId)
|
||||
&& timedEffectRemainingMs(effect, now) > 0
|
||||
));
|
||||
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 effect = activeEffects.find((candidate) => candidate.id === inspected.id);
|
||||
if (effect) {
|
||||
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`,
|
||||
} 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}
|
||||
/>
|
||||
{partyAuraTargetIds.length ? (
|
||||
<AuraStrip
|
||||
targetId={partyAuraTargetIds[0]}
|
||||
targetIds={partyAuraTargetIds}
|
||||
dispositions={["buff"]}
|
||||
className="player-status-panel__strip"
|
||||
showOnlyLongTerm
|
||||
targetLabels={partyAuraTargetLabels}
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
) : null}
|
||||
<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}
|
||||
/>
|
||||
{partyAuraTargetIds.length ? (
|
||||
<AuraStrip
|
||||
targetId={partyAuraTargetIds[0]}
|
||||
targetIds={partyAuraTargetIds}
|
||||
dispositions={["debuff"]}
|
||||
className="player-status-panel__strip"
|
||||
showOnlyLongTerm
|
||||
targetLabels={partyAuraTargetLabels}
|
||||
onInspect={setInspected}
|
||||
tooltipId={TOOLTIP_ID}
|
||||
/>
|
||||
) : null}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { timedEffectDescription } from "./statusEffectInspection";
|
||||
|
||||
describe("timed effect tooltip copy", () => {
|
||||
it("uses the source ability description when one is available", () => {
|
||||
expect(timedEffectDescription("hot", " Restores health over 12 sec. ")).toBe(
|
||||
"Restores health over 12 sec.",
|
||||
);
|
||||
});
|
||||
|
||||
it("describes periodic healing and damage when source copy is unavailable", () => {
|
||||
expect(timedEffectDescription("hot")).toContain("Restores health");
|
||||
expect(timedEffectDescription("dot")).toContain("Deals damage");
|
||||
});
|
||||
});
|
||||
@@ -5,21 +5,33 @@ import {
|
||||
formatTimedEffectTimer,
|
||||
timedEffectRemainingMs,
|
||||
} from "../game/timedEffectDisplay";
|
||||
import {
|
||||
timedEffectDescription,
|
||||
type StatusEffectInspectHandler,
|
||||
type StatusEffectInspection,
|
||||
} from "./statusEffectInspection";
|
||||
|
||||
export type TimedEffectKind = TimedCombatEffect["kind"];
|
||||
|
||||
interface TimedEffectStripProps {
|
||||
readonly targetId: string | null;
|
||||
readonly targetIds?: readonly (string | null)[];
|
||||
readonly kinds?: readonly TimedEffectKind[];
|
||||
readonly className?: string;
|
||||
readonly onInspect?: StatusEffectInspectHandler;
|
||||
readonly tooltipId?: string;
|
||||
}
|
||||
|
||||
function LiveTimedEffectStrip({
|
||||
effects,
|
||||
className = "",
|
||||
onInspect,
|
||||
tooltipId,
|
||||
}: {
|
||||
readonly effects: readonly TimedCombatEffect[];
|
||||
readonly className?: string;
|
||||
readonly onInspect?: StatusEffectInspectHandler;
|
||||
readonly tooltipId?: string;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
@@ -45,12 +57,29 @@ function LiveTimedEffectStrip({
|
||||
{visibleEffects.map(({ effect, ability, remainingMs }) => {
|
||||
const label = ability?.name ?? effect.sourceAbilityId;
|
||||
const timer = formatTimedEffectTimer(remainingMs);
|
||||
const disposition = effect.kind === "hot" ? "buff" : "debuff";
|
||||
const inspection: StatusEffectInspection = {
|
||||
id: effect.id,
|
||||
name: label,
|
||||
disposition,
|
||||
description: timedEffectDescription(effect.kind, ability?.description),
|
||||
remainingLabel: `${timer} remaining`,
|
||||
stacks: 1,
|
||||
};
|
||||
return (
|
||||
<span
|
||||
key={effect.id}
|
||||
className={`timed-effect timed-effect--${effect.kind}`}
|
||||
className={`timed-effect timed-effect--${effect.kind} ${onInspect ? "timed-effect--inspectable" : ""}`.trim()}
|
||||
tabIndex={onInspect ? 0 : undefined}
|
||||
aria-label={`${label}, ${effect.kind === "hot" ? "healing" : "damage"} over time, ${timer} remaining`}
|
||||
title={`${label} · ${timer} remaining`}
|
||||
aria-describedby={onInspect ? tooltipId : undefined}
|
||||
title={onInspect ? undefined : `${label} · ${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}
|
||||
>
|
||||
{ability?.icon
|
||||
? <img src={ability.icon} alt="" draggable={false} />
|
||||
@@ -69,14 +98,25 @@ function LiveTimedEffectStrip({
|
||||
*/
|
||||
export function TimedEffectStrip({
|
||||
targetId,
|
||||
targetIds,
|
||||
kinds = ["dot", "hot"],
|
||||
className,
|
||||
onInspect,
|
||||
tooltipId,
|
||||
}: TimedEffectStripProps) {
|
||||
const allEffects = useCombatStore((state) => state.effects);
|
||||
const requestedTargetIds = targetIds ?? [targetId];
|
||||
const effects = allEffects.filter((effect) => (
|
||||
effect.targetId === targetId && kinds.includes(effect.kind)
|
||||
requestedTargetIds.includes(effect.targetId) && kinds.includes(effect.kind)
|
||||
));
|
||||
|
||||
if (!effects.length) return null;
|
||||
return <LiveTimedEffectStrip effects={effects} className={className} />;
|
||||
return (
|
||||
<LiveTimedEffectStrip
|
||||
effects={effects}
|
||||
className={className}
|
||||
onInspect={onInspect}
|
||||
tooltipId={tooltipId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AuraDisposition, DispelCategory } from "../game/combatAuras";
|
||||
|
||||
export interface StatusEffectInspection {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly disposition: AuraDisposition;
|
||||
readonly description: string;
|
||||
readonly remainingLabel: string;
|
||||
readonly stacks: number;
|
||||
readonly targetLabel?: string;
|
||||
readonly dispelCategory?: DispelCategory;
|
||||
}
|
||||
|
||||
export type StatusEffectInspectHandler = (effect: StatusEffectInspection | null) => void;
|
||||
|
||||
export function timedEffectDescription(
|
||||
kind: "dot" | "hot",
|
||||
authoredDescription?: string,
|
||||
): string {
|
||||
const authored = authoredDescription?.trim();
|
||||
if (authored) return authored;
|
||||
return kind === "hot"
|
||||
? "Restores health periodically while active."
|
||||
: "Deals damage periodically while active.";
|
||||
}
|
||||
Reference in new issue
Block a user