From 177bab48957eea913b3252407640b9e03cb90cbb Mon Sep 17 00:00:00 2001 From: NellInc Date: Mon, 10 Aug 2026 19:14:59 +0100 Subject: [PATCH] Polish keyboard camera navigation --- README.md | 2 + docs/ACCESSIBILITY_AUDIT.md | 11 +- docs/ACCESSIBILITY_RECOMMENDATIONS.md | 86 +---------- src/App.tsx | 7 +- src/components/CameraController.tsx | 137 ++++++++---------- src/components/FirstPersonController.tsx | 109 +++++--------- .../physics/PhysicsFirstPersonController.tsx | 68 +++++++-- src/components/ui/KeyboardShortcutsModal.tsx | 2 +- src/utils/__tests__/cameraNavigation.test.ts | 97 +++++++++++++ src/utils/cameraNavigation.ts | 125 ++++++++++++++++ 10 files changed, 388 insertions(+), 256 deletions(-) create mode 100644 src/utils/__tests__/cameraNavigation.test.ts create mode 100644 src/utils/cameraNavigation.ts diff --git a/README.md b/README.md index 055dcdb..d2d9e84 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Fully functional emergency evacuation simulation: Immersive walkthrough experience with: - **WASD movement** with collision detection against machines +- **Q/E vertical movement** for elevated inspection - **Sprint mode** (Shift key) for faster exploration - **Mouse look** with pointer lock controls - **105° FOV** for immersive factory tours @@ -791,6 +792,7 @@ MODBUS_PORT=502 |-------|--------| | **V** | Toggle first-person mode | | **WASD** | Move forward/left/back/right | +| **Q / E** | Move down/up | | **Shift** | Sprint (3.6x speed) | | **Mouse** | Look around | | **Esc** | Exit first-person mode | diff --git a/docs/ACCESSIBILITY_AUDIT.md b/docs/ACCESSIBILITY_AUDIT.md index 980509e..319c9b1 100644 --- a/docs/ACCESSIBILITY_AUDIT.md +++ b/docs/ACCESSIBILITY_AUDIT.md @@ -415,16 +415,13 @@ const shouldReduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)') --- -### 23. 3D Canvas Not Keyboard Accessible -**Location:** `src/App.tsx:202-253`, `src/components/MillScene.tsx` +### 23. 3D Canvas Keyboard Navigation +**Location:** `src/components/CameraController.tsx`, `src/utils/cameraNavigation.ts` **WCAG:** 2.1.1 Keyboard (Level A) -**Issue:** -3D scene with OrbitControls is mouse-only. No keyboard controls for camera navigation. - -**Current State:** OrbitControls exist but don't support keyboard by default. +**Current State:** Remediated. WASD and the arrow keys translate the camera, Q/E move down and up, and Shift increases travel speed. Movement uses physical key codes for keyboard-layout independence, stops when browser focus is lost, and remains inactive while an interface control has focus. -**Recommendation:** Implement keyboard camera controls (arrow keys for rotation, +/- for zoom) or provide alternative 2D representations of critical data. +**Residual Recommendation:** Continue providing the existing DOM control panels as the accessible representation of operational data that is also shown in the 3D scene. --- diff --git a/docs/ACCESSIBILITY_RECOMMENDATIONS.md b/docs/ACCESSIBILITY_RECOMMENDATIONS.md index 830de2d..0f7fae6 100644 --- a/docs/ACCESSIBILITY_RECOMMENDATIONS.md +++ b/docs/ACCESSIBILITY_RECOMMENDATIONS.md @@ -344,91 +344,13 @@ export const MyComponent = () => { --- -### 23. 3D Canvas Not Keyboard Accessible (WCAG 2.1.1 Keyboard - Level A) +### 23. 3D Canvas Keyboard Navigation (WCAG 2.1.1 Keyboard - Level A) -**Issue:** OrbitControls only work with mouse, no keyboard camera navigation. +**Status:** Implemented in `CameraController.tsx`, `FirstPersonController.tsx`, and `PhysicsFirstPersonController.tsx`, with shared input policy in `cameraNavigation.ts`. -**Impact:** Keyboard-only users cannot explore the 3D environment. +**Controls:** WASD and arrow keys translate, Q/E move down and up, and Shift increases speed. Physical key codes keep movement consistent across keyboard layouts. Navigation pauses for focused interface controls, browser blur, and hidden tabs. Collision resolution preserves the orbit target offset so blocked movement does not twist the view. -**Recommendation:** - -Implement keyboard controls for 3D navigation: - -```tsx -// src/components/KeyboardCameraControls.tsx -import { useEffect } from 'react'; -import { useThree } from '@react-three/fiber'; - -interface KeyboardCameraControlsProps { - enabled?: boolean; - rotateSpeed?: number; - zoomSpeed?: number; -} - -export const KeyboardCameraControls: React.FC = ({ - enabled = true, - rotateSpeed = 0.05, - zoomSpeed = 0.5, -}) => { - const { camera } = useThree(); - - useEffect(() => { - if (!enabled) return; - - const handleKeyDown = (e: KeyboardEvent) => { - // Prevent default if we handle the key - const handled = true; - - switch (e.key) { - case 'ArrowLeft': - // Rotate camera left - camera.position.x -= rotateSpeed; - break; - case 'ArrowRight': - // Rotate camera right - camera.position.x += rotateSpeed; - break; - case 'ArrowUp': - // Rotate camera up - camera.position.y += rotateSpeed; - break; - case 'ArrowDown': - // Rotate camera down - camera.position.y -= rotateSpeed; - break; - case '+': - case '=': - // Zoom in - camera.position.z -= zoomSpeed; - break; - case '-': - case '_': - // Zoom out - camera.position.z += zoomSpeed; - break; - default: - return; // Don't prevent default for unhandled keys - } - - if (handled) { - e.preventDefault(); - camera.lookAt(0, 0, 0); // Keep looking at center - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [camera, enabled, rotateSpeed, zoomSpeed]); - - return null; -}; - -// Usage in MillScene.tsx - - - {/* Rest of scene */} - -``` +**Ongoing Recommendation:** Keep the DOM dashboards and panels fully operable without navigating the 3D scene, and retain automated keyboard and focus-safety coverage. **Alternative: Provide 2D Representations** diff --git a/src/App.tsx b/src/App.tsx index dc538fb..bb53dc7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -661,9 +661,10 @@ const App: React.FC = () => { {/* 3D Canvas keyboard accessibility notice - visible to screen readers */}
- The 3D factory visualization is interactive. Press V to toggle first-person view mode. Use - keyboard shortcuts: I for AI panel, O for SCADA, Escape to close panels. Press 1-5 to switch - camera presets. Arrow keys control camera in first-person mode. + The 3D factory visualization is interactive. Use W, A, S, D or the arrow keys to move, Q and + E to move down and up, and Shift to move faster. Press V to toggle first-person view mode. + Press 1-5 to switch camera presets. Keyboard movement pauses while an interface control has + focus.
{!runtimeMode.benchmark && !deferredUIReady && } diff --git a/src/components/CameraController.tsx b/src/components/CameraController.tsx index 5937f27..b008a7e 100644 --- a/src/components/CameraController.tsx +++ b/src/components/CameraController.tsx @@ -11,41 +11,17 @@ import { import { useMobileControlStore } from '../stores/mobileControlStore'; import { SITE_LAYOUT, getVisibleSiteCellsForView } from '../constants/siteLayout'; import { resolveCameraCollision } from '../utils/cameraCollision'; +import { + clampNavigationDelta, + getNavigationIntent, + shouldHandleNavigationKey, + shouldPreventNavigationDefault, + syncOrbitTargetToAcceptedTranslation, +} from '../utils/cameraNavigation'; // Movement key tracking const pressedKeys = new Set(); -/** - * Physical key codes this controller consumes. - * - * Keyed on `event.code`, not `event.key`: `key` reports the produced character, - * which is layout dependent (AZERTY emits z/q/s/d for the WASD positions, - * QWERTZ emits 'y' for W), so a character-keyed table leaves movement dead on - * those keyboards. `code` is positional and identical everywhere. - */ -const MOVEMENT_CODES: ReadonlySet = new Set([ - 'KeyW', - 'KeyA', - 'KeyS', - 'KeyD', - 'KeyQ', - 'KeyE', - 'ShiftLeft', - 'ShiftRight', - 'ArrowUp', - 'ArrowDown', - 'ArrowLeft', - 'ArrowRight', -]); - -/** True when the event comes from somewhere the user is entering text. */ -function isTypingTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) return false; - if (target.isContentEditable) return true; - const tag = target.tagName; - return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; -} - // Reusable vector for the D-pad look offset (avoids a per-frame Vector3 // allocation while the mobile look control is held). const _lookOffset = new THREE.Vector3(); @@ -173,16 +149,15 @@ export const CameraController: React.FC = ({ const moveDirection = useRef(new THREE.Vector3()); const forward = useRef(new THREE.Vector3()); const right = useRef(new THREE.Vector3()); + const manualCameraStart = useRef(new THREE.Vector3()); + const manualTargetStart = useRef(new THREE.Vector3()); // Set up keyboard listeners for WASD/Arrow movement useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (isTypingTarget(e.target)) return; - - if (MOVEMENT_CODES.has(e.code)) { + if (shouldHandleNavigationKey(e)) { pressedKeys.add(e.code); - // Arrow keys scroll the page by default, which fights camera movement. - if (e.code.startsWith('Arrow')) e.preventDefault(); + if (shouldPreventNavigationDefault(e.code)) e.preventDefault(); } }; @@ -194,19 +169,26 @@ export const CameraController: React.FC = ({ const handleBlur = () => { pressedKeys.clear(); }; + const handleVisibilityChange = () => { + if (document.hidden) pressedKeys.clear(); + }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); window.addEventListener('blur', handleBlur); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); + document.removeEventListener('visibilitychange', handleVisibilityChange); + pressedKeys.clear(); }; }, []); useFrame((_, delta) => { + const movementDelta = clampNavigationDelta(delta); if (!cameraPositionInitialized.current) { previousCameraPosition.current.copy(camera.position); cameraPositionInitialized.current = true; @@ -227,8 +209,8 @@ export const CameraController: React.FC = ({ let phi = Math.acos(Math.max(-1, Math.min(1, offset.y / radius))); // polar angle // Apply rotation - theta -= dpadDirection.x * LOOK_SPEED * delta; - phi += dpadDirection.y * LOOK_SPEED * delta; + theta -= dpadDirection.x * LOOK_SPEED * movementDelta; + phi += dpadDirection.y * LOOK_SPEED * movementDelta; // Clamp polar angle phi = Math.max(0.2, Math.min(Math.PI / 2 - 0.05, phi)); @@ -244,16 +226,10 @@ export const CameraController: React.FC = ({ // Combine keyboard and D-pad move input const hasDpadMoveInput = dpadDirection && dpadMode === 'move'; - // Shift is a modifier, not a direction: counting it would let a held sprint - // key cancel a running preset animation without the camera moving at all. - let hasKeyboardInput = false; - for (const code of pressedKeys) { - if (code !== 'ShiftLeft' && code !== 'ShiftRight') { - hasKeyboardInput = true; - break; - } - } + const keyboardIntent = getNavigationIntent(pressedKeys); + const hasKeyboardInput = keyboardIntent.hasMotion; const hasManualInput = Boolean(dpadDirection || hasKeyboardInput); + let manualMovementApplied = false; if (hasManualInput && isAnimating) { clearAnimation(); @@ -264,6 +240,11 @@ export const CameraController: React.FC = ({ // Get forward direction (from camera to target, but flattened on XZ plane) forward.current.subVectors(orbitControlsRef.current.target, camera.position); forward.current.y = 0; + if (forward.current.lengthSq() < 1e-8) { + camera.getWorldDirection(forward.current); + forward.current.y = 0; + } + if (forward.current.lengthSq() < 1e-8) forward.current.set(0, 0, -1); forward.current.normalize(); // Get right direction (perpendicular to forward) @@ -273,20 +254,10 @@ export const CameraController: React.FC = ({ moveDirection.current.set(0, 0, 0); // Forward/Backward (W/S or Up/Down arrows or D-pad Y) - if (pressedKeys.has('KeyW') || pressedKeys.has('ArrowUp')) { - moveDirection.current.add(forward.current); - } - if (pressedKeys.has('KeyS') || pressedKeys.has('ArrowDown')) { - moveDirection.current.sub(forward.current); - } + moveDirection.current.addScaledVector(forward.current, keyboardIntent.forward); // Left/Right strafe (A/D or Left/Right arrows or D-pad X) - if (pressedKeys.has('KeyA') || pressedKeys.has('ArrowLeft')) { - moveDirection.current.sub(right.current); - } - if (pressedKeys.has('KeyD') || pressedKeys.has('ArrowRight')) { - moveDirection.current.add(right.current); - } + moveDirection.current.addScaledVector(right.current, keyboardIntent.strafe); // D-pad move input (when in move mode) if (hasDpadMoveInput && dpadDirection) { @@ -303,18 +274,12 @@ export const CameraController: React.FC = ({ } // Up/Down (Q/E for vertical movement) - if (pressedKeys.has('KeyQ')) { - moveDirection.current.y -= 1; - } - if (pressedKeys.has('KeyE')) { - moveDirection.current.y += 1; - } + moveDirection.current.y += keyboardIntent.vertical; // Apply movement if there's any if (moveDirection.current.length() > 0) { // Apply sprint multiplier if shift is held - const speedMultiplier = - pressedKeys.has('ShiftLeft') || pressedKeys.has('ShiftRight') ? SPRINT_MULTIPLIER : 1; + const speedMultiplier = keyboardIntent.sprint ? SPRINT_MULTIPLIER : 1; // Normalize horizontal movement but keep vertical separate const verticalMove = moveDirection.current.y; @@ -322,15 +287,18 @@ export const CameraController: React.FC = ({ if (moveDirection.current.length() > 0) { moveDirection.current.normalize(); - moveDirection.current.multiplyScalar(MOVE_SPEED * speedMultiplier * delta); + moveDirection.current.multiplyScalar(MOVE_SPEED * speedMultiplier * movementDelta); } // Add vertical movement - moveDirection.current.y = verticalMove * VERTICAL_SPEED * speedMultiplier * delta; + moveDirection.current.y = verticalMove * VERTICAL_SPEED * speedMultiplier * movementDelta; // Move both camera and orbit target together + manualCameraStart.current.copy(camera.position); + manualTargetStart.current.copy(orbitControlsRef.current.target); camera.position.add(moveDirection.current); orbitControlsRef.current.target.add(moveDirection.current); + manualMovementApplied = true; // Clamp camera and target height to prevent ground clipping if (camera.position.y < MIN_CAMERA_HEIGHT) { @@ -343,12 +311,14 @@ export const CameraController: React.FC = ({ } // Frame-rate independent exponential smoothing for perfectly smooth rotation if (orbitControlsRef?.current) { - const target = autoRotateEnabled ? targetSpeed : 0; + const target = autoRotateEnabled && !hasManualInput ? targetSpeed : 0; // Exponential decay smoothing - completely frame-rate independent // smoothTime controls how quickly we reach target (lower = faster) const smoothTime = 2.5; // seconds to reach ~63% of target - const alpha = 1 - Math.exp(-delta / smoothTime); - currentSpeed.current += (target - currentSpeed.current) * alpha; + const alpha = 1 - Math.exp(-movementDelta / smoothTime); + currentSpeed.current = hasManualInput + ? 0 + : currentSpeed.current + (target - currentSpeed.current) * alpha; orbitControlsRef.current.autoRotateSpeed = currentSpeed.current; } @@ -356,7 +326,7 @@ export const CameraController: React.FC = ({ // lerp never followed a predictable easing curve and could stop short. if (isAnimating && targetPosition && targetLookAt && !hasManualInput) { const animationDuration = 0.9; - animationProgress.current += delta / animationDuration; + animationProgress.current += movementDelta / animationDuration; const t = Math.min(animationProgress.current, 1); const easeT = t * t * (3 - 2 * t); @@ -382,16 +352,27 @@ export const CameraController: React.FC = ({ orbitControlsRef.current.target.y = MIN_TARGET_HEIGHT; } + const collisionStart = manualMovementApplied + ? manualCameraStart.current + : previousCameraPosition.current; const collision = resolveCameraCollision( - [ - previousCameraPosition.current.x, - previousCameraPosition.current.y, - previousCameraPosition.current.z, - ], + [collisionStart.x, collisionStart.y, collisionStart.z], [camera.position.x, camera.position.y, camera.position.z] ); camera.position.set(...collision.position); + if (manualMovementApplied && orbitControlsRef?.current) { + syncOrbitTargetToAcceptedTranslation( + orbitControlsRef.current.target, + manualTargetStart.current, + manualCameraStart.current, + camera.position + ); + } camera.userData.lastCollision = collision.collidedWith; + + if (orbitControlsRef?.current && orbitControlsRef.current.target.y < MIN_TARGET_HEIGHT) { + orbitControlsRef.current.target.y = MIN_TARGET_HEIGHT; + } previousCameraPosition.current.copy(camera.position); }); diff --git a/src/components/FirstPersonController.tsx b/src/components/FirstPersonController.tsx index b01c0d8..05af1ff 100644 --- a/src/components/FirstPersonController.tsx +++ b/src/components/FirstPersonController.tsx @@ -5,6 +5,12 @@ import * as THREE from 'three'; import { FACTORY_ZONE_Z } from '../constants/factoryLayout'; import { WORLD_RADIUS } from '../constants/siteLayout'; import { useUIStore } from '../stores/uiStore'; +import { + clampNavigationDelta, + getNavigationIntent, + shouldHandleNavigationKey, + shouldPreventNavigationDefault, +} from '../utils/cameraNavigation'; // Movement configuration const MOVE_SPEED = 12; // Units per second (walking speed) @@ -138,51 +144,6 @@ const COLLISION_BOXES: Array<{ // Track pressed keys const pressedKeys = new Set(); -/** - * Physical key codes this controller consumes. - * - * `KeyW`/`KeyA`/`KeyS`/`KeyD` are positions, not letters, so these bindings - * hold on AZERTY, QWERTZ, Dvorak and Colemak without a per-layout table. - * `KeyQ`/`KeyE` drive vertical movement; both Shift keys sprint. - */ -const MOVEMENT_CODES: ReadonlySet = new Set([ - 'KeyW', - 'KeyA', - 'KeyS', - 'KeyD', - 'KeyQ', - 'KeyE', - 'ShiftLeft', - 'ShiftRight', - 'Space', - 'ArrowUp', - 'ArrowDown', - 'ArrowLeft', - 'ArrowRight', -]); - -/** Keys whose default action scrolls the page and must be suppressed. */ -const SCROLLING_CODES: ReadonlySet = new Set([ - 'Space', - 'ArrowUp', - 'ArrowDown', - 'ArrowLeft', - 'ArrowRight', -]); - -/** - * True when the event originates from somewhere the user is entering text. - * - * Checking only input/textarea misses contenteditable surfaces and select - * elements, where arrow keys and letters are meaningful to the control. - */ -function isTypingTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) return false; - if (target.isContentEditable) return true; - const tag = target.tagName; - return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; -} - /** Vertical travel rate for Q/E, in units per second. */ const VERTICAL_SPEED = 8; /** Ceiling for Q/E ascent, high enough to clear the roof but not the sky. */ @@ -250,13 +211,9 @@ export const FirstPersonController: React.FC = ({ on // identical on every layout, which is why it is the standard choice for // game movement. It is also immune to Shift and AltGr changing the character. const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (isTypingTarget(e.target)) return; - - if (MOVEMENT_CODES.has(e.code)) { + if (shouldHandleNavigationKey(e)) { pressedKeys.add(e.code); - // Space and the arrows scroll the page by default, which fights the - // pointer-locked view. - if (SCROLLING_CODES.has(e.code)) e.preventDefault(); + if (shouldPreventNavigationDefault(e.code)) e.preventDefault(); } }, []); @@ -267,19 +224,24 @@ export const FirstPersonController: React.FC = ({ on const handleBlur = useCallback(() => { pressedKeys.clear(); }, []); + const handleVisibilityChange = useCallback(() => { + if (document.hidden) pressedKeys.clear(); + }, []); useEffect(() => { window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); window.addEventListener('blur', handleBlur); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); + document.removeEventListener('visibilitychange', handleVisibilityChange); pressedKeys.clear(); }; - }, [handleKeyDown, handleKeyUp, handleBlur]); + }, [handleKeyDown, handleKeyUp, handleBlur, handleVisibilityChange]); // Collision detection const checkCollision = useCallback((newX: number, newZ: number): boolean => { @@ -332,14 +294,11 @@ export const FirstPersonController: React.FC = ({ on // Movement update useFrame((_, delta) => { if (!isLocked.current) return; + const movementDelta = clampNavigationDelta(delta); + const keyboardIntent = getNavigationIntent(pressedKeys); // Get movement input - direction.current.set(0, 0, 0); - - if (pressedKeys.has('KeyW') || pressedKeys.has('ArrowUp')) direction.current.z -= 1; - if (pressedKeys.has('KeyS') || pressedKeys.has('ArrowDown')) direction.current.z += 1; - if (pressedKeys.has('KeyA') || pressedKeys.has('ArrowLeft')) direction.current.x -= 1; - if (pressedKeys.has('KeyD') || pressedKeys.has('ArrowRight')) direction.current.x += 1; + direction.current.set(keyboardIntent.strafe, 0, -keyboardIntent.forward); // Normalize diagonal movement if (direction.current.length() > 0) { @@ -347,15 +306,12 @@ export const FirstPersonController: React.FC = ({ on } // Apply sprint multiplier - const sprinting = pressedKeys.has('ShiftLeft') || pressedKeys.has('ShiftRight'); - const speedScale = sprinting ? SPRINT_MULTIPLIER : 1; + const speedScale = keyboardIntent.sprint ? SPRINT_MULTIPLIER : 1; const speed = MOVE_SPEED * speedScale; // Q descends, E ascends. Held separately from the horizontal direction so a // diagonal walk does not dilute the climb rate when both are pressed. - let verticalInput = 0; - if (pressedKeys.has('KeyE')) verticalInput += 1; - if (pressedKeys.has('KeyQ')) verticalInput -= 1; + const verticalInput = keyboardIntent.vertical; // Calculate world-space movement based on camera direction const forward = forwardRef.current.set(0, 0, -1).applyQuaternion(camera.quaternion); @@ -384,16 +340,13 @@ export const FirstPersonController: React.FC = ({ on // CLIMBING PHYSICS: W/S moves Up/Down const climbSpeed = speed * 0.8; - if (pressedKeys.has('KeyW') || pressedKeys.has('ArrowUp')) - velocity.current.y += climbSpeed * delta; - if (pressedKeys.has('KeyS') || pressedKeys.has('ArrowDown')) - velocity.current.y -= climbSpeed * delta; + velocity.current.y += keyboardIntent.forward * climbSpeed * movementDelta; // Q/E climb the ladder too, so the vertical binding is consistent. - velocity.current.y += verticalInput * climbSpeed * delta; + velocity.current.y += verticalInput * climbSpeed * movementDelta; // Allow some horizontal movement to guide onto/off ladder - velocity.current.addScaledVector(right, direction.current.x * speed * 0.5 * delta); - velocity.current.addScaledVector(forward, -direction.current.z * speed * 0.5 * delta); + velocity.current.addScaledVector(right, direction.current.x * speed * 0.5 * movementDelta); + velocity.current.addScaledVector(forward, -direction.current.z * speed * 0.5 * movementDelta); // Update height currentHeight.current += velocity.current.y; @@ -413,8 +366,8 @@ export const FirstPersonController: React.FC = ({ on velocity.current.y = 0; // Reset vertical velocity accumulation for next frame logic } else { // WALKING PHYSICS - velocity.current.addScaledVector(forward, -direction.current.z * speed * delta); - velocity.current.addScaledVector(right, direction.current.x * speed * delta); + velocity.current.addScaledVector(forward, -direction.current.z * speed * movementDelta); + velocity.current.addScaledVector(right, direction.current.x * speed * movementDelta); // Calculate new position const newX = camera.position.x + velocity.current.x; @@ -433,7 +386,7 @@ export const FirstPersonController: React.FC = ({ on // why an unconditional ground-snap and a fly control cannot coexist. if (verticalInput !== 0) { currentHeight.current = THREE.MathUtils.clamp( - currentHeight.current + verticalInput * VERTICAL_SPEED * speedScale * delta, + currentHeight.current + verticalInput * VERTICAL_SPEED * speedScale * movementDelta, PLAYER_HEIGHT, MAX_FREE_HEIGHT ); @@ -505,6 +458,14 @@ export const FPSInstructions: React.FC<{ visible: boolean }> = ({ visible }) => Move around +
+
+ Q + E +
+ Move down / up +
+
Mouse @@ -512,7 +473,7 @@ export const FPSInstructions: React.FC<{ visible: boolean }> = ({ visible }) => Look around
-
+
Shift diff --git a/src/components/physics/PhysicsFirstPersonController.tsx b/src/components/physics/PhysicsFirstPersonController.tsx index 3aad0ec..1d7e186 100644 --- a/src/components/physics/PhysicsFirstPersonController.tsx +++ b/src/components/physics/PhysicsFirstPersonController.tsx @@ -18,12 +18,23 @@ import { createCollisionGroups, WORLD_RADIUS, } from '../../physics/PhysicsConfig'; +import { + clampNavigationDelta, + getNavigationIntent, + shouldHandleNavigationKey, + shouldPreventNavigationDefault, +} from '../../utils/cameraNavigation'; // Movement configuration const FPS_FOV = 105; const ORBIT_FOV = 65; const MOUSE_SENSITIVITY = 1.5; const PLAYER_RADIUS = PHYSICS_CONFIG.player.capsuleRadius; +const VERTICAL_SPEED = 8; +const MIN_BODY_HEIGHT = 0.02; +const MAX_CAMERA_HEIGHT = 60; +const PHYSICS_SPRINT_MULTIPLIER = + PHYSICS_CONFIG.player.maxSprintVelocity / PHYSICS_CONFIG.player.maxLinearVelocity; // Track pressed keys (module level to persist across renders) const pressedKeys = new Set(); @@ -112,20 +123,32 @@ export const PhysicsFirstPersonController: React.FC { const handleKeyDown = (e: KeyboardEvent) => { - pressedKeys.add(e.key.toLowerCase()); + if (shouldHandleNavigationKey(e)) { + pressedKeys.add(e.code); + if (shouldPreventNavigationDefault(e.code)) e.preventDefault(); + } }; const handleKeyUp = (e: KeyboardEvent) => { - pressedKeys.delete(e.key.toLowerCase()); + pressedKeys.delete(e.code); + }; + + const clearPressedKeys = () => pressedKeys.clear(); + const handleVisibilityChange = () => { + if (document.hidden) clearPressedKeys(); }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); + window.addEventListener('blur', clearPressedKeys); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); - pressedKeys.clear(); + window.removeEventListener('blur', clearPressedKeys); + document.removeEventListener('visibilitychange', handleVisibilityChange); + clearPressedKeys(); }; }, []); @@ -136,15 +159,11 @@ export const PhysicsFirstPersonController: React.FC 0) { @@ -167,7 +186,7 @@ export const PhysicsFirstPersonController: React.FC= maxBodyHeight && verticalVelocity > 0) + ) { + verticalVelocity = 0; + } + const currentVelocity = rb.linvel(); + rb.setLinvel({ x: currentVelocity.x, y: verticalVelocity, z: currentVelocity.z }, true); + + if (currentPosition.y < MIN_BODY_HEIGHT || currentPosition.y > maxBodyHeight) { + rb.setTranslation( + { + x: currentPosition.x, + y: THREE.MathUtils.clamp(currentPosition.y, MIN_BODY_HEIGHT, maxBodyHeight), + z: currentPosition.z, + }, + true + ); + } + // Sync camera to physics body position const pos = rb.translation(); camera.position.set(pos.x, pos.y + PHYSICS_CONFIG.player.height, pos.z); diff --git a/src/components/ui/KeyboardShortcutsModal.tsx b/src/components/ui/KeyboardShortcutsModal.tsx index 75aa455..a3427ce 100644 --- a/src/components/ui/KeyboardShortcutsModal.tsx +++ b/src/components/ui/KeyboardShortcutsModal.tsx @@ -35,7 +35,7 @@ export const KeyboardShortcutsModal: React.FC = ({ { key: '1-5', description: 'Camera presets' }, { key: '0', description: 'Reset camera view' }, { key: 'V', description: 'First-person mode' }, - { key: 'Shift', description: 'Sprint (FPS mode)' }, + { key: 'Shift', description: 'Move faster / sprint' }, ], }, { diff --git a/src/utils/__tests__/cameraNavigation.test.ts b/src/utils/__tests__/cameraNavigation.test.ts new file mode 100644 index 0000000..0ca3d0f --- /dev/null +++ b/src/utils/__tests__/cameraNavigation.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import * as THREE from 'three'; +import { resolveCameraCollision } from '../cameraCollision'; +import { + NAVIGATION_CODES, + clampNavigationDelta, + getNavigationIntent, + isNavigationBlockedTarget, + shouldHandleNavigationKey, + shouldPreventNavigationDefault, + syncOrbitTargetToAcceptedTranslation, +} from '../cameraNavigation'; + +describe('camera navigation input', () => { + it('maps physical WASD, arrows, Q/E, and either Shift key consistently', () => { + expect(getNavigationIntent(new Set(['KeyW', 'KeyA', 'KeyE', 'ShiftRight']))).toEqual({ + forward: 1, + strafe: -1, + vertical: 1, + sprint: true, + hasMotion: true, + }); + expect(getNavigationIntent(new Set(['ArrowDown', 'ArrowRight', 'KeyQ']))).toEqual({ + forward: -1, + strafe: 1, + vertical: -1, + sprint: false, + hasMotion: true, + }); + expect([...NAVIGATION_CODES]).toEqual( + expect.arrayContaining(['KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyQ', 'KeyE']) + ); + }); + + it('cancels opposing directions and does not treat Shift alone as movement', () => { + expect( + getNavigationIntent(new Set(['KeyW', 'KeyS', 'KeyA', 'KeyD', 'KeyQ', 'KeyE', 'ShiftLeft'])) + ).toEqual({ forward: 0, strafe: 0, vertical: 0, sprint: true, hasMotion: false }); + }); + + it('blocks movement while an interface control or its child has focus', () => { + const input = document.createElement('input'); + const button = document.createElement('button'); + const icon = document.createElement('span'); + const canvas = document.createElement('canvas'); + button.append(icon); + + expect(isNavigationBlockedTarget(input)).toBe(true); + expect(isNavigationBlockedTarget(icon)).toBe(true); + expect(isNavigationBlockedTarget(canvas)).toBe(false); + }); + + it('ignores modified shortcuts and keys already claimed by another control', () => { + const normal = new KeyboardEvent('keydown', { code: 'KeyW' }); + const modified = new KeyboardEvent('keydown', { code: 'KeyW', ctrlKey: true }); + const claimed = new KeyboardEvent('keydown', { code: 'KeyW', cancelable: true }); + claimed.preventDefault(); + + expect(shouldHandleNavigationKey(normal)).toBe(true); + expect(shouldHandleNavigationKey(modified)).toBe(false); + expect(shouldHandleNavigationKey(claimed)).toBe(false); + expect(shouldPreventNavigationDefault('ArrowUp')).toBe(true); + expect(shouldPreventNavigationDefault('KeyW')).toBe(false); + }); + + it('caps resume spikes while preserving ordinary frame deltas', () => { + expect(clampNavigationDelta(1 / 60)).toBeCloseTo(1 / 60); + expect(clampNavigationDelta(0.5)).toBe(0.1); + expect(clampNavigationDelta(Number.NaN)).toBe(0); + expect(clampNavigationDelta(-1)).toBe(0); + }); +}); + +describe('orbit collision response', () => { + it('moves the target only by the translation accepted by collision resolution', () => { + const target = new THREE.Vector3(0, 5, 0); + const targetBefore = target.clone(); + const cameraBefore = new THREE.Vector3(30, 5, 45); + const collision = resolveCameraCollision( + [cameraBefore.x, cameraBefore.y, cameraBefore.z], + [30, 5, 55] + ); + const cameraAfter = new THREE.Vector3(...collision.position); + + syncOrbitTargetToAcceptedTranslation(target, targetBefore, cameraBefore, cameraAfter); + + expect(collision.collidedWith).toBe('shipping-wall'); + expect(target.x).toBeCloseTo(0); + expect(target.y).toBeCloseTo(5); + expect(target.z).toBeCloseTo(4.35); + const offsetAfter = cameraAfter.clone().sub(target); + const offsetBefore = cameraBefore.clone().sub(targetBefore); + expect(offsetAfter.x).toBeCloseTo(offsetBefore.x); + expect(offsetAfter.y).toBeCloseTo(offsetBefore.y); + expect(offsetAfter.z).toBeCloseTo(offsetBefore.z); + }); +}); diff --git a/src/utils/cameraNavigation.ts b/src/utils/cameraNavigation.ts new file mode 100644 index 0000000..559fe52 --- /dev/null +++ b/src/utils/cameraNavigation.ts @@ -0,0 +1,125 @@ +import * as THREE from 'three'; + +/** Physical key positions shared by orbit and first-person camera modes. */ +export const NAVIGATION_CODES: ReadonlySet = new Set([ + 'KeyW', + 'KeyA', + 'KeyS', + 'KeyD', + 'KeyQ', + 'KeyE', + 'ShiftLeft', + 'ShiftRight', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', +]); + +const SCROLLING_NAVIGATION_CODES: ReadonlySet = new Set([ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', +]); + +const INTERACTIVE_TARGET_SELECTOR = [ + 'input', + 'textarea', + 'select', + 'button', + 'a[href]', + '[contenteditable]:not([contenteditable="false"])', + '[role="button"]', + '[role="checkbox"]', + '[role="combobox"]', + '[role="link"]', + '[role="menuitem"]', + '[role="radio"]', + '[role="searchbox"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="switch"]', + '[role="tab"]', + '[role="textbox"]', +].join(','); + +export interface NavigationIntent { + readonly forward: number; + readonly strafe: number; + readonly vertical: number; + readonly sprint: boolean; + readonly hasMotion: boolean; +} + +/** + * Camera movement must stay inactive while an operator is using an interface + * control. This covers buttons and links as well as ordinary text fields, so a + * focused modal or SCADA control cannot move the world behind it. + */ +export function isNavigationBlockedTarget(target: EventTarget | null): boolean { + if (typeof Element === 'undefined' || !(target instanceof Element)) return false; + return target.closest(INTERACTIVE_TARGET_SELECTOR) !== null; +} + +/** True when a keydown should enter the active camera key set. */ +export function shouldHandleNavigationKey(event: KeyboardEvent): boolean { + return ( + NAVIGATION_CODES.has(event.code) && + !event.defaultPrevented && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !isNavigationBlockedTarget(event.target) + ); +} + +/** Arrow keys scroll the document unless the camera consumes their default. */ +export function shouldPreventNavigationDefault(code: string): boolean { + return SCROLLING_NAVIGATION_CODES.has(code); +} + +/** Resolve opposing keys and aliases into one deterministic movement intent. */ +export function getNavigationIntent(keys: ReadonlySet): NavigationIntent { + const forward = + Number(keys.has('KeyW') || keys.has('ArrowUp')) - + Number(keys.has('KeyS') || keys.has('ArrowDown')); + const strafe = + Number(keys.has('KeyD') || keys.has('ArrowRight')) - + Number(keys.has('KeyA') || keys.has('ArrowLeft')); + const vertical = Number(keys.has('KeyE')) - Number(keys.has('KeyQ')); + + return { + forward, + strafe, + vertical, + sprint: keys.has('ShiftLeft') || keys.has('ShiftRight'), + hasMotion: forward !== 0 || strafe !== 0 || vertical !== 0, + }; +} + +/** + * Avoid a camera leap after a suspended tab resumes while retaining exact + * frame-rate independence down to ten rendered frames per second. + */ +export function clampNavigationDelta(delta: number): number { + return Number.isFinite(delta) && delta > 0 ? Math.min(delta, 0.1) : 0; +} + +/** + * Keep the orbit target translated by the camera movement that collision + * resolution actually accepted. Without this correction, a blocked camera + * leaves its target beyond the wall and the view twists on every held key. + */ +export function syncOrbitTargetToAcceptedTranslation( + target: THREE.Vector3, + targetBeforeMove: THREE.Vector3, + cameraBeforeMove: THREE.Vector3, + cameraAfterCollision: THREE.Vector3 +): void { + target.set( + targetBeforeMove.x + cameraAfterCollision.x - cameraBeforeMove.x, + targetBeforeMove.y + cameraAfterCollision.y - cameraBeforeMove.y, + targetBeforeMove.z + cameraAfterCollision.z - cameraBeforeMove.z + ); +}