126 lines
5.9 KiB
TypeScript
126 lines
5.9 KiB
TypeScript
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<number, { x: number; y: number }>();
|
|
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);
|
|
},
|
|
};
|
|
}
|