Files
healer-man/src/ui/ThreatMeterWidget.tsx
T
2026-08-14 15:56:39 -04:00

185 lines
6.2 KiB
TypeScript

import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
clampHudPositionToViewport,
type NormalizedHudPosition,
} from "../game/hudLayout";
import type { ThreatMeterRow } from "../game/threatMeter";
export interface ThreatMeterWidgetProps {
readonly targetName: string;
readonly rows: readonly ThreatMeterRow[];
readonly position: NormalizedHudPosition;
readonly onPositionChange: (position: NormalizedHudPosition) => void;
}
interface DragState {
readonly pointerId: number;
readonly offsetX: number;
readonly offsetY: number;
}
function positionsMatch(left: NormalizedHudPosition, right: NormalizedHudPosition): boolean {
return Math.abs(left.x - right.x) < 0.0001 && Math.abs(left.y - right.y) < 0.0001;
}
export function ThreatMeterWidget({
targetName,
rows,
position,
onPositionChange,
}: ThreatMeterWidgetProps) {
const panelRef = useRef<HTMLElement>(null);
const dragRef = useRef<DragState | null>(null);
const latestPositionRef = useRef(position);
const [localPosition, setLocalPosition] = useState(position);
latestPositionRef.current = localPosition;
useEffect(() => {
if (dragRef.current || positionsMatch(position, latestPositionRef.current)) return;
latestPositionRef.current = position;
setLocalPosition(position);
}, [position]);
const constrain = useCallback((candidate: NormalizedHudPosition) => {
const rect = panelRef.current?.getBoundingClientRect();
return clampHudPositionToViewport(
candidate,
{ width: window.innerWidth, height: window.innerHeight },
{ width: rect?.width ?? 156, height: rect?.height ?? 92 },
);
}, []);
const setConstrainedPosition = useCallback((candidate: NormalizedHudPosition, commit: boolean) => {
const next = constrain(candidate);
latestPositionRef.current = next;
setLocalPosition(next);
if (commit) onPositionChange(next);
}, [constrain, onPositionChange]);
useLayoutEffect(() => {
const keepVisible = () => {
const next = constrain(latestPositionRef.current);
if (!positionsMatch(next, latestPositionRef.current)) setConstrainedPosition(next, true);
};
keepVisible();
window.addEventListener("resize", keepVisible);
return () => window.removeEventListener("resize", keepVisible);
}, [constrain, setConstrainedPosition]);
useEffect(() => {
const moveDrag = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
setConstrainedPosition({
x: (event.clientX - drag.offsetX) / Math.max(1, window.innerWidth),
y: (event.clientY - drag.offsetY) / Math.max(1, window.innerHeight),
}, false);
};
const finishDrag = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
dragRef.current = null;
panelRef.current?.classList.remove("is-dragging");
onPositionChange(latestPositionRef.current);
};
window.addEventListener("pointermove", moveDrag);
window.addEventListener("pointerup", finishDrag);
window.addEventListener("pointercancel", finishDrag);
return () => {
window.removeEventListener("pointermove", moveDrag);
window.removeEventListener("pointerup", finishDrag);
window.removeEventListener("pointercancel", finishDrag);
};
}, [onPositionChange, setConstrainedPosition]);
const beginDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
if (document.pointerLockElement) document.exitPointerLock();
const rect = panelRef.current?.getBoundingClientRect();
dragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - (rect ? rect.left + rect.width / 2 : event.clientX),
offsetY: event.clientY - (rect ? rect.top + rect.height / 2 : event.clientY),
};
event.currentTarget.setPointerCapture(event.pointerId);
panelRef.current?.classList.add("is-dragging");
};
const moveWithKeyboard = (event: KeyboardEvent<HTMLButtonElement>) => {
const direction = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
}[event.key];
if (!direction) return;
event.preventDefault();
event.stopPropagation();
const step = event.shiftKey ? 0.05 : 0.015;
setConstrainedPosition({
x: latestPositionRef.current.x + direction[0] * step,
y: latestPositionRef.current.y + direction[1] * step,
}, true);
};
const currentTarget = rows.find((row) => row.currentTarget) ?? null;
const widgetStyle = {
left: `${localPosition.x * 100}%`,
top: `${localPosition.y * 100}%`,
} as CSSProperties;
return (
<section
ref={panelRef}
className="threat-widget"
style={widgetStyle}
aria-label={`Threat meter for ${targetName}`}
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
className="threat-widget__drag-handle"
aria-label={`Move threat meter for ${targetName}. Use arrow keys for precise movement.`}
onPointerDown={beginDrag}
onKeyDown={moveWithKeyboard}
>
<strong>Threat</strong>
<span>{targetName}</span>
<i aria-hidden="true">::</i>
</button>
<div className="threat-widget__victim">
<span>Target</span>
<b>{currentTarget?.name ?? "None"}</b>
</div>
<div className="threat-widget__rows">
{rows.map((row) => (
<div
key={row.actorId}
className={`threat-widget__row threat-widget__row--${row.role} ${row.currentTarget ? "is-target" : ""} ${row.forced ? "is-forced" : ""}`}
>
<span><b>{row.name}</b><i>{row.forced ? "TAUNT" : `${Math.round(row.percentOfLeader)}%`}</i></span>
<em aria-hidden="true"><i style={{ width: `${row.percentOfLeader}%` }} /></em>
</div>
))}
</div>
</section>
);
}