forked from phenom/healer-man
88 lines
7.4 KiB
TypeScript
88 lines
7.4 KiB
TypeScript
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>;
|
||
}
|