All files / renderer/village useKeyboardPan.ts

42.85% Statements 54/126
100% Branches 23/23
50% Functions 2/4
42.85% Lines 54/126

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 2101x                                     1x                 1x   1x   1x     1x     1x 1x                       1x 11x 11x 11x 11x 11x 11x     10x 11x 11x     11x 11x   11x 11x 11x 7x 7x 7x 11x 2x 2x 2x 11x 2x 2x 2x 11x 1x 1x 1x                 10x 11x 9x 11x 11x 11x 11x     1x 9x 9x 9x 5x 9x 9x 9x                                       1x                                                                                                                                                              
/**
 * Keyboard camera-pan helper for VillageScene.
 *
 * Arrow keys pan the OrbitControls target in the xz ground plane, relative
 * to the camera's current facing direction. `+` / `=` / PgUp dolly the
 * camera in; `-` / `_` / PgDn dolly out. Holding a key repeats smoothly
 * (we drive motion from a `useFrame` tick, not from the OS key-repeat).
 *
 * The keydown/keyup listeners live on `window` but become a no-op as soon
 * as the active element is editable (input / textarea / contenteditable)
 * so typing in Settings does not jitter the camera. Any arrow keypress
 * also fires a user-override callback so an in-flight focus-agent /
 * focus-zone lerp is cancelled and the user gets immediate control.
 */
import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
 
/** Pan speed in world units per second. Sized so a full island (~26u) takes ~2s. */
export const PAN_SPEED = 14;
/**
 * Extra multiplier on the forward/back axis. Forward-pan translates the orbit
 * target along the camera look direction - visually this scrolls the whole
 * scene away rather than closing distance, so at the same world-units-per-
 * second the forward axis reads as "slow" while strafing reads fine. Bumping
 * the forward axis by ~2.2x matches the perceived strafe speed without
 * speeding up left/right.
 */
export const FORWARD_MULTIPLIER = 2.2;
/** Dolly speed in world units per second. */
export const DOLLY_SPEED = 4;
/** Multiplier applied while Shift is held. */
export const FAST_MULTIPLIER = 2.5;
 
/** Keys that trigger panning. Matches `KeyboardEvent.key`. */
const PAN_KEYS: ReadonlySet<string> = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
 
/** Keys that trigger dollying. */
const DOLLY_IN_KEYS: ReadonlySet<string> = new Set(["+", "=", "PageUp"]);
const DOLLY_OUT_KEYS: ReadonlySet<string> = new Set(["-", "_", "PageDown"]);
 
/**
 * Pure math: given the set of currently-pressed keys, a camera forward
 * vector projected onto the ground plane (xz), a pan speed and a delta
 * time, compute the `{dx, dz}` offset to add to the orbit target.
 *
 * Exported so unit tests can verify it without instantiating three.js.
 *
 * The forward vector is normalised internally - callers may pass a
 * raw projected camera direction.
 */
export function panDeltaForKeys(
  keys: ReadonlySet<string>,
  forwardXZ: { x: number; z: number },
  speed: number,
  dt: number
): { dx: number; dz: number } {
  if (keys.size === 0) return { dx: 0, dz: 0 };
  // Normalise forward. If the camera looks straight down (rare - the scene
  // clamps polarAngle), fall back to +z forward so the controls stay usable.
  const fLen = Math.hypot(forwardXZ.x, forwardXZ.z);
  const fx = fLen > 1e-6 ? forwardXZ.x / fLen : 0;
  const fz = fLen > 1e-6 ? forwardXZ.z / fLen : 1;
  // Right = forward rotated -90 deg around +y (standard right-hand rule).
  // Rotating (fx, fz) by -90 gives (fz, -fx).
  const rx = fz;
  const rz = -fx;
 
  let ax = 0;
  let az = 0;
  if (keys.has("ArrowUp")) {
    ax += fx * FORWARD_MULTIPLIER;
    az += fz * FORWARD_MULTIPLIER;
  }
  if (keys.has("ArrowDown")) {
    ax -= fx * FORWARD_MULTIPLIER;
    az -= fz * FORWARD_MULTIPLIER;
  }
  if (keys.has("ArrowRight")) {
    ax += rx;
    az += rz;
  }
  if (keys.has("ArrowLeft")) {
    ax -= rx;
    az -= rz;
  }
  // Normalise diagonal motion so pressing two keys is not sqrt(2)x faster,
  // but keep the forward multiplier effective by comparing against the
  // longest single-axis contribution we just built instead of the raw length.
  // If the user pressed only ArrowUp, (ax,az) has length FORWARD_MULTIPLIER,
  // and we want the step to be FORWARD_MULTIPLIER * speed * dt (not normalised
  // back down to `speed * dt`). If they press Up+Right the longest single
  // axis is still FORWARD_MULTIPLIER, so the diagonal stays at that cap
  // rather than racing ahead.
  const len = Math.hypot(ax, az);
  if (len < 1e-6) return { dx: 0, dz: 0 };
  const cap = Math.max(FORWARD_MULTIPLIER, 1);
  const scale = len > cap ? cap / len : 1;
  const step = speed * dt;
  return { dx: ax * scale * step, dz: az * scale * step };
}
 
/** Sum of dolly deltas requested by the pressed keys, in world units per frame. */
export function dollyDeltaForKeys(keys: ReadonlySet<string>, speed: number, dt: number): number {
  let d = 0;
  for (const k of keys) {
    if (DOLLY_IN_KEYS.has(k)) d -= speed * dt;
    else if (DOLLY_OUT_KEYS.has(k)) d += speed * dt;
  }
  return d;
}
 
/** True when the active element would be hijacked by our arrow handling. */
function isEditableActive(): boolean {
  const el = typeof document !== "undefined" ? document.activeElement : null;
  if (!el) return false;
  const tag = el.tagName;
  if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
  if ((el as HTMLElement).isContentEditable) return true;
  return false;
}
 
/**
 * Hook: attach global keydown/keyup listeners while mounted and mutate the
 * OrbitControls target each frame based on which keys are down. Must be
 * rendered inside a `<Canvas>` so `useFrame` / `useThree` work.
 *
 * `onUserOverride` is invoked once per keydown for any pan/dolly key so
 * the caller can cancel an in-flight lerp.
 */
export function useKeyboardPan(
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  controlsRef: React.MutableRefObject<any>,
  onUserOverride: () => void
): void {
  const keysRef = useRef<Set<string>>(new Set());
  const { camera } = useThree();
 
  useEffect(() => {
    const handleDown = (e: KeyboardEvent): void => {
      if (isEditableActive()) return;
      const key = e.key;
      const isPan = PAN_KEYS.has(key);
      const isDolly = DOLLY_IN_KEYS.has(key) || DOLLY_OUT_KEYS.has(key);
      if (!isPan && !isDolly) return;
      // Prevent page scroll etc. from arrow keys inside the app window.
      e.preventDefault();
      if (!keysRef.current.has(key)) {
        keysRef.current.add(key);
        onUserOverride();
      }
    };
    const handleUp = (e: KeyboardEvent): void => {
      keysRef.current.delete(e.key);
    };
    const handleBlur = (): void => {
      // Window lost focus - drop all pressed keys so we do not keep panning
      // when the user alt-tabs away mid-press.
      keysRef.current.clear();
    };
    window.addEventListener("keydown", handleDown);
    window.addEventListener("keyup", handleUp);
    window.addEventListener("blur", handleBlur);
    return () => {
      window.removeEventListener("keydown", handleDown);
      window.removeEventListener("keyup", handleUp);
      window.removeEventListener("blur", handleBlur);
    };
  }, [onUserOverride]);
 
  // Reusable scratch vector so we do not allocate every frame.
  const forwardRef = useRef(new THREE.Vector3());
 
  useFrame((_, dt) => {
    const controls = controlsRef.current;
    if (!controls) return;
    const keys = keysRef.current;
    if (keys.size === 0) return;
 
    // Camera forward, projected onto the xz plane.
    const fwd = forwardRef.current;
    camera.getWorldDirection(fwd);
    const shift = keys.has("Shift") ? FAST_MULTIPLIER : 1;
    const { dx, dz } = panDeltaForKeys(keys, { x: fwd.x, z: fwd.z }, PAN_SPEED * shift, dt);
    if (dx !== 0 || dz !== 0) {
      const t = controls.target as THREE.Vector3;
      t.x += dx;
      t.z += dz;
    }
 
    const dollyDelta = dollyDeltaForKeys(keys, DOLLY_SPEED * shift, dt);
    if (dollyDelta !== 0) {
      // OrbitControls stores distance implicitly via camera.position vs target.
      // Scale the offset by `1 + dollyDelta / distance` to move along that ray.
      const t = controls.target as THREE.Vector3;
      const offset = camera.position.clone().sub(t);
      const distance = offset.length();
      if (distance > 1e-6) {
        const minD = typeof controls.minDistance === "number" ? controls.minDistance : 0;
        const maxD = typeof controls.maxDistance === "number" ? controls.maxDistance : Infinity;
        const newDistance = Math.max(minD, Math.min(maxD, distance + dollyDelta));
        offset.multiplyScalar(newDistance / distance);
        camera.position.copy(t).add(offset);
      }
    }
 
    if (typeof controls.update === "function") controls.update();
  });
}