t2-mapper/src/components/MouseAndKeyboardHandler.tsx

215 lines
6.8 KiB
TypeScript
Raw Normal View History

2026-03-22 21:11:02 -07:00
import { useEffect, useRef } from "react";
2025-11-13 22:55:58 -08:00
import { useFrame, useThree } from "@react-three/fiber";
import {
MAX_SPEED_MULTIPLIER,
MIN_SPEED_MULTIPLIER,
useControls,
} from "./SettingsProvider";
2025-11-26 14:37:49 -08:00
import { useCameras } from "./CamerasProvider";
import { useInputContext } from "./InputContext";
2026-03-15 09:07:36 -07:00
import { useTouchDevice } from "./useTouchDevice";
2026-03-18 06:26:17 -07:00
import { cameraTourStore } from "../state/cameraTourStore";
2026-03-22 21:11:02 -07:00
import {
useInputAction,
useInputState,
clearInputDeltas,
type ActionState,
type DragState,
type KeyState,
type ScrollState,
} from "./InputControls";
2025-11-13 22:55:58 -08:00
2026-03-22 21:11:02 -07:00
export const ARROW_LOOK_SPEED = 1; // radians/sec
2026-03-18 06:26:17 -07:00
2026-03-22 21:11:02 -07:00
const MIN_SPEED_ADJUSTMENT = 1;
2026-03-13 18:04:02 -07:00
const MAX_SPEED_ADJUSTMENT = 11;
2025-11-13 22:55:58 -08:00
2026-03-22 21:11:02 -07:00
/** Hardcoded drag sensitivity for non-locked drag (not affected by user setting). */
2026-03-13 20:16:47 -07:00
const DRAG_SENSITIVITY = 0.002;
2026-03-09 12:38:40 -07:00
function quantizeSpeed(speedMultiplier: number): number {
const t =
(speedMultiplier - MIN_SPEED_MULTIPLIER) / (1 - MIN_SPEED_MULTIPLIER);
2026-03-22 21:11:02 -07:00
const steps = Math.round(t * 15);
return (steps + 1) / 16;
}
2026-03-22 21:11:02 -07:00
function isPressed(state: Record<string, ActionState>, name: string): boolean {
const s = state[name];
return s != null && "pressed" in s && (s as KeyState).pressed;
}
2026-03-13 18:04:02 -07:00
export function MouseAndKeyboardHandler() {
2026-03-15 09:07:36 -07:00
const isTouch = useTouchDevice();
2026-03-13 20:16:47 -07:00
const {
speedMultiplier,
setSpeedMultiplier,
mouseSensitivity,
invertScroll,
invertDrag,
} = useControls();
const { onInput, mode } = useInputContext();
2026-03-22 21:11:02 -07:00
const [, getInputState] = useInputState();
const gl = useThree((state) => state.gl);
2026-03-22 21:11:02 -07:00
const { setCameraIndex, cameraCount } = useCameras();
// 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
2026-03-22 21:11:02 -07:00
// Exit pointer lock when switching to touch mode.
2026-03-15 09:07:36 -07:00
useEffect(() => {
2026-03-22 21:11:02 -07:00
if (isTouch && document.pointerLockElement) {
document.exitPointerLock();
2026-03-15 09:07:36 -07:00
}
}, [isTouch]);
2026-03-22 21:11:02 -07:00
// Exit pointer lock when a tour starts.
2026-03-18 06:26:17 -07:00
useEffect(() => {
return cameraTourStore.subscribe((state) => {
2026-03-22 21:11:02 -07:00
if (state.animation && document.pointerLockElement) {
document.exitPointerLock();
2026-03-18 06:26:17 -07:00
}
});
}, []);
2026-03-22 21:11:02 -07:00
// Canvas click: lock pointer (only fires when not already locked).
useInputAction("canvasClick", () => {
if (!isTouch && !cameraTourStore.getState().animation) {
gl.domElement.requestPointerLock();
}
});
2025-11-14 00:15:28 -08:00
2026-03-22 21:11:02 -07:00
// Next player (live observer follow mode): fire trigger 0.
useInputAction("nextPlayer", () => {
triggerFire.current = true;
});
2026-03-22 21:11:02 -07:00
// Handle mousewheel for speed adjustment.
useInputAction("adjustSpeed", () => {
const scroll = getInputState().adjustSpeed as ScrollState | undefined;
if (!scroll || scroll.deltaY === 0) return;
const scrollSign = invertScroll ? -1 : 1;
const direction = (scroll.deltaY > 0 ? -1 : 1) * scrollSign;
const scaledDeltaY = Math.ceil(Math.log2(Math.abs(scroll.deltaY) + 1));
const speedDelta =
Math.max(
MIN_SPEED_ADJUSTMENT,
Math.min(MAX_SPEED_ADJUSTMENT, scaledDeltaY),
) * direction;
setSpeedMultiplier((prev) => {
const newSpeed = Math.round(prev * 100) + speedDelta;
return Math.max(
MIN_SPEED_MULTIPLIER,
Math.min(MAX_SPEED_MULTIPLIER, newSpeed / 100),
);
});
2026-03-22 21:11:02 -07:00
});
2025-11-14 00:15:28 -08:00
2026-03-22 21:11:02 -07:00
// Handle number keys 1-9 for camera selection.
const selectCamera = (i: number) => {
if (i < cameraCount) setCameraIndex(i);
};
useInputAction("camera1", () => selectCamera(0));
useInputAction("camera2", () => selectCamera(1));
useInputAction("camera3", () => selectCamera(2));
useInputAction("camera4", () => selectCamera(3));
useInputAction("camera5", () => selectCamera(4));
useInputAction("camera6", () => selectCamera(5));
useInputAction("camera7", () => selectCamera(6));
useInputAction("camera8", () => selectCamera(7));
useInputAction("camera9", () => selectCamera(8));
2026-03-18 06:26:17 -07:00
// 'O' key: toggle observer mode (sets trigger 2).
2026-03-22 21:11:02 -07:00
useInputAction("toggleObserverMode", () => {
triggerObserve.current = true;
});
2026-03-09 12:38:40 -07:00
// Build and emit InputFrame each render frame.
useFrame((_state, delta) => {
2026-03-18 06:26:17 -07:00
// Suppress all input while a camera tour is active.
if (cameraTourStore.getState().animation) return;
2026-03-22 21:11:02 -07:00
const inputState = getInputState();
2026-03-22 21:11:02 -07:00
// ── Look deltas ──
let deltaYaw = 0;
let deltaPitch = 0;
// Pointer-locked mouse movement (raw deltas, user sensitivity).
const locked = inputState.lockedLook as DragState | undefined;
if (locked && (locked.deltaX !== 0 || locked.deltaY !== 0)) {
deltaYaw = locked.deltaX * mouseSensitivity;
deltaPitch = locked.deltaY * mouseSensitivity;
}
2026-03-22 21:11:02 -07:00
// Drag-to-look (unlocked, fixed sensitivity, orbit flip in follow mode).
const drag = inputState.dragLook as DragState | undefined;
if (drag?.dragging && (drag.deltaX !== 0 || drag.deltaY !== 0)) {
const orbitFlip = mode === "follow" ? -1 : 1;
const dragSign = (invertDrag ? 1 : -1) * orbitFlip;
deltaYaw += dragSign * drag.deltaX * DRAG_SENSITIVITY;
deltaPitch += dragSign * drag.deltaY * DRAG_SENSITIVITY;
}
2026-03-22 21:11:02 -07:00
// Arrow keys contribute to look deltas.
if (isPressed(inputState, "lookLeft")) deltaYaw -= ARROW_LOOK_SPEED * delta;
if (isPressed(inputState, "lookRight"))
deltaYaw += ARROW_LOOK_SPEED * delta;
if (isPressed(inputState, "lookUp")) deltaPitch -= ARROW_LOOK_SPEED * delta;
if (isPressed(inputState, "lookDown"))
deltaPitch += ARROW_LOOK_SPEED * delta;
// ── Movement axes ──
let x = 0;
let y = 0;
let z = 0;
2026-03-22 21:11:02 -07:00
if (isPressed(inputState, "moveLeft")) x -= 1;
if (isPressed(inputState, "moveRight")) x += 1;
if (isPressed(inputState, "moveForward")) y += 1;
if (isPressed(inputState, "moveBackward")) y -= 1;
if (isPressed(inputState, "moveUp")) z += 1;
if (isPressed(inputState, "moveDown")) 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));
2026-03-22 21:11:02 -07:00
// ── Triggers ──
const triggers = [false, false, false, false, false, false];
if (triggerFire.current) {
triggers[0] = true;
triggerFire.current = false;
}
if (triggerObserve.current) {
triggers[2] = true;
triggerObserve.current = false;
2025-11-13 22:55:58 -08:00
}
2026-03-22 21:11:02 -07:00
// Always clear deltas so stale values don't linger in the store.
clearInputDeltas();
// 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;
}