// Floating Joystick // Purpose: Defines the Floating Joystick module and the local helpers/components used in this file. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. import { useCallback, useEffect, useRef, useState } from 'react'; import { clampUnit } from '../../controls/controlMath.js'; export default function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) { const containerRef = useRef(null); const pointerIdRef = useRef(null); const baseRef = useRef({ x: 0, y: 0 }); const [visual, setVisual] = useState({ active: false, base: { x: 0, y: 0 }, knob: { x: 0, y: 0 } }); const stopTracking = useCallback(() => { pointerIdRef.current = null; setVisual({ active: false, base: { x: 0, y: 0 }, knob: { x: 0, y: 0 } }); onStop?.(); }, [onStop]); const handlePointerDown = useCallback( (event) => { if (disabled) return; if (pointerIdRef.current !== null) return; event.preventDefault(); const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; baseRef.current = { x, y }; pointerIdRef.current = event.pointerId; container.setPointerCapture?.(event.pointerId); setVisual({ active: true, base: { x, y }, knob: { x: 0, y: 0 } }); }, [disabled], ); const handlePointerMove = useCallback( (event) => { if (disabled || pointerIdRef.current !== event.pointerId) return; event.preventDefault(); const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const currentX = event.clientX - rect.left; const currentY = event.clientY - rect.top; const dx = currentX - baseRef.current.x; const dy = currentY - baseRef.current.y; const distance = Math.min(Math.hypot(dx, dy), radius); const angle = Math.atan2(dy, dx); const knobX = Math.cos(angle) * distance; const knobY = Math.sin(angle) * distance; const vector = { x: clampUnit(knobX / radius), y: clampUnit(-knobY / radius), boost: false, }; setVisual((prev) => ({ ...prev, knob: { x: knobX, y: knobY } })); onMove?.(vector); }, [disabled, onMove, radius], ); const handlePointerEnd = useCallback( (event) => { if (pointerIdRef.current !== event.pointerId) return; event.preventDefault(); const container = containerRef.current; container?.releasePointerCapture?.(event.pointerId); stopTracking(); }, [stopTracking], ); useEffect(() => { if (disabled) stopTracking(); }, [disabled, stopTracking]); const heightClass = 'h-full'; return (