2026-03-12 16:25:04 -07:00
|
|
|
import { useEffect, useEffectEvent, useRef } from "react";
|
2025-11-13 22:55:58 -08:00
|
|
|
import { useFrame, useThree } from "@react-three/fiber";
|
2026-02-12 17:54:51 -08:00
|
|
|
import { useKeyboardControls } from "@react-three/drei";
|
2025-11-14 00:15:28 -08:00
|
|
|
import { PointerLockControls } from "three-stdlib";
|
2026-03-13 11:08:11 -07:00
|
|
|
import {
|
|
|
|
|
MAX_SPEED_MULTIPLIER,
|
|
|
|
|
MIN_SPEED_MULTIPLIER,
|
|
|
|
|
useControls,
|
|
|
|
|
} from "./SettingsProvider";
|
2025-11-26 14:37:49 -08:00
|
|
|
import { useCameras } from "./CamerasProvider";
|
2026-03-13 11:08:11 -07:00
|
|
|
import { useInputContext } from "./InputContext";
|
2026-03-15 09:07:36 -07:00
|
|
|
import { useTouchDevice } from "./useTouchDevice";
|
2025-11-13 22:55:58 -08:00
|
|
|
|
2026-02-12 17:54:51 -08:00
|
|
|
export enum Controls {
|
2025-11-13 22:55:58 -08:00
|
|
|
forward = "forward",
|
|
|
|
|
backward = "backward",
|
|
|
|
|
left = "left",
|
|
|
|
|
right = "right",
|
|
|
|
|
up = "up",
|
|
|
|
|
down = "down",
|
2026-02-15 08:16:48 -08:00
|
|
|
lookUp = "lookUp",
|
|
|
|
|
lookDown = "lookDown",
|
|
|
|
|
lookLeft = "lookLeft",
|
|
|
|
|
lookRight = "lookRight",
|
2025-11-26 17:19:17 -08:00
|
|
|
camera1 = "camera1",
|
|
|
|
|
camera2 = "camera2",
|
|
|
|
|
camera3 = "camera3",
|
|
|
|
|
camera4 = "camera4",
|
|
|
|
|
camera5 = "camera5",
|
|
|
|
|
camera6 = "camera6",
|
|
|
|
|
camera7 = "camera7",
|
|
|
|
|
camera8 = "camera8",
|
|
|
|
|
camera9 = "camera9",
|
2025-11-13 22:55:58 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-12 16:25:04 -07:00
|
|
|
export const KEYBOARD_CONTROLS = [
|
|
|
|
|
{ name: Controls.forward, keys: ["KeyW"] },
|
|
|
|
|
{ name: Controls.backward, keys: ["KeyS"] },
|
|
|
|
|
{ name: Controls.left, keys: ["KeyA"] },
|
|
|
|
|
{ name: Controls.right, keys: ["KeyD"] },
|
|
|
|
|
{ name: Controls.up, keys: ["Space"] },
|
|
|
|
|
{ name: Controls.down, keys: ["ShiftLeft", "ShiftRight"] },
|
|
|
|
|
{ name: Controls.lookUp, keys: ["ArrowUp"] },
|
|
|
|
|
{ name: Controls.lookDown, keys: ["ArrowDown"] },
|
|
|
|
|
{ name: Controls.lookLeft, keys: ["ArrowLeft"] },
|
|
|
|
|
{ name: Controls.lookRight, keys: ["ArrowRight"] },
|
|
|
|
|
{ name: Controls.camera1, keys: ["Digit1"] },
|
|
|
|
|
{ name: Controls.camera2, keys: ["Digit2"] },
|
|
|
|
|
{ name: Controls.camera3, keys: ["Digit3"] },
|
|
|
|
|
{ name: Controls.camera4, keys: ["Digit4"] },
|
|
|
|
|
{ name: Controls.camera5, keys: ["Digit5"] },
|
|
|
|
|
{ name: Controls.camera6, keys: ["Digit6"] },
|
|
|
|
|
{ name: Controls.camera7, keys: ["Digit7"] },
|
|
|
|
|
{ name: Controls.camera8, keys: ["Digit8"] },
|
|
|
|
|
{ name: Controls.camera9, keys: ["Digit9"] },
|
|
|
|
|
];
|
|
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
const MIN_SPEED_ADJUSTMENT = 2;
|
2026-03-13 18:04:02 -07:00
|
|
|
const MAX_SPEED_ADJUSTMENT = 11;
|
2026-02-11 22:29:09 -08:00
|
|
|
const DRAG_THRESHOLD = 3; // px of movement before it counts as a drag
|
2025-11-13 22:55:58 -08:00
|
|
|
|
2026-03-13 20:16:47 -07:00
|
|
|
/** Hardcoded drag sensitivity (not affected by user setting). */
|
|
|
|
|
const DRAG_SENSITIVITY = 0.002;
|
2026-03-09 12:38:40 -07:00
|
|
|
export const ARROW_LOOK_SPEED = 1; // radians/sec
|
|
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
function quantizeSpeed(speedMultiplier: number): number {
|
|
|
|
|
// Map [0.01, 1] → [1/16, 1], snapped to the 6-bit grid (multiples of 1/16).
|
|
|
|
|
const t =
|
|
|
|
|
(speedMultiplier - MIN_SPEED_MULTIPLIER) / (1 - MIN_SPEED_MULTIPLIER);
|
|
|
|
|
const steps = Math.round(t * 15); // 0..15 → 16 levels (1/16 to 16/16)
|
|
|
|
|
return (steps + 1) / 16;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 18:04:02 -07:00
|
|
|
export function MouseAndKeyboardHandler() {
|
2026-03-15 09:07:36 -07:00
|
|
|
const isTouch = useTouchDevice();
|
|
|
|
|
|
2026-03-12 16:25:04 -07:00
|
|
|
// Don't let KeyboardControls handle stuff when metaKey is held.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
|
|
|
// Let Cmd/Ctrl+K pass through for search focus.
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.metaKey) {
|
|
|
|
|
e.stopImmediatePropagation();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keydown", handleKey, { capture: true });
|
|
|
|
|
window.addEventListener("keyup", handleKey, { capture: true });
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener("keydown", handleKey, { capture: true });
|
|
|
|
|
window.removeEventListener("keyup", handleKey, { capture: true });
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-13 20:16:47 -07:00
|
|
|
const {
|
|
|
|
|
speedMultiplier,
|
|
|
|
|
setSpeedMultiplier,
|
|
|
|
|
mouseSensitivity,
|
|
|
|
|
invertScroll,
|
|
|
|
|
invertDrag,
|
|
|
|
|
} = useControls();
|
2026-03-13 11:08:11 -07:00
|
|
|
const { onInput, mode } = useInputContext();
|
2025-11-13 22:55:58 -08:00
|
|
|
const [subscribe, getKeys] = useKeyboardControls<Controls>();
|
2026-03-12 16:25:04 -07:00
|
|
|
const camera = useThree((state) => state.camera);
|
|
|
|
|
const gl = useThree((state) => state.gl);
|
2025-11-26 17:19:17 -08:00
|
|
|
const { nextCamera, setCameraIndex, cameraCount } = useCameras();
|
2025-11-14 00:15:28 -08:00
|
|
|
const controlsRef = useRef<PointerLockControls | null>(null);
|
2025-11-13 22:55:58 -08:00
|
|
|
|
2026-03-12 16:25:04 -07:00
|
|
|
const getInvertScroll = useEffectEvent(() => invertScroll);
|
|
|
|
|
const getInvertDrag = useEffectEvent(() => invertDrag);
|
2026-03-13 18:04:02 -07:00
|
|
|
const getMode = useEffectEvent(() => mode);
|
2026-03-13 20:16:47 -07:00
|
|
|
const getMouseSensitivity = useEffectEvent(() => mouseSensitivity);
|
2026-03-15 09:07:36 -07:00
|
|
|
const getIsTouch = useEffectEvent(() => isTouch);
|
2026-03-12 16:25:04 -07:00
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
// Accumulated mouse deltas between frames.
|
|
|
|
|
const mouseDeltaYaw = useRef(0);
|
|
|
|
|
const mouseDeltaPitch = useRef(0);
|
|
|
|
|
|
|
|
|
|
// Trigger flags set by event handlers, consumed in useFrame.
|
|
|
|
|
const triggerFire = useRef(false);
|
|
|
|
|
const triggerObserve = useRef(false);
|
2025-11-13 22:55:58 -08:00
|
|
|
|
2025-11-14 00:15:28 -08:00
|
|
|
// Setup pointer lock controls
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const controls = new PointerLockControls(camera, gl.domElement);
|
|
|
|
|
controlsRef.current = controls;
|
|
|
|
|
|
2025-12-16 17:31:13 -08:00
|
|
|
return () => {
|
|
|
|
|
controls.dispose();
|
|
|
|
|
};
|
|
|
|
|
}, [camera, gl.domElement]);
|
|
|
|
|
|
2026-03-15 09:07:36 -07:00
|
|
|
// Exit pointer lock when switching to touch mode.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isTouch && controlsRef.current?.isLocked) {
|
|
|
|
|
controlsRef.current.unlock();
|
|
|
|
|
}
|
|
|
|
|
}, [isTouch]);
|
|
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
// Mouse handling: accumulate deltas for input frames.
|
|
|
|
|
// In local mode, drag-to-look works without pointer lock.
|
|
|
|
|
// Pointer lock and click behavior depend on mode.
|
2025-12-16 17:31:13 -08:00
|
|
|
useEffect(() => {
|
2026-02-11 22:29:09 -08:00
|
|
|
const canvas = gl.domElement;
|
|
|
|
|
let dragging = false;
|
|
|
|
|
let didDrag = false;
|
|
|
|
|
let startX = 0;
|
|
|
|
|
let startY = 0;
|
|
|
|
|
|
|
|
|
|
const handleMouseDown = (e: MouseEvent) => {
|
|
|
|
|
if (controlsRef.current?.isLocked) return;
|
|
|
|
|
if (e.target !== canvas) return;
|
|
|
|
|
dragging = true;
|
|
|
|
|
didDrag = false;
|
|
|
|
|
startX = e.clientX;
|
|
|
|
|
startY = e.clientY;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
2026-03-13 11:08:11 -07:00
|
|
|
if (controlsRef.current?.isLocked) {
|
|
|
|
|
// Pointer is locked: accumulate raw deltas.
|
2026-03-13 20:16:47 -07:00
|
|
|
const sens = getMouseSensitivity();
|
|
|
|
|
mouseDeltaYaw.current += e.movementX * sens;
|
|
|
|
|
mouseDeltaPitch.current += e.movementY * sens;
|
2026-03-13 11:08:11 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 22:29:09 -08:00
|
|
|
if (!dragging) return;
|
|
|
|
|
if (
|
|
|
|
|
!didDrag &&
|
|
|
|
|
Math.abs(e.clientX - startX) < DRAG_THRESHOLD &&
|
|
|
|
|
Math.abs(e.clientY - startY) < DRAG_THRESHOLD
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
didDrag = true;
|
|
|
|
|
|
2026-03-13 18:04:02 -07:00
|
|
|
// In follow/orbit mode, drag direction is reversed because the camera
|
|
|
|
|
// orbits around a target — dragging right should move the camera right
|
|
|
|
|
// (decreasing yaw), opposite of fly mode.
|
|
|
|
|
const orbitFlip = getMode() === "follow" ? -1 : 1;
|
|
|
|
|
const dragSign = (getInvertDrag() ? 1 : -1) * orbitFlip;
|
2026-03-13 20:16:47 -07:00
|
|
|
mouseDeltaYaw.current += dragSign * e.movementX * DRAG_SENSITIVITY;
|
|
|
|
|
mouseDeltaPitch.current += dragSign * e.movementY * DRAG_SENSITIVITY;
|
2026-02-11 22:29:09 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = () => {
|
|
|
|
|
dragging = false;
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-14 00:15:28 -08:00
|
|
|
const handleClick = (e: MouseEvent) => {
|
2025-12-16 17:31:13 -08:00
|
|
|
const controls = controlsRef.current;
|
2026-03-13 11:08:11 -07:00
|
|
|
if (controls?.isLocked) {
|
|
|
|
|
if (mode === "follow") {
|
|
|
|
|
// In follow mode, click cycles to next player (trigger 0).
|
|
|
|
|
triggerFire.current = true;
|
|
|
|
|
} else if (mode === "local") {
|
|
|
|
|
// In local mode, click while locked cycles preset cameras.
|
|
|
|
|
nextCamera();
|
|
|
|
|
}
|
|
|
|
|
// In fly mode, clicks while locked do nothing special.
|
2026-03-15 09:07:36 -07:00
|
|
|
} else if (e.target === canvas && !didDrag && !getIsTouch()) {
|
2026-03-13 11:08:11 -07:00
|
|
|
controls?.lock();
|
2025-11-14 00:15:28 -08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-11 22:29:09 -08:00
|
|
|
canvas.addEventListener("mousedown", handleMouseDown);
|
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
2025-11-26 14:37:49 -08:00
|
|
|
document.addEventListener("click", handleClick);
|
2025-11-14 00:15:28 -08:00
|
|
|
|
|
|
|
|
return () => {
|
2026-02-11 22:29:09 -08:00
|
|
|
canvas.removeEventListener("mousedown", handleMouseDown);
|
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
|
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
2025-11-26 14:37:49 -08:00
|
|
|
document.removeEventListener("click", handleClick);
|
2025-11-14 00:15:28 -08:00
|
|
|
};
|
2026-03-13 11:08:11 -07:00
|
|
|
}, [camera, gl.domElement, nextCamera, mode]);
|
2025-11-14 00:15:28 -08:00
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
// Handle number keys 1-9 for camera selection (local-only UI action).
|
2025-11-26 17:19:17 -08:00
|
|
|
useEffect(() => {
|
|
|
|
|
const cameraControls = [
|
|
|
|
|
Controls.camera1,
|
|
|
|
|
Controls.camera2,
|
|
|
|
|
Controls.camera3,
|
|
|
|
|
Controls.camera4,
|
|
|
|
|
Controls.camera5,
|
|
|
|
|
Controls.camera6,
|
|
|
|
|
Controls.camera7,
|
|
|
|
|
Controls.camera8,
|
|
|
|
|
Controls.camera9,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return subscribe((state) => {
|
|
|
|
|
for (let i = 0; i < cameraControls.length; i++) {
|
|
|
|
|
if (state[cameraControls[i]] && i < cameraCount) {
|
|
|
|
|
setCameraIndex(i);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, [subscribe, setCameraIndex, cameraCount]);
|
|
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
// Handle mousewheel for speed adjustment (local setting, stays in KMH).
|
2025-11-14 00:15:28 -08:00
|
|
|
useEffect(() => {
|
|
|
|
|
const handleWheel = (e: WheelEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
2026-03-12 16:25:04 -07:00
|
|
|
const scrollSign = getInvertScroll() ? -1 : 1;
|
|
|
|
|
const direction = (e.deltaY > 0 ? -1 : 1) * scrollSign;
|
2025-11-14 00:15:28 -08:00
|
|
|
|
2026-03-13 18:04:02 -07:00
|
|
|
// scale deltaY in a way that feels natural for both trackpads (often just
|
|
|
|
|
// a deltaY of 1 at a time!) and scroll wheels (can be 100s or more).
|
|
|
|
|
const scaledDeltaY = Math.ceil(Math.log2(Math.abs(e.deltaY) + 1));
|
|
|
|
|
|
2025-11-13 23:41:10 -08:00
|
|
|
const delta =
|
|
|
|
|
Math.max(
|
|
|
|
|
MIN_SPEED_ADJUSTMENT,
|
2026-03-13 18:04:02 -07:00
|
|
|
Math.min(MAX_SPEED_ADJUSTMENT, scaledDeltaY),
|
2025-11-13 23:41:10 -08:00
|
|
|
) * direction;
|
|
|
|
|
|
|
|
|
|
setSpeedMultiplier((prev) => {
|
2026-03-13 11:08:11 -07:00
|
|
|
const newSpeed = Math.round(prev * 100) + delta;
|
|
|
|
|
return Math.max(
|
|
|
|
|
MIN_SPEED_MULTIPLIER,
|
|
|
|
|
Math.min(MAX_SPEED_MULTIPLIER, newSpeed / 100),
|
|
|
|
|
);
|
2025-11-13 23:41:10 -08:00
|
|
|
});
|
2025-11-14 00:15:28 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const canvas = gl.domElement;
|
|
|
|
|
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
canvas.removeEventListener("wheel", handleWheel);
|
|
|
|
|
};
|
2025-12-29 20:02:54 -08:00
|
|
|
}, [gl.domElement, setSpeedMultiplier]);
|
2025-11-14 00:15:28 -08:00
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
// 'O' key: toggle observer mode (sets trigger 2).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (mode === "local") return;
|
|
|
|
|
|
|
|
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.code !== "KeyO" || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
if (
|
|
|
|
|
target.tagName === "INPUT" ||
|
|
|
|
|
target.tagName === "TEXTAREA" ||
|
|
|
|
|
target.isContentEditable
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
triggerObserve.current = true;
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", handleKey);
|
|
|
|
|
return () => window.removeEventListener("keydown", handleKey);
|
|
|
|
|
}, [mode]);
|
2026-03-09 12:38:40 -07:00
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
// Build and emit InputFrame each render frame.
|
|
|
|
|
useFrame((_state, delta) => {
|
2026-02-15 08:16:48 -08:00
|
|
|
const {
|
|
|
|
|
forward,
|
|
|
|
|
backward,
|
|
|
|
|
left,
|
|
|
|
|
right,
|
|
|
|
|
up,
|
|
|
|
|
down,
|
|
|
|
|
lookUp,
|
|
|
|
|
lookDown,
|
|
|
|
|
lookLeft,
|
|
|
|
|
lookRight,
|
|
|
|
|
} = getKeys();
|
|
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
// Arrow keys contribute to look deltas.
|
|
|
|
|
let deltaYaw = mouseDeltaYaw.current;
|
|
|
|
|
let deltaPitch = mouseDeltaPitch.current;
|
|
|
|
|
mouseDeltaYaw.current = 0;
|
|
|
|
|
mouseDeltaPitch.current = 0;
|
|
|
|
|
|
|
|
|
|
if (lookLeft) deltaYaw -= ARROW_LOOK_SPEED * delta;
|
|
|
|
|
if (lookRight) deltaYaw += ARROW_LOOK_SPEED * delta;
|
|
|
|
|
if (lookUp) deltaPitch -= ARROW_LOOK_SPEED * delta;
|
|
|
|
|
if (lookDown) deltaPitch += ARROW_LOOK_SPEED * delta;
|
|
|
|
|
|
|
|
|
|
// Movement axes, pre-scaled by speedMultiplier (clamped to [-1, 1]).
|
|
|
|
|
let x = 0;
|
|
|
|
|
let y = 0;
|
|
|
|
|
let z = 0;
|
|
|
|
|
if (left) x -= 1;
|
|
|
|
|
if (right) x += 1;
|
|
|
|
|
if (forward) y += 1;
|
|
|
|
|
if (backward) y -= 1;
|
|
|
|
|
if (up) z += 1;
|
|
|
|
|
if (down) z -= 1;
|
|
|
|
|
|
|
|
|
|
const quantizedSpeedMultiplier = quantizeSpeed(speedMultiplier);
|
|
|
|
|
|
|
|
|
|
x = Math.max(-1, Math.min(1, x * quantizedSpeedMultiplier));
|
|
|
|
|
y = Math.max(-1, Math.min(1, y * quantizedSpeedMultiplier));
|
|
|
|
|
z = Math.max(-1, Math.min(1, z * quantizedSpeedMultiplier));
|
|
|
|
|
|
|
|
|
|
// Triggers.
|
|
|
|
|
const triggers = [false, false, false, false, false, false];
|
|
|
|
|
if (triggerFire.current) {
|
|
|
|
|
triggers[0] = true;
|
|
|
|
|
triggerFire.current = false;
|
2026-02-15 08:16:48 -08:00
|
|
|
}
|
2026-03-13 11:08:11 -07:00
|
|
|
if (triggerObserve.current) {
|
|
|
|
|
triggers[2] = true;
|
|
|
|
|
triggerObserve.current = false;
|
2025-11-13 22:55:58 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-13 11:08:11 -07:00
|
|
|
// Only emit if there's actual input.
|
|
|
|
|
const hasLook = deltaYaw !== 0 || deltaPitch !== 0;
|
|
|
|
|
const hasMove = x !== 0 || y !== 0 || z !== 0;
|
|
|
|
|
const hasTriggers = triggers.some(Boolean);
|
|
|
|
|
if (!hasLook && !hasMove && !hasTriggers) return;
|
|
|
|
|
|
|
|
|
|
onInput({
|
|
|
|
|
deltaYaw,
|
|
|
|
|
deltaPitch,
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
z,
|
|
|
|
|
triggers,
|
|
|
|
|
delta,
|
|
|
|
|
});
|
2025-11-13 22:55:58 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|