Improve controls, add mobile joystick control

This commit is contained in:
Brian Beck 2026-02-11 22:29:09 -08:00
parent 0c6aa3b4dd
commit 5214a8556b
33 changed files with 381 additions and 68 deletions

View file

@ -186,6 +186,7 @@ export function MissionSelect({
missionName: newValue,
missionType: newMissionType,
});
inputRef.current?.blur();
}
},
setValue: (value) => {
@ -272,6 +273,11 @@ export function MissionSelect({
document.exitPointerLock();
combobox.show();
}}
onKeyDown={(e) => {
if (e.key === "Escape" && !combobox.getState().open) {
inputRef.current?.blur();
}
}}
/>
<div className="MissionSelect-selectedValue">
<span className="MissionSelect-selectedName">{displayValue}</span>
@ -286,7 +292,12 @@ export function MissionSelect({
</div>
<kbd className="MissionSelect-shortcut">{isMac ? "⌘K" : "^K"}</kbd>
</div>
<ComboboxPopover gutter={4} fitViewport className="MissionSelect-popover">
<ComboboxPopover
gutter={4}
fitViewport
autoFocusOnHide={false}
className="MissionSelect-popover"
>
<ComboboxList className="MissionSelect-list">
{filteredResults.type === "flat"
? filteredResults.missions.map(renderItem)

View file

@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { Vector3 } from "three";
import { Euler, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { KeyboardControls, useKeyboardControls } from "@react-three/drei";
import { PointerLockControls } from "three-stdlib";
@ -27,6 +27,9 @@ enum Controls {
const BASE_SPEED = 80;
const MIN_SPEED_ADJUSTMENT = 0.05;
const MAX_SPEED_ADJUSTMENT = 0.5;
const DRAG_SENSITIVITY = 0.003;
const MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
const DRAG_THRESHOLD = 3; // px of movement before it counts as a drag
function CameraMovement() {
const { speedMultiplier, setSpeedMultiplier } = useControls();
@ -50,23 +53,68 @@ function CameraMovement() {
};
}, [camera, gl.domElement]);
// When pointer is locked: click cycles camera.
// When pointer is unlocked: drag rotates camera, click locks pointer.
useEffect(() => {
const canvas = gl.domElement;
const euler = new Euler(0, 0, 0, "YXZ");
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) => {
if (!dragging) return;
if (
!didDrag &&
Math.abs(e.clientX - startX) < DRAG_THRESHOLD &&
Math.abs(e.clientY - startY) < DRAG_THRESHOLD
) {
return;
}
didDrag = true;
euler.setFromQuaternion(camera.quaternion, "YXZ");
euler.y -= e.movementX * DRAG_SENSITIVITY;
euler.x -= e.movementY * DRAG_SENSITIVITY;
euler.x = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, euler.x));
camera.quaternion.setFromEuler(euler);
};
const handleMouseUp = () => {
dragging = false;
};
const handleClick = (e: MouseEvent) => {
const controls = controlsRef.current;
if (!controls || controls.isLocked) {
nextCamera();
} else if (e.target === gl.domElement) {
// Only lock if clicking directly on the canvas (not on UI elements)
} else if (e.target === canvas && !didDrag) {
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);
};
}, [gl.domElement, nextCamera]);
}, [camera, gl.domElement, nextCamera]);
// Handle number keys 1-9 for camera selection
useEffect(() => {

View file

@ -0,0 +1,187 @@
import { useEffect, useRef, type RefObject } from "react";
import { Euler, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import type nipplejs from "nipplejs";
import { useControls } from "./SettingsProvider";
const BASE_SPEED = 80;
const LOOK_SENSITIVITY = 0.004;
const MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
export type JoystickState = {
angle: number;
force: number;
};
type SharedProps = {
joystickState: RefObject<JoystickState>;
joystickZone: RefObject<HTMLDivElement | null>;
};
/** Renders the joystick zone. Place inside canvasContainer, outside Canvas. */
export function TouchJoystick({ joystickState, joystickZone }: SharedProps) {
useEffect(() => {
const zone = joystickZone.current;
if (!zone) return;
let manager: nipplejs.JoystickManager | null = null;
let cancelled = false;
import("nipplejs").then((mod) => {
if (cancelled) return;
manager = mod.default.create({
zone,
mode: "static",
position: { left: "70px", bottom: "70px" },
size: 120,
restOpacity: 0.9,
});
manager.on("move", (_event, data) => {
joystickState.current.angle = data.angle.radian;
// Clamp force to 01 range (nipplejs force can exceed 1)
joystickState.current.force = Math.min(1, data.force);
});
manager.on("end", () => {
joystickState.current.force = 0;
});
});
return () => {
cancelled = true;
manager?.destroy();
};
}, [joystickState, joystickZone]);
return <div ref={joystickZone} className="TouchJoystick" />;
}
/** Handles touch look and joystick-driven movement. Place inside Canvas. */
export function TouchCameraMovement({
joystickState,
joystickZone,
}: SharedProps) {
const { speedMultiplier } = useControls();
const { camera, gl } = useThree();
// Touch look state
const euler = useRef(new Euler(0, 0, 0, "YXZ"));
const lookTouchId = useRef<number | null>(null);
const lastTouchPos = useRef({ x: 0, y: 0 });
// Scratch vectors
const forwardVec = useRef(new Vector3());
const sideVec = useRef(new Vector3());
const moveVec = useRef(new Vector3());
// Initialize euler from current camera rotation on mount
useEffect(() => {
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
}, [camera]);
// Touch look handling
useEffect(() => {
const canvas = gl.domElement;
const isTouchOnJoystick = (touch: Touch) => {
const zone = joystickZone.current;
if (!zone) return false;
const rect = zone.getBoundingClientRect();
return (
touch.clientX >= rect.left &&
touch.clientX <= rect.right &&
touch.clientY >= rect.top &&
touch.clientY <= rect.bottom
);
};
const handleTouchStart = (e: TouchEvent) => {
if (lookTouchId.current !== null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
if (!isTouchOnJoystick(touch)) {
lookTouchId.current = touch.identifier;
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
break;
}
}
};
const handleTouchMove = (e: TouchEvent) => {
if (lookTouchId.current === null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
if (touch.identifier === lookTouchId.current) {
const dx = touch.clientX - lastTouchPos.current.x;
const dy = touch.clientY - lastTouchPos.current.y;
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
euler.current.y -= dx * LOOK_SENSITIVITY;
euler.current.x -= dy * LOOK_SENSITIVITY;
euler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, euler.current.x),
);
camera.quaternion.setFromEuler(euler.current);
break;
}
}
};
const handleTouchEnd = (e: TouchEvent) => {
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === lookTouchId.current) {
lookTouchId.current = null;
break;
}
}
};
canvas.addEventListener("touchstart", handleTouchStart, { passive: true });
canvas.addEventListener("touchmove", handleTouchMove, { passive: true });
canvas.addEventListener("touchend", handleTouchEnd, { passive: true });
canvas.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
canvas.removeEventListener("touchstart", handleTouchStart);
canvas.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchend", handleTouchEnd);
canvas.removeEventListener("touchcancel", handleTouchEnd);
};
}, [camera, gl.domElement, joystickZone]);
useFrame((_state, delta) => {
const { force, angle } = joystickState.current;
if (force === 0) return;
const speed = BASE_SPEED * speedMultiplier * force;
// Decompose joystick angle into forward/sideways components
// nipplejs angle: 0 = right, π/2 = up, π = left, 3π/2 = down
const joyX = Math.cos(angle); // right component
const joyY = Math.sin(angle); // forward component
// Get camera forward projected onto XZ ground plane
camera.getWorldDirection(forwardVec.current);
forwardVec.current.y = 0;
forwardVec.current.normalize();
// Get camera right vector on XZ plane
sideVec.current.crossVectors(forwardVec.current, camera.up).normalize();
// Combine: joyY pushes forward, joyX pushes right
moveVec.current
.set(0, 0, 0)
.addScaledVector(forwardVec.current, joyY)
.addScaledVector(sideVec.current, joyX);
if (moveVec.current.lengthSq() > 0) {
moveVec.current.normalize().multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
}
});
return null;
}

View file

@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
const query = "(pointer: coarse) and (hover: none)";
export function useTouchDevice() {
const [isTouch, setIsTouch] = useState<boolean | null>(null);
useEffect(() => {
const mql = window.matchMedia(query);
setIsTouch(mql.matches);
const handleChange = (e: MediaQueryListEvent) => {
setIsTouch(e.matches);
};
mql.addEventListener("change", handleChange);
return () => {
mql.removeEventListener("change", handleChange);
};
}, []);
return isTouch;
}