Release Healer Man 0.1.43

This commit is contained in:
phenom committed 2026-09-14 13:20:20 -04:00
1 parent c7e8163790
commit ccc01396cd
86 files changed
+2951 -198

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
import {
cloneElement, useEffect, useId, useLayoutEffect, useRef, useState,
type ButtonHTMLAttributes, type ReactElement,
} from "react";
import { createPortal } from "react-dom";
import type { AbilityTarget } from "../game/abilityCatalog";
const INSPECT_EVENT = "healer-man:ability-tooltip-inspect";
export interface AbilityTooltipData {
name: string;
description?: string;
resourceCost?: number;
resourceLabel?: string;
cooldownSeconds?: number;
castSeconds?: number;
range?: number;
target?: AbilityTarget;
passive?: boolean;
rank?: number;
lockedUntilLevel?: number;
talentLocked?: boolean;
}
export function abilityTooltipFacts(ability: AbilityTooltipData): string[] {
const facts: string[] = [];
if (ability.passive) facts.push("Passive");
if (ability.rank !== undefined) facts.push(`Rank ${ability.rank}`);
if (!ability.passive) {
if (ability.resourceCost !== undefined) facts.push(ability.resourceCost > 0
? `${Number(ability.resourceCost.toFixed(2))} ${ability.resourceLabel ?? "resource"}` : "No cost");
if (ability.castSeconds !== undefined) facts.push(ability.castSeconds > 0 ? `${ability.castSeconds} sec cast` : "Instant");
if (ability.cooldownSeconds !== undefined) facts.push(ability.cooldownSeconds > 0 ? `${ability.cooldownSeconds} sec cooldown` : "No cooldown");
if (ability.range !== undefined && ability.range > 0) facts.push(`${ability.range} m range`);
if (ability.target) facts.push({ friendly: "Selected ally or self", hostile: "Enemy target", any: "Enemy or ally", self: "Self" }[ability.target]);
}
return facts;
}
export function abilityTooltipPosition(
anchor: { left: number; right: number; top: number; bottom: number },
size: { width: number; height: number },
viewport: { width: number; height: number },
): { left: number; top: number } {
const margin = 12;
const gap = 8;
const clamp = (value: number, maximum: number) => Math.max(margin, Math.min(value, maximum - margin));
const above = anchor.top - size.height - gap;
return {
left: clamp((anchor.left + anchor.right - size.width) / 2, viewport.width - size.width),
top: clamp(above >= margin ? above : anchor.bottom + gap, viewport.height - size.height),
};
}
export function AbilityTooltipContent({ ability, bindingLabel }: { ability: AbilityTooltipData; bindingLabel?: string }) {
return <>
<header><strong>{ability.name}</strong>{bindingLabel && <kbd>{bindingLabel}</kbd>}</header>
<p>{ability.description || "No description available for this ability."}</p>
<footer>{abilityTooltipFacts(ability).map((fact) => <span key={fact}>{fact}</span>)}</footer>
{ability.lockedUntilLevel !== undefined && <p className="ability-tooltip__lock">Unlocks at level {ability.lockedUntilLevel}</p>}
{ability.talentLocked && <p className="ability-tooltip__lock">Learn in talents</p>}
</>;
}
/** Adds inspection without a layout wrapper or changing the control's activation behavior. */
export function AbilityTooltipTarget({ ability, bindingLabel, children }: {
ability: AbilityTooltipData | null | undefined;
bindingLabel?: string;
children: ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>;
}) {
const id = useId();
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
const [position, setPosition] = useState({ left: 12, top: 12 });
const tooltip = useRef<HTMLDivElement>(null);
const hovering = useRef(false);
const keyboardFocused = useRef(false);
const visible = Boolean(anchor && ability);
const inspect = (element: HTMLButtonElement) => {
window.dispatchEvent(new CustomEvent(INSPECT_EVENT, { detail: id }));
setAnchor(element);
};
useLayoutEffect(() => {
if (!anchor || !tooltip.current || !ability) return;
setPosition(abilityTooltipPosition(anchor.getBoundingClientRect(), tooltip.current.getBoundingClientRect(), {
width: window.innerWidth, height: window.innerHeight,
}));
}, [anchor, ability, bindingLabel]);
useEffect(() => {
if (!visible) return;
const dismiss = () => {
hovering.current = false;
keyboardFocused.current = false;
setAnchor(null);
};
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") dismiss(); };
const onInspect = (event: Event) => { if ((event as CustomEvent<string>).detail !== id) dismiss(); };
window.addEventListener(INSPECT_EVENT, onInspect);
window.addEventListener("keydown", onKey);
window.addEventListener("blur", dismiss);
window.addEventListener("resize", dismiss);
window.addEventListener("scroll", dismiss, true);
return () => {
window.removeEventListener(INSPECT_EVENT, onInspect);
window.removeEventListener("keydown", onKey);
window.removeEventListener("blur", dismiss);
window.removeEventListener("resize", dismiss);
window.removeEventListener("scroll", dismiss, true);
};
}, [visible, id]);
const original = children.props;
return <>
{cloneElement(children, {
title: ability ? undefined : original.title,
"aria-describedby": [original["aria-describedby"], visible ? id : undefined].filter(Boolean).join(" ") || undefined,
onPointerEnter: (event) => {
original.onPointerEnter?.(event);
if (event.pointerType === "touch" || !ability) return;
hovering.current = true;
inspect(event.currentTarget);
},
onPointerLeave: (event) => {
original.onPointerLeave?.(event);
hovering.current = false;
if (!keyboardFocused.current) setAnchor(null);
},
onFocus: (event) => {
original.onFocus?.(event);
keyboardFocused.current = event.currentTarget.matches(":focus-visible");
if (keyboardFocused.current && ability) inspect(event.currentTarget);
},
onBlur: (event) => {
original.onBlur?.(event);
keyboardFocused.current = false;
if (!hovering.current) setAnchor(null);
},
})}
{visible && createPortal(
<div ref={tooltip} id={id} role="tooltip" className="ability-tooltip" style={position}>
<AbilityTooltipContent ability={ability!} bindingLabel={bindingLabel} />
</div>, document.body,
)}
</>;
}