mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-02-13 19:53:52 +00:00
187 lines
5.9 KiB
TypeScript
187 lines
5.9 KiB
TypeScript
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 0–1 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;
|
||
}
|