forked from phenom/healer-man
198 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|