t2-mapper/src/components/TouchControls.tsx
2026-02-11 22:29:09 -08:00

187 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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