input and tour improvements, bug fixes

This commit is contained in:
Brian Beck 2026-03-22 21:11:02 -07:00
parent fe90146e1e
commit 90ec7cbae2
110 changed files with 2802 additions and 1286 deletions

View file

@ -1,7 +1,5 @@
import { useEffect, useEffectEvent, useRef } from "react";
import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei";
import { PointerLockControls } from "three-stdlib";
import {
MAX_SPEED_MULTIPLIER,
MIN_SPEED_MULTIPLIER,
@ -11,99 +9,39 @@ import { useCameras } from "./CamerasProvider";
import { useInputContext } from "./InputContext";
import { useTouchDevice } from "./useTouchDevice";
import { cameraTourStore } from "../state/cameraTourStore";
import {
useInputAction,
useInputState,
clearInputDeltas,
type ActionState,
type DragState,
type KeyState,
type ScrollState,
} from "./InputControls";
export const Controls = {
forward: "forward",
backward: "backward",
left: "left",
right: "right",
up: "up",
down: "down",
lookUp: "lookUp",
lookDown: "lookDown",
lookLeft: "lookLeft",
lookRight: "lookRight",
camera1: "camera1",
camera2: "camera2",
camera3: "camera3",
camera4: "camera4",
camera5: "camera5",
camera6: "camera6",
camera7: "camera7",
camera8: "camera8",
camera9: "camera9",
} as const;
export type ControlName = (typeof Controls)[keyof typeof Controls];
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"] },
];
const TOUR_CANCEL_KEYS = new Set([
"KeyW", "KeyA", "KeyS", "KeyD",
"Space", "ShiftLeft", "ShiftRight",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
]);
const MIN_SPEED_ADJUSTMENT = 2;
const MAX_SPEED_ADJUSTMENT = 11;
const DRAG_THRESHOLD = 3; // px of movement before it counts as a drag
/** Hardcoded drag sensitivity (not affected by user setting). */
const DRAG_SENSITIVITY = 0.002;
export const ARROW_LOOK_SPEED = 1; // radians/sec
const MIN_SPEED_ADJUSTMENT = 1;
const MAX_SPEED_ADJUSTMENT = 11;
/** Hardcoded drag sensitivity for non-locked drag (not affected by user setting). */
const DRAG_SENSITIVITY = 0.002;
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)
const steps = Math.round(t * 15);
return (steps + 1) / 16;
}
function isPressed(state: Record<string, ActionState>, name: string): boolean {
const s = state[name];
return s != null && "pressed" in s && (s as KeyState).pressed;
}
export function MouseAndKeyboardHandler() {
const isTouch = useTouchDevice();
// 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 });
};
}, []);
const {
speedMultiplier,
setSpeedMultiplier,
@ -112,269 +50,136 @@ export function MouseAndKeyboardHandler() {
invertDrag,
} = useControls();
const { onInput, mode } = useInputContext();
const [subscribe, getKeys] = useKeyboardControls<ControlName>();
const camera = useThree((state) => state.camera);
const [, getInputState] = useInputState();
const gl = useThree((state) => state.gl);
const { nextCamera, setCameraIndex, cameraCount } = useCameras();
const controlsRef = useRef<PointerLockControls | null>(null);
const getInvertScroll = useEffectEvent(() => invertScroll);
const getInvertDrag = useEffectEvent(() => invertDrag);
const getMode = useEffectEvent(() => mode);
const getMouseSensitivity = useEffectEvent(() => mouseSensitivity);
const getIsTouch = useEffectEvent(() => isTouch);
// Accumulated mouse deltas between frames.
const mouseDeltaYaw = useRef(0);
const mouseDeltaPitch = useRef(0);
const { setCameraIndex, cameraCount } = useCameras();
// Trigger flags set by event handlers, consumed in useFrame.
const triggerFire = useRef(false);
const triggerObserve = useRef(false);
// Setup pointer lock controls
// Exit pointer lock when switching to touch mode.
useEffect(() => {
const controls = new PointerLockControls(camera, gl.domElement);
controlsRef.current = controls;
return () => {
controls.dispose();
};
}, [camera, gl.domElement]);
// Exit pointer lock when switching to touch mode or when a tour starts.
useEffect(() => {
if (isTouch && controlsRef.current?.isLocked) {
controlsRef.current.unlock();
if (isTouch && document.pointerLockElement) {
document.exitPointerLock();
}
}, [isTouch]);
// Exit pointer lock when a tour starts.
useEffect(() => {
return cameraTourStore.subscribe((state) => {
if (state.animation && controlsRef.current?.isLocked) {
controlsRef.current.unlock();
if (state.animation && document.pointerLockElement) {
document.exitPointerLock();
}
});
}, []);
// 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.
useEffect(() => {
const canvas = gl.domElement;
let dragging = false;
let didDrag = false;
let startX = 0;
let startY = 0;
// Canvas click: lock pointer (only fires when not already locked).
useInputAction("canvasClick", () => {
if (!isTouch && !cameraTourStore.getState().animation) {
gl.domElement.requestPointerLock();
}
});
const handleMouseDown = (e: MouseEvent) => {
if (controlsRef.current?.isLocked) return;
if (e.target !== canvas) return;
dragging = true;
didDrag = false;
startX = e.clientX;
startY = e.clientY;
};
// Next player (live observer follow mode): fire trigger 0.
useInputAction("nextPlayer", () => {
triggerFire.current = true;
});
const handleMouseMove = (e: MouseEvent) => {
if (controlsRef.current?.isLocked) {
// Pointer is locked: accumulate raw deltas.
const sens = getMouseSensitivity();
mouseDeltaYaw.current += e.movementX * sens;
mouseDeltaPitch.current += e.movementY * sens;
return;
}
// Handle mousewheel for speed adjustment.
useInputAction("adjustSpeed", () => {
const scroll = getInputState().adjustSpeed as ScrollState | undefined;
if (!scroll || scroll.deltaY === 0) return;
if (!dragging) return;
if (
!didDrag &&
Math.abs(e.clientX - startX) < DRAG_THRESHOLD &&
Math.abs(e.clientY - startY) < DRAG_THRESHOLD
) {
return;
}
didDrag = true;
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;
// 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;
mouseDeltaYaw.current += dragSign * e.movementX * DRAG_SENSITIVITY;
mouseDeltaPitch.current += dragSign * e.movementY * DRAG_SENSITIVITY;
};
const handleMouseUp = () => {
dragging = false;
};
const handleClick = (e: MouseEvent) => {
const controls = controlsRef.current;
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.
} else if (e.target === canvas && !didDrag && !getIsTouch() && !cameraTourStore.getState().animation) {
controls?.lock();
}
};
canvas.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("click", handleClick);
return () => {
canvas.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("click", handleClick);
};
}, [camera, gl.domElement, nextCamera, mode]);
// Handle number keys 1-9 for camera selection (local-only UI action).
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;
}
}
setSpeedMultiplier((prev) => {
const newSpeed = Math.round(prev * 100) + speedDelta;
return Math.max(
MIN_SPEED_MULTIPLIER,
Math.min(MAX_SPEED_MULTIPLIER, newSpeed / 100),
);
});
}, [subscribe, setCameraIndex, cameraCount]);
});
// Handle mousewheel for speed adjustment (local setting, stays in KMH).
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const scrollSign = getInvertScroll() ? -1 : 1;
const direction = (e.deltaY > 0 ? -1 : 1) * scrollSign;
// 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));
const delta =
Math.max(
MIN_SPEED_ADJUSTMENT,
Math.min(MAX_SPEED_ADJUSTMENT, scaledDeltaY),
) * direction;
setSpeedMultiplier((prev) => {
const newSpeed = Math.round(prev * 100) + delta;
return Math.max(
MIN_SPEED_MULTIPLIER,
Math.min(MAX_SPEED_MULTIPLIER, newSpeed / 100),
);
});
};
const canvas = gl.domElement;
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => {
canvas.removeEventListener("wheel", handleWheel);
};
}, [gl.domElement, setSpeedMultiplier]);
// Escape or movement keys: cancel active camera tour.
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (!cameraTourStore.getState().animation) return;
if (e.code === "Escape" || TOUR_CANCEL_KEYS.has(e.code)) {
cameraTourStore.getState().cancel();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, []);
// 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));
// '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]);
useInputAction("toggleObserverMode", () => {
triggerObserve.current = true;
});
// Build and emit InputFrame each render frame.
useFrame((_state, delta) => {
// Suppress all input while a camera tour is active.
if (cameraTourStore.getState().animation) return;
const {
forward,
backward,
left,
right,
up,
down,
lookUp,
lookDown,
lookLeft,
lookRight,
} = getKeys();
const inputState = getInputState();
// ── 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;
}
// 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;
}
// Arrow keys contribute to look deltas.
let deltaYaw = mouseDeltaYaw.current;
let deltaPitch = mouseDeltaPitch.current;
mouseDeltaYaw.current = 0;
mouseDeltaPitch.current = 0;
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;
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]).
// ── Movement axes ──
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;
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));
// Triggers.
// ── Triggers ──
const triggers = [false, false, false, false, false, false];
if (triggerFire.current) {
triggers[0] = true;
@ -385,6 +190,9 @@ export function MouseAndKeyboardHandler() {
triggerObserve.current = false;
}
// 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;