| 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<OrbitControlsImpl>(null); |
| const pressedKeysRef = useRef<Set<string>>(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 ( |
| <OrbitControls |
| ref={controlsRef} |
| makeDefault |
| enableDamping |
| dampingFactor={0.08} |
| maxPolarAngle={Math.PI / 2.2} |
| minDistance={16} |
| maxDistance={105} |
| target={[0, 0, 0]} |
| /> |
| ); |
| } |
|
|
| 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; |
| } |
|
|