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; joystickZone: RefObject; }; /** 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
; } /** 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(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; }