import * as THREE from 'three'; import {GamepadController} from '../../input/GamepadController.js'; import {Input} from '../../input/Input.js'; import {Keycodes} from '../../utils/Keycodes'; import {SimulatorRenderMode} from '../SimulatorConstants'; import {SimulatorControllerState} from '../SimulatorControllerState'; import {SimulatorHands} from '../SimulatorHands.js'; import {SimulatorNavMesh} from '../SimulatorNavMesh'; import {SimulatorHandPose} from '../handPoses/HandPoses'; import {SimulatorOptions} from '../SimulatorOptions'; const {A_CODE, D_CODE, E_CODE, Q_CODE, S_CODE, W_CODE} = Keycodes; const vector3 = new THREE.Vector3(); const desiredCameraPosition = new THREE.Vector3(); const originVec = new THREE.Vector3(); const forwardVec = new THREE.Vector3(0, 0, -1); const offsetVec = new THREE.Vector3(); const axisVec = new THREE.Vector3(); const currentOffsetVec = new THREE.Vector3(); const newOffsetVec = new THREE.Vector3(); const euler = new THREE.Euler(); const yawEuler = new THREE.Euler(); const yawQuaternion = new THREE.Quaternion(); const HAND_POSES = Object.values(SimulatorHandPose); export class SimulatorControlMode { camera!: THREE.Camera; input!: Input; timer!: THREE.Timer; domElement?: HTMLCanvasElement; simulatorOptions?: SimulatorOptions; /** * Create a SimulatorControlMode */ constructor( protected simulatorControllerState: SimulatorControllerState, protected downKeys: Set, protected hands: SimulatorHands, protected navMesh: SimulatorNavMesh, protected setStereoRenderMode: (_: SimulatorRenderMode) => void, protected toggleUserInterface: () => void, protected cycleSimulatorMode: () => void = () => {} ) {} /** * Initialize the simulator control mode. */ init({ camera, input, timer, domElement, simulatorOptions, }: { camera: THREE.Camera; input: Input; timer: THREE.Timer; domElement?: HTMLCanvasElement; simulatorOptions?: SimulatorOptions; }) { this.camera = camera; this.input = input; this.timer = timer; this.domElement = domElement; this.simulatorOptions = simulatorOptions; input.gamepadController.init({camera}); } onPointerDown(_: MouseEvent) {} onPointerUp(_: MouseEvent) {} onPointerMove(_: MouseEvent) {} onWheel(_: WheelEvent) { return false; } onKeyDown(event: KeyboardEvent) { if (event.code == Keycodes.DIGIT_1) { this.setStereoRenderMode(SimulatorRenderMode.STEREO_LEFT); } else if (event.code == Keycodes.DIGIT_2) { this.setStereoRenderMode(SimulatorRenderMode.STEREO_RIGHT); } else if (event.code == Keycodes.BACKQUOTE) { this.toggleUserInterface(); } } onModeActivated() {} onModeDeactivated() {} update() { this.updateGamepad(); this.updateCameraPosition(); this.updateControllerPositions(); } /** * Poll the gamepad and handle button actions. Called from all modes. */ updateGamepad() { const gp = this.input.gamepadController; gp.update(); if (gp.userData.connected) { this.updateGamepadUI(gp); } } updateCameraPosition() { const gp = this.input.gamepadController; // While a modal menu owns gamepad input, don't move the camera. if (gp.menuActive) return; const deltaTime = this.timer.getDelta(); const cameraRotation = this.camera.quaternion; const downKeys = this.downKeys; this.applyYawRelativeMovement( Number(downKeys.has(D_CODE)) - Number(downKeys.has(A_CODE)), this.navMesh.constrained ? 0 : Number(downKeys.has(Q_CODE)) - Number(downKeys.has(E_CODE)), Number(downKeys.has(S_CODE)) - Number(downKeys.has(W_CODE)), deltaTime ); // Gamepad stick input (if connected). Skip while the tab isn't // focused — the Gamepad API delivers state to every tab, so without // this guard the camera moves in background tabs whenever the user // touches the stick in the foreground tab. if (gp.userData.connected && document.hasFocus()) { const [lx, ly, rx, ry] = gp.getAxes(); // Left stick → move camera. if (lx !== 0 || ly !== 0) { this.applyYawRelativeMovement(lx, 0, ly, deltaTime); } // Right stick → look (yaw + pitch). if (rx !== 0 || ry !== 0) { const LOOK_SPEED = 2.0; euler.setFromQuaternion(cameraRotation, 'YXZ'); euler.y -= rx * LOOK_SPEED * deltaTime; euler.x -= ry * LOOK_SPEED * deltaTime; const PI_2 = Math.PI / 2; euler.x = Math.max(-PI_2 + 0.01, Math.min(PI_2 - 0.01, euler.x)); cameraRotation.setFromEuler(euler); } // Configurable vertical movement bindings (defaults LT/RT, analog). if (!this.navMesh.constrained) { const downVal = gp.getButtonValue(gp.bindings.getBinding('moveDown')); const upVal = gp.getButtonValue(gp.bindings.getBinding('moveUp')); const verticalDelta = (upVal - downVal) * deltaTime; if (verticalDelta !== 0) { desiredCameraPosition.copy(this.camera.position); desiredCameraPosition.y += verticalDelta; this.navMesh.applyUserMovement(this.camera, desiredCameraPosition); } } } } private applyYawRelativeMovement( localX: number, localY: number, localZ: number, deltaTime: number ) { euler.setFromQuaternion(this.camera.quaternion, 'YXZ'); yawEuler.set(0, euler.y, 0, 'YXZ'); yawQuaternion.setFromEuler(yawEuler); vector3 .set(localX, 0, localZ) .multiplyScalar(deltaTime) .applyQuaternion(yawQuaternion); vector3.y += localY * deltaTime; desiredCameraPosition.copy(this.camera.position).add(vector3); this.navMesh.applyUserMovement(this.camera, desiredCameraPosition); } /** * Handle gamepad buttons for simulator UI using configurable bindings. */ updateGamepadUI(gp: GamepadController) { // Suppress normal actions during rebind or while a modal menu owns input. if (gp.captureActive || gp.menuActive) return; const b = gp.bindings; if (gp.isButtonJustPressed(b.getBinding('cycleHandPoseLeft'))) { this.cycleHandPose(-1); } if (gp.isButtonJustPressed(b.getBinding('cycleHandPoseRight'))) { this.cycleHandPose(1); } if (gp.isButtonJustPressed(b.getBinding('cycleSimulatorMode'))) { this.cycleSimulatorMode(); } if (gp.isButtonJustPressed(b.getBinding('toggleUI'))) { this.toggleUserInterface(); } if (gp.isButtonJustPressed(b.getBinding('toggleHand'))) { this.hands.toggleHandedness(); } if (gp.isButtonJustPressed(b.getBinding('openSettings'))) { gp.onOpenSettings?.(); } } cycleHandPose(direction: number) { const idx = this.simulatorControllerState.currentControllerIndex; const currentPose = idx === 0 ? this.hands.leftHandPose : this.hands.rightHandPose; const currentIdx = HAND_POSES.indexOf(currentPose ?? HAND_POSES[0]); const nextIdx = (currentIdx + direction + HAND_POSES.length) % HAND_POSES.length; const nextPose = HAND_POSES[nextIdx]; if (idx === 0) { this.hands.setLeftHandLerpPose(nextPose); } else { this.hands.setRightHandLerpPose(nextPose); } } private getHandOrigin(idx: number, target: THREE.Vector3): boolean { const distOpt = this.simulatorOptions?.reachDistance; const angleOpt = this.simulatorOptions?.reachAngle; if (!distOpt?.enabled && !angleOpt?.enabled) return false; const originObj = idx === 0 ? distOpt!.leftHandOrigin : distOpt!.rightHandOrigin; target.set(originObj.x, originObj.y, originObj.z); return true; } limitMovementAtReachEdge( idx: number, localPos: THREE.Vector3, delta: THREE.Vector3 ) { if (!this.getHandOrigin(idx, originVec)) return; const distOpt = this.simulatorOptions!.reachDistance; const angleOpt = this.simulatorOptions!.reachAngle; currentOffsetVec.copy(localPos).sub(originVec); newOffsetVec.copy(currentOffsetVec).add(delta); if (distOpt.enabled) { const curDistSq = currentOffsetVec.lengthSq(); const radSq = distOpt.radius * distOpt.radius; if (curDistSq >= radSq && newOffsetVec.lengthSq() >= curDistSq) { delta.set(0, 0, 0); return; } } if (angleOpt.enabled) { const maxAngle = angleOpt.angle * 0.5; const curAngle = currentOffsetVec.angleTo(forwardVec); if ( curAngle >= maxAngle && newOffsetVec.angleTo(forwardVec) >= curAngle ) { delta.set(0, 0, 0); } } } updateControllerPositions() { const distOpt = this.simulatorOptions?.reachDistance; const angleOpt = this.simulatorOptions?.reachAngle; const distEnabled = !!distOpt?.enabled; const angleEnabled = !!angleOpt?.enabled; if (distEnabled || angleEnabled) { const radius = distOpt!.radius; const maxAngle = angleOpt!.angle * 0.5; for (let i = 0; i < 2; i++) { this.getHandOrigin(i, originVec); const localPos = this.simulatorControllerState.localControllerPositions[i]; offsetVec.copy(localPos).sub(originVec); const dist = offsetVec.length(); const angle = offsetVec.angleTo(forwardVec); const atMax = (distEnabled && dist >= radius) || (angleEnabled && angle >= maxAngle); if (i === 0) { this.hands.leftHandAtMaxRange = atMax; } else { this.hands.rightHandAtMaxRange = atMax; } if (angleEnabled && angle > maxAngle) { axisVec.copy(offsetVec).cross(forwardVec); if (axisVec.lengthSq() < 1e-6) { axisVec.set(0, 1, 0); } else { axisVec.normalize(); } offsetVec.applyAxisAngle(axisVec, angle - maxAngle); } if (distEnabled && offsetVec.lengthSq() > radius * radius) { offsetVec.clampLength(0, radius); } localPos.copy(originVec).add(offsetVec); } } else { this.hands.leftHandAtMaxRange = false; this.hands.rightHandAtMaxRange = false; } this.camera.updateMatrixWorld(); for (let i = 0; i < 2 && i < this.input.controllers.length; i++) { const controller = this.input.controllers[i]; controller.position .copy(this.simulatorControllerState.localControllerPositions[i]) .applyMatrix4(this.camera.matrixWorld); controller.quaternion .copy(this.simulatorControllerState.localControllerOrientations[i]) .premultiply(this.camera.quaternion); controller.updateMatrix(); const mesh = i == 0 ? this.hands.leftController : this.hands.rightController; mesh.position.copy(controller.position); mesh.quaternion.copy(controller.quaternion); } } rotateOnPointerMove( event: MouseEvent, objectQuaternion: THREE.Quaternion, multiplier = 0.002 ) { euler.setFromQuaternion(objectQuaternion, 'YXZ'); euler.y += event.movementX * multiplier; euler.x += event.movementY * multiplier; // Clamp camera pitch to +/-90 deg (+/-1.57 rad) with a 0.01 rad (0.573 deg) // buffer to prevent gimbal lock. const PI_2 = Math.PI / 2; euler.x = Math.max(-PI_2 + 0.01, Math.min(PI_2 - 0.01, euler.x)); objectQuaternion.setFromEuler(euler); } enableSimulatorHands() { this.hands.showHands(); this.input.dispatchEvent({ type: 'connected', target: this.input.controllers[0], data: {handedness: 'left'}, }); this.input.dispatchEvent({ type: 'connected', target: this.input.controllers[1], data: {handedness: 'right'}, }); } disableSimulatorHands() { this.hands.hideHands(); this.input.dispatchEvent({ type: 'disconnected', target: this.input.controllers[0], data: {handedness: 'left'}, }); this.input.dispatchEvent({ type: 'disconnected', target: this.input.controllers[1], data: {handedness: 'right'}, }); } }