import { MathUtils, PerspectiveCamera, type Vector3 } from 'three'; import { SCENE } from '../constants/scene.ts'; import { MOVEMENT } from '../constants/movement.ts'; export function createFpsCameraController(camera: PerspectiveCamera, canvas: HTMLCanvasElement, pickDestination: (x: number, y: number) => Vector3 | null, options: { onTurn?: (radians: number) => void; onZoom?: (factor: number) => void; onSelect?: (x: number, y: number) => boolean; onModeChange?: (turning: boolean) => void } = {}) { let destination: Vector3 | null = null; let arrivalYaw: number | undefined; let turning = false; const pointers = new Map(); let pinched = false; const pinchDistance = () => { const [a, b] = [...pointers.values()]; return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; }; let gesture: { id: number; startX: number; startY: number; x: number; y: number; dragged: boolean; canMove: boolean } | null = null; canvas.tabIndex = 0; const cancelGesture = () => { const ids = [...pointers.keys()]; gesture = null; pointers.clear(); pinched = false; for (const id of ids) if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id); }; const stop = () => { destination = null; arrivalYaw = undefined; }; const blur = () => { stop(); cancelGesture(); }; const setTurning = (value: boolean) => { stop(); cancelGesture(); turning = value; options.onModeChange?.(value); }; const clear = () => setTurning(false); const down = (event: PointerEvent) => { if (pointers.size >= 2 || (event.pointerType !== 'touch' && (!event.isPrimary || pointers.size)) || (event.button !== 0 && event.button !== 2)) return; event.preventDefault(); canvas.focus({ preventScroll: true }); pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); canvas.setPointerCapture(event.pointerId); if (pointers.size > 1) { pinched = true; return; } gesture = { id: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, dragged: false, canMove: event.pointerType !== 'mouse' || event.button === 2 }; }; const move = (event: PointerEvent) => { if (!pointers.has(event.pointerId)) return; const before = pinchDistance(); pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); if (pinched) { const after = pinchDistance(); if (before > 0 && after > 0) options.onZoom?.(before / after); return; } if (!gesture || gesture.id !== event.pointerId) return; if (Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) > MOVEMENT.dragThreshold) gesture.dragged = true; if (gesture.dragged) { if (turning) options.onTurn?.((event.clientX - gesture.x) * MOVEMENT.turnSensitivity); else { camera.rotation.reorder('YXZ'); camera.rotation.y -= (event.clientX - gesture.x) * MOVEMENT.dragSensitivity; camera.rotation.x = MathUtils.clamp(camera.rotation.x - (event.clientY - gesture.y) * MOVEMENT.dragSensitivity, -MathUtils.degToRad(SCENE.pitchLimitDegrees), MathUtils.degToRad(SCENE.pitchLimitDegrees)); } } gesture.x = event.clientX; gesture.y = event.clientY; }; const up = (event: PointerEvent) => { if (!pointers.has(event.pointerId)) return; if (pinched) { pointers.delete(event.pointerId); if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); if (!pointers.size) cancelGesture(); return; } if (!gesture || gesture.id !== event.pointerId) return; const tap = !gesture.dragged && Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <= MOVEMENT.dragThreshold; if (tap && !turning) { if (options.onSelect?.(event.clientX, event.clientY)) stop(); else if (gesture.canMove) { destination = pickDestination(event.clientX, event.clientY); arrivalYaw = undefined; } } cancelGesture(); }; const cancel = (event: PointerEvent) => { if (pointers.has(event.pointerId)) cancelGesture(); }; const wheel = (event: WheelEvent) => { if (!options.onZoom) return; event.preventDefault(); options.onZoom(Math.exp(MathUtils.clamp(event.deltaY, -100, 100) * MOVEMENT.wheelSensitivity)); }; const escape = (event: KeyboardEvent) => { if (event.code === 'Escape') clear(); }; const contextMenu = (event: Event) => event.preventDefault(); window.addEventListener('blur', clear); window.addEventListener('keydown', escape); canvas.addEventListener('blur', blur); canvas.addEventListener('wheel', wheel, { passive: false }); document.addEventListener('visibilitychange', clear); canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', cancel); canvas.addEventListener('lostpointercapture', cancel); canvas.addEventListener('contextmenu', contextMenu); return { get destination() { return destination; }, get arrivalYaw() { return arrivalYaw; }, get turning() { return turning; }, setTurning, moveTo(point: Vector3, yaw?: number) { setTurning(false); destination = point.clone(); arrivalYaw = yaw; }, stop, active() { return document.activeElement === canvas; }, clear, dispose() { clear(); window.removeEventListener('blur', clear); window.removeEventListener('keydown', escape); canvas.removeEventListener('blur', blur); canvas.removeEventListener('wheel', wheel); document.removeEventListener('visibilitychange', clear); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', cancel); canvas.removeEventListener('lostpointercapture', cancel); canvas.removeEventListener('contextmenu', contextMenu); }, }; }