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