Files
healer-man/src/ui/ArenaActionBar.tsx
T
2026-09-14 13:20:20 -04:00

88 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from "react";
import type { CharacterProfile } from "../app/types";
import { arenaCharacterAbilities, arenaAbilityIsLearned, createArenaCharacterKit } from "../game/arenaCharacter";
import { ACTION_CONTROLS, actionBindingId, type ActionBindingLayer, type ActionBindingControl } from "../game/actionBindings";
import type { ArenaActorSnapshot } from "../game/arenaTypes";
import { actionKeyLabel } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useArenaStore } from "../game/arenaStore";
import { AbilityTooltipTarget } from "./AbilityTooltip";
const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const;
export function ArenaActionBar({ character, actor, layer, inputMode, blocked, onCast, feedback }: {
character: CharacterProfile;
actor: ArenaActorSnapshot | null;
layer: ActionBindingLayer;
inputMode: "keyboard" | "gamepad";
blocked: boolean;
onCast: (abilityId: string) => void;
feedback: string;
}) {
const ui = useUiLayoutStore();
const bindingLabel = useInputBindingLabels();
const level = actor?.level ?? character.level;
const kit = useMemo(() => createArenaCharacterKit(character, level), [character, level]);
const abilities = useMemo(() => arenaCharacterAbilities(character, level), [character, level]);
const layout = ui.preferences.current[currentUiDevice()];
const bindings = actor?.actionBindings ?? kit.actionBindings;
const desktopBars = layout.wowBars ? layout.bars.slice(0, layout.barCount).map((bar) => ({ ...bar, slots: bar.slots.map((slot) => ({ ...slot, label: actionKeyLabel(slot.key) })) }))
: [{ columns: 8, slots: PC_ACTION_CONTROLS.map((control) => ({ abilityId: bindings[actionBindingId(layer, control)], label: bindingLabel("pc", `ability:${layer}:${control}`) })) }];
const items = Object.fromEntries(abilities.map((ability): [string, ActionBarItem] => {
const learned = actor?.abilityIds ? actor.abilityIds.includes(ability.id) : arenaAbilityIsLearned(ability, character, level);
const remaining = Math.max(actor?.cooldowns[ability.id] ?? 0, actor?.gcdRemainingMs ?? 0) / 1000;
return [ability.id, {
id: ability.id, name: ability.name, iconUrl: ability.icon, shortLabel: ability.sigil,
description: ability.description, target: ability.target,
resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
cooldownRemaining: remaining,
cooldownSeconds: ability.cooldownMs / 1000, castSeconds: ability.castTimeMs / 1000, range: ability.range.max,
disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
lockedUntilLevel: learned ? undefined : ability.unlockLevel,
}];
}));
const casting = actor?.effects.find((effect) => effect.id.startsWith("casting:"));
const castName = casting ? items[casting.id.slice(8)]?.name : null;
const castBinding = (control: ActionBindingControl) => {
const id = bindings[actionBindingId(layer, control)];
if (id) onCast(id);
};
return <div className="arena-player-hud">
<div className="arena-player-status"><strong>{character.name}</strong><span>Level {level} · {kit.classId.replace("rom-", "")} · {kit.resource.name} {actor?.resource ?? 0}/{actor?.maxResource ?? kit.resource.maximum}</span></div>
<div className="arena-extra-resources" aria-label="Character resources">
{Object.entries(actor?.combat?.resourcePools ?? {}).filter(([type]) => type !== actor?.resourceType).map(([type, amount]) => <span key={type}>{type}: {Math.round(amount ?? 0)}/{actor?.combat?.maxResourcePools[type as keyof typeof actor.combat.maxResourcePools] ?? 0}</span>)}
{(actor?.combat?.comboPoints ?? 0) > 0 && <span>Combo points: {actor!.combat!.comboPoints}/5</span>}
{actor?.combat?.runes.map((rune) => <span key={rune.slot}>{rune.type}: {Math.max(0, (rune.readyAt - (useArenaStore.getState().snapshot?.serverTime ?? 0)) / 1000).toFixed(1)}s</span>)}
{(actor?.combat?.shield ?? 0) > 0 && <span>Absorb: {Math.round(actor!.combat!.shield)}</span>}
</div>
<div className="arena-aura-strip" aria-label="Your buffs and debuffs">{actor?.effects.filter((effect) => !effect.id.startsWith("casting:")).slice(0, 12).map((effect, index) => <span className={effect.disposition === "debuff" ? "is-debuff" : ""} key={`${effect.id}:${index}`} title={effect.id}>{effect.icon && <img src={effect.icon} alt="" />}{effect.id.replace(/ \(DBC.*$/, "")} {effect.stacks && effect.stacks > 1 ? `×${effect.stacks}` : ""} {effect.remainingMs > 0 ? `${Math.ceil(effect.remainingMs / 1000)}s` : ""}</span>)}</div>
{(actor?.combat?.summons.length ?? 0) > 0 && <div className="arena-pet-orders" aria-label="Pet commands">{(["attack", "defend", "stop", "recall"] as const).map((command) => <button key={command} type="button" onClick={() => useArenaStore.getState().intent({ type: "command", command })}>{command}</button>)}</div>}
{castName && <div className="arena-cast-status" role="status">{actor?.combat?.activeCast?.mode === "channel" ? "Channeling" : "Casting"} {castName} · {(casting!.remainingMs / 1000).toFixed(1)}s <button type="button" onClick={() => useArenaStore.getState().intent({ type: "command", command: "cancel-cast" })}>Cancel</button></div>}
{feedback && <div className="arena-action-feedback" role="status">{feedback}</div>}
{currentUiDevice() === "pc" && inputMode !== "gamepad" ? (
<nav className="arena-personal-bars" aria-label="Your arena action bars">
{desktopBars.map((bar, index) => <section key={index} aria-label={`Action bar ${index + 1}`} style={{ gridTemplateColumns: `repeat(${bar.columns}, minmax(0, 1fr))` }}>
{bar.slots.map((slot, slotIndex) => {
const item = slot.abilityId ? items[slot.abilityId] : null;
return <AbilityTooltipTarget key={slotIndex} ability={item} bindingLabel={slot.label}>
<button type="button" aria-disabled={!item || item.disabled}
aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"}
onClick={() => item && !item.disabled && onCast(item.id)}>
<kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span>
{item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>}
</button></AbilityTooltipTarget>;
})}
</section>)}
</nav>
) : <ActionBar variant="compact" inputMode={inputMode} activeLayer={layer}
items={Object.fromEntries(ACTION_CONTROLS.map((control) => [control, items[bindings[actionBindingId(layer, control)] ?? ""] ?? null])) as ActionBarItems}
onActivate={castBinding} />}
<small className="arena-control-hints">{inputMode === "gamepad"
? `Left stick move · Right stick look · ${bindingLabel("controller", "target-next")} enemy · ${bindingLabel("controller", "party-next")} ally`
: `${["move-forward", "move-left", "move-backward", "move-right"].map((id) => bindingLabel("pc", id)).join("/")} move · Hold right mouse to look · ${bindingLabel("pc", "target-next")} enemy · Click a team frame to target`}</small>
</div>;
}