Files
healer-man/src/ui/ActionBar.tsx
T
2026-09-11 14:26:15 -04:00

198 lines
7.9 KiB
TypeScript

import { useEffect, useState, type CSSProperties } from "react";
import type { AbilityTarget } from "../game/abilityCatalog";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import {
ACTION_LAYER_LABELS,
type ActionBindingControl,
type ActionBindingLayer,
} from "../game/actionBindings";
export interface ActionBarItem {
id: string;
name: string;
shortLabel?: string;
iconUrl?: string;
description?: string;
color?: string;
resourceCost?: number;
resourceLabel?: string;
cooldownRemaining?: number;
cooldownDuration?: number;
disabled?: boolean;
lockedUntilLevel?: number;
target?: AbilityTarget;
}
export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>;
export interface ActionBarProps {
items: ActionBarItems;
activeLayer: ActionBindingLayer;
onActivate: (control: ActionBindingControl) => void;
inputMode?: "keyboard" | "gamepad";
feedback?: string | null;
variant?: "hud" | "compact";
}
function cooldownPercent(remaining = 0, duration = 0): number {
if (duration <= 0 || remaining <= 0) return 0;
return Math.max(0, Math.min(100, (remaining / duration) * 100));
}
export function ActionIcon({ item }: { item: ActionBarItem | null }) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [item?.iconUrl]);
if (!item) return <span className="action-slot__icon" aria-hidden="true" />;
return (
<span className="action-slot__icon" aria-hidden="true">
{item.iconUrl && !failed
? <img src={item.iconUrl} alt="" onError={() => setFailed(true)} />
: item.shortLabel ?? item.name.slice(0, 2).toUpperCase()}
</span>
);
}
function ActionSlot({
control,
item,
layer,
inputMode,
onActivate,
onInspect,
onStopInspecting,
}: {
control: ActionBindingControl;
item: ActionBarItem | null;
layer: ActionBindingLayer;
inputMode: "keyboard" | "gamepad";
onActivate: (control: ActionBindingControl) => void;
onInspect: (control: ActionBindingControl) => void;
onStopInspecting: (control: ActionBindingControl) => void;
}) {
const cooldown = cooldownPercent(item?.cooldownRemaining, item?.cooldownDuration);
const unavailable = !item || Boolean(item.disabled);
const style = {
"--ability-color": item?.color ?? "#789b68",
"--cooldown-fill": `${cooldown}%`,
} as CSSProperties;
const label = useInputBindingLabels();
const chord = label(inputMode === "gamepad" ? "controller" : "pc", `ability:${layer}:${control}`);
const bindingLabel = chord.replace(/ \/ [^+]+/g, "").replace(/Left stick/g, "LS").replace(/Right stick/g, "RS");
const cooldownLabel = (item?.cooldownRemaining ?? 0) > 0.05
? ", cooling down"
: "";
const targetLabel = item?.target === "friendly"
? ", targets selected ally or self"
: item?.target === "any"
? ", targets selected enemy or ally"
: "";
const resourceLabel = item?.resourceCost
? `, costs ${item.resourceCost} ${item.resourceLabel ?? "resource"}`
: "";
const lockedLabel = item?.lockedUntilLevel
? `, unlocks at level ${item.lockedUntilLevel}`
: "";
return (
<button
type="button"
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
style={style}
aria-disabled={unavailable}
aria-label={item ? `${item.name}, ${chord}${targetLabel}${resourceLabel}${lockedLabel}${cooldownLabel}` : `Empty binding, ${chord}`}
title={item ? `${item.description ?? item.name}\n${item.lockedUntilLevel ? `Unlocks at level ${item.lockedUntilLevel}\n` : ""}${chord}${targetLabel}` : `${chord}: Empty binding`}
onFocus={() => onInspect(control)}
onBlur={() => onStopInspecting(control)}
onPointerEnter={() => onInspect(control)}
onPointerLeave={(event) => {
if (event.currentTarget !== document.activeElement) onStopInspecting(control);
}}
onClick={() => {
onInspect(control);
if (!unavailable) onActivate(control);
}}
>
<kbd className="action-slot__binding" aria-hidden="true">{bindingLabel}</kbd>
<ActionIcon item={item} />
<strong>{item?.name ?? "Empty"}</strong>
{item?.lockedUntilLevel ? <span className="action-slot__unlock">LVL {item.lockedUntilLevel}</span> : null}
{item?.target === "friendly" ? <span className="action-slot__target">ALLY</span> : null}
{item?.target === "any" ? <span className="action-slot__target">ANY</span> : null}
{item?.resourceCost ? <small aria-hidden="true">{item.resourceCost}</small> : null}
{cooldown > 0 && <i className="action-slot__cooldown" aria-hidden="true" />}
{(item?.cooldownRemaining ?? 0) > 0.05 && (
<em>{item!.cooldownRemaining! < 10 ? item!.cooldownRemaining!.toFixed(1) : Math.ceil(item!.cooldownRemaining!)}</em>
)}
</button>
);
}
export function ActionBar({
items,
activeLayer,
onActivate,
inputMode = "keyboard",
feedback,
variant = "hud",
}: ActionBarProps) {
const [inspectedControl, setInspectedControl] = useState<ActionBindingControl | null>(null);
const inspectedItem = inspectedControl ? items[inspectedControl] : null;
const stopInspecting = (control: ActionBindingControl) => {
setInspectedControl((current) => current === control ? null : current);
};
const slot = (control: ActionBindingControl) => (
<ActionSlot
key={control}
control={control}
item={items[control]}
layer={activeLayer}
inputMode={inputMode}
onActivate={onActivate}
onInspect={setInspectedControl}
onStopInspecting={stopInspecting}
/>
);
return (
<section
className={`action-bar action-bar--${variant} action-bar--layer-${activeLayer}`}
aria-label={`${ACTION_LAYER_LABELS[activeLayer]} controller abilities`}
>
<div className="action-bar__feedback" role="status" aria-live="polite">{feedback ?? ""}</div>
<div className="action-bar__layer-status" aria-label={`Active layer: ${ACTION_LAYER_LABELS[activeLayer]}`}>
<span className={activeLayer === "primary" ? "is-active" : ""}>Base</span>
<span className={activeLayer === "secondary" ? "is-active" : ""}>{inputMode === "gamepad" ? "L1" : "Shift"}</span>
<span className={activeLayer === "tertiary" ? "is-active" : ""}>{inputMode === "gamepad" ? "L2" : "Alt"}</span>
</div>
<div className="action-bar__controller">
<div className="action-cluster action-cluster--dpad" role="group" aria-label="D-pad abilities">
{slot("dpad-left")}
<span className="action-cluster__dpad-center" aria-hidden="true">+</span>
{slot("dpad-right")}
</div>
<div className="action-cluster action-cluster--shoulders" role="group" aria-label="Right shoulder abilities">
{slot("r1")}
{slot("r2")}
</div>
<div className="action-cluster action-cluster--face" role="group" aria-label="Face button abilities">
{slot("face-top")}
{slot("face-right")}
{slot("face-bottom")}
{slot("face-left")}
</div>
<div className="action-bar__detail" aria-hidden="true">
<strong title={inspectedItem?.name}>{inspectedItem?.name ?? `${ACTION_LAYER_LABELS[activeLayer]} layer`}</strong>
<span>{inspectedItem
? inspectedItem.lockedUntilLevel
? `Unlocks at level ${inspectedItem.lockedUntilLevel}`
: inspectedItem.resourceCost
? `${inspectedItem.resourceCost} ${inspectedItem.resourceLabel ?? "resource"}`
: inspectedItem.target === "friendly" ? "Friendly target"
: inspectedItem.target === "any" ? "Enemy or friendly target" : "Ready"
: inspectedControl ? "Empty binding" : "Focus a button for details"}</span>
</div>
</div>
</section>
);
}