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

@ -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;
}