import { OrbitControls } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; import { useEffect, useMemo, useRef } from "react"; import { MathUtils, Vector3 } from "three"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; type KeyboardOrbitControlsProps = { terrainWidth: number; terrainDepth: number; }; export function KeyboardOrbitControls({ terrainWidth, terrainDepth, }: KeyboardOrbitControlsProps) { const controlsRef = useRef(null); const pressedKeysRef = useRef>(new Set()); const forward = useMemo(() => new Vector3(), []); const right = useMemo(() => new Vector3(), []); const movement = useMemo(() => new Vector3(), []); const previousTarget = useMemo(() => new Vector3(), []); const appliedMovement = useMemo(() => new Vector3(), []); useEffect(() => { const trackedKeys = new Set([ "arrowup", "arrowdown", "arrowleft", "arrowright", "w", "a", "s", "d", ]); const keyAliases = new Map([ ["ArrowUp", "arrowup"], ["ArrowDown", "arrowdown"], ["ArrowLeft", "arrowleft"], ["ArrowRight", "arrowright"], ["KeyW", "w"], ["KeyA", "a"], ["KeyS", "s"], ["KeyD", "d"], ]); const updateKey = (event: KeyboardEvent, isPressed: boolean) => { const key = keyAliases.get(event.code) ?? event.key.toLowerCase(); if (!trackedKeys.has(key) || isTypingTarget(event.target)) { return; } event.preventDefault(); if (isPressed) { pressedKeysRef.current.add(key); } else { pressedKeysRef.current.delete(key); } }; const handleKeyDown = (event: KeyboardEvent) => updateKey(event, true); const handleKeyUp = (event: KeyboardEvent) => updateKey(event, false); const clearKeys = () => pressedKeysRef.current.clear(); window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); window.addEventListener("blur", clearKeys); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", clearKeys); }; }, []); useFrame(({ camera }, delta) => { const keys = pressedKeysRef.current; const zInput = Number(keys.has("w") || keys.has("arrowup")) - Number(keys.has("s") || keys.has("arrowdown")); const xInput = Number(keys.has("d") || keys.has("arrowright")) - Number(keys.has("a") || keys.has("arrowleft")); if (!zInput && !xInput) { return; } camera.getWorldDirection(forward); forward.y = 0; if (forward.lengthSq() < 0.001) { forward.set(0, 0, -1); } forward.normalize(); right.crossVectors(forward, camera.up).normalize(); movement.set(0, 0, 0).addScaledVector(forward, zInput).addScaledVector(right, xInput); if (movement.lengthSq() === 0) { return; } movement.normalize().multiplyScalar(54 * delta); moveCamera( camera.position, movement, controlsRef.current, terrainWidth, terrainDepth, previousTarget, appliedMovement, ); }); return ( ); } function moveCamera( cameraPosition: Vector3, moveDelta: Vector3, controls: OrbitControlsImpl | null, width: number, depth: number, previousTarget: Vector3, appliedMovement: Vector3, ) { if (!controls) { cameraPosition.add(moveDelta); return; } previousTarget.copy(controls.target); controls.target.add(moveDelta); controls.target.x = MathUtils.clamp(controls.target.x, -width / 2, width / 2); controls.target.z = MathUtils.clamp(controls.target.z, -depth / 2, depth / 2); appliedMovement.copy(controls.target).sub(previousTarget); cameraPosition.add(appliedMovement); controls.update(); } function isTypingTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { return false; } const tagName = target.tagName.toLowerCase(); return tagName === "input" || tagName === "textarea" || target.isContentEditable; }