mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-04-25 22:35:45 +00:00
Improve controls, add mobile joystick control
This commit is contained in:
parent
0c6aa3b4dd
commit
5214a8556b
33 changed files with 381 additions and 68 deletions
187
src/components/TouchControls.tsx
Normal file
187
src/components/TouchControls.tsx
Normal 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 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue