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(null); const dragRef = useRef(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) => { 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) => { 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 (
event.stopPropagation()} >
Target {currentTarget?.name ?? "None"}
{rows.map((row) => (
{row.name}{row.forced ? "TAUNT" : `${Math.round(row.percentOfLeader)}%`}
))}
); }