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";
|
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";
|
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
|
|
|
|
2026-03-13 11:08:11 -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);
|
2026-03-13 11:08:11 -07:00
|
|
|
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();
|
2026-03-13 11:08:11 -07:00
|
|
|
const { onInput, mode } = useInputContext();
|
2026-03-22 21:11:02 -07:00
|
|
|
const [, getInputState] = useInputState();
|
2026-03-12 16:25:04 -07:00
|
|
|
const gl = useThree((state) => state.gl);
|
2026-03-22 21:11:02 -07:00
|
|
|
const { setCameraIndex, cameraCount } = useCameras();
|
2026-03-13 11:08:11 -07:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
});
|
2025-11-26 17:19:17 -08:00
|
|
|
|
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),
|
|
|
|
|
);
|
2025-11-26 17:19:17 -08:00
|
|
|
});
|
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
|
|
|
|
2026-03-13 11:08:11 -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
|
|
|
|
2026-03-13 11:08:11 -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-02-15 08:16:48 -08:00
|
|
|
|
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-13 11:08:11 -07:00
|
|
|
|
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-13 11:08:11 -07:00
|
|
|
|
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 ──
|
2026-03-13 11:08:11 -07:00
|
|
|
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;
|
2026-03-13 11:08:11 -07:00
|
|
|
|
|
|
|
|
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 ──
|
2026-03-13 11:08:11 -07:00
|
|
|
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-22 21:11:02 -07:00
|
|
|
// Always clear deltas so stale values don't linger in the store.
|
|
|
|
|
clearInputDeltas();
|
|
|
|
|
|
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;
|
|
|
|
|
}
|