2026-03-31 03:04:15 +02:00
import { Euler , Vector3 } from "three" ;
import type { Vec3 } from "../core/vector" ;
2026-04-04 15:53:09 +02:00
import { getFirstPersonPlayerEyeHeight } from "./player-collision" ;
2026-04-11 12:30:28 +02:00
import {
resolvePlayerStartLookInput ,
resolvePlayerStartMovementActions
} from "./player-input-bindings" ;
2026-04-11 04:19:50 +02:00
import type {
2026-04-11 05:16:30 +02:00
NavigationControllerDeactivateOptions ,
2026-04-11 04:19:50 +02:00
NavigationController ,
RuntimeControllerContext ,
RuntimeLocomotionState
} from "./navigation-controller" ;
2026-04-11 18:00:36 +02:00
import type { RuntimePlayerMovement } from "./runtime-scene-build" ;
2026-03-31 03:04:15 +02:00
const LOOK_SENSITIVITY = 0.0022 ;
2026-04-11 12:30:28 +02:00
const GAMEPAD_LOOK_SPEED = 2.4 ;
2026-03-31 03:04:15 +02:00
const GRAVITY = 22 ;
const MAX_PITCH_RADIANS = Math . PI * 0.48 ;
function clampPitch ( pitchRadians : number ) : number {
2026-04-11 04:19:50 +02:00
return Math . max (
- MAX_PITCH_RADIANS ,
Math . min ( MAX_PITCH_RADIANS , pitchRadians )
) ;
2026-03-31 03:04:15 +02:00
}
2026-04-04 15:53:09 +02:00
function toEyePosition ( feetPosition : Vec3 , eyeHeight : number ) : Vec3 {
2026-03-31 03:04:15 +02:00
return {
x : feetPosition.x ,
2026-04-04 15:53:09 +02:00
y : feetPosition.y + eyeHeight ,
2026-03-31 03:04:15 +02:00
z : feetPosition.z
} ;
}
2026-04-11 18:00:36 +02:00
function cloneRuntimePlayerMovement (
movement : RuntimePlayerMovement
) : RuntimePlayerMovement {
return {
templateKind : movement.templateKind ,
moveSpeed : movement.moveSpeed ,
capabilities : {
jump : movement.capabilities.jump ,
sprint : movement.capabilities.sprint ,
crouch : movement.capabilities.crouch
}
} ;
}
2026-04-11 15:23:53 +02:00
function shouldAutoCapturePointerLockOnActivate ( ) : boolean {
if ( typeof navigator === "undefined" ) {
return true ;
}
const userAgent = navigator . userAgent ;
const vendor = navigator . vendor ;
const isSafari =
vendor . includes ( "Apple" ) &&
userAgent . includes ( "Safari/" ) &&
! userAgent . includes ( "Chrome/" ) &&
! userAgent . includes ( "Chromium/" ) &&
! userAgent . includes ( "CriOS/" ) &&
! userAgent . includes ( "Edg/" ) &&
! userAgent . includes ( "OPR/" ) &&
! userAgent . includes ( "Firefox/" ) ;
return ! isSafari ;
}
2026-03-31 03:04:15 +02:00
export class FirstPersonNavigationController implements NavigationController {
readonly id = "firstPerson" as const ;
private context : RuntimeControllerContext | null = null ;
private readonly pressedKeys = new Set < string > ( ) ;
private readonly cameraRotation = new Euler ( 0 , 0 , 0 , "YXZ" ) ;
private readonly forwardVector = new Vector3 ( ) ;
private readonly rightVector = new Vector3 ( ) ;
private feetPosition = {
x : 0 ,
y : 0 ,
z : 0
} ;
private yawRadians = 0 ;
private pitchRadians = 0 ;
private verticalVelocity = 0 ;
private grounded = false ;
2026-04-06 08:22:12 +02:00
private locomotionState : RuntimeLocomotionState = "flying" ;
private inWaterVolume = false ;
private inFogVolume = false ;
2026-03-31 03:04:15 +02:00
private pointerLocked = false ;
2026-04-11 15:13:23 +02:00
private suppressNextPointerLockError = false ;
2026-03-31 03:04:15 +02:00
private initializedFromSpawn = false ;
activate ( ctx : RuntimeControllerContext ) : void {
this . context = ctx ;
if ( ! this . initializedFromSpawn ) {
const spawn = ctx . getRuntimeScene ( ) . spawn ;
this . feetPosition = {
. . . spawn . position
} ;
this . yawRadians = ( spawn . yawDegrees * Math . PI ) / 180 ;
this . pitchRadians = 0 ;
this . verticalVelocity = 0 ;
this . grounded = false ;
2026-04-06 08:22:12 +02:00
this . locomotionState = "flying" ;
this . inWaterVolume = false ;
this . inFogVolume = false ;
2026-03-31 03:04:15 +02:00
this . initializedFromSpawn = true ;
}
window . addEventListener ( "keydown" , this . handleKeyDown ) ;
window . addEventListener ( "keyup" , this . handleKeyUp ) ;
window . addEventListener ( "blur" , this . handleBlur ) ;
document . addEventListener ( "mousemove" , this . handleMouseMove ) ;
2026-04-11 04:19:50 +02:00
document . addEventListener (
"pointerlockchange" ,
this . handlePointerLockChange
) ;
2026-03-31 03:04:15 +02:00
document . addEventListener ( "pointerlockerror" , this . handlePointerLockError ) ;
ctx . domElement . addEventListener ( "pointerdown" , this . handlePointerDown ) ;
this . syncPointerLockState ( ) ;
2026-04-11 15:13:23 +02:00
2026-04-11 15:23:53 +02:00
if (
shouldAutoCapturePointerLockOnActivate ( ) &&
document . pointerLockElement !== ctx . domElement
) {
2026-04-11 15:13:23 +02:00
const pointerLockCapableElement = ctx . domElement as HTMLCanvasElement & {
requestPointerLock ? : ( ) = > void | Promise < void > ;
} ;
if ( typeof pointerLockCapableElement . requestPointerLock === "function" ) {
this . suppressNextPointerLockError = true ;
const pointerLockResult = pointerLockCapableElement . requestPointerLock ( ) ;
if ( pointerLockResult instanceof Promise ) {
pointerLockResult . catch ( ( ) = > { } ) ;
}
}
}
2026-03-31 03:04:15 +02:00
this . updateCameraTransform ( ) ;
this . publishTelemetry ( ) ;
}
2026-04-11 05:16:30 +02:00
deactivate (
ctx : RuntimeControllerContext ,
options : NavigationControllerDeactivateOptions = { }
) : void {
2026-03-31 03:04:15 +02:00
window . removeEventListener ( "keydown" , this . handleKeyDown ) ;
window . removeEventListener ( "keyup" , this . handleKeyUp ) ;
window . removeEventListener ( "blur" , this . handleBlur ) ;
document . removeEventListener ( "mousemove" , this . handleMouseMove ) ;
2026-04-11 04:19:50 +02:00
document . removeEventListener (
"pointerlockchange" ,
this . handlePointerLockChange
) ;
document . removeEventListener (
"pointerlockerror" ,
this . handlePointerLockError
) ;
2026-03-31 03:04:15 +02:00
ctx . domElement . removeEventListener ( "pointerdown" , this . handlePointerDown ) ;
this . pressedKeys . clear ( ) ;
2026-04-11 05:16:30 +02:00
if (
( options . releasePointerLock ? ? true ) &&
document . pointerLockElement === ctx . domElement
) {
2026-03-31 03:04:15 +02:00
document . exitPointerLock ( ) ;
}
this . pointerLocked = false ;
2026-04-11 15:13:23 +02:00
this . suppressNextPointerLockError = false ;
2026-03-31 03:04:15 +02:00
ctx . setRuntimeMessage ( null ) ;
ctx . setFirstPersonTelemetry ( null ) ;
this . context = null ;
}
2026-04-11 04:14:48 +02:00
resetSceneState ( ) : void {
this . pressedKeys . clear ( ) ;
this . feetPosition = {
x : 0 ,
y : 0 ,
z : 0
} ;
this . yawRadians = 0 ;
this . pitchRadians = 0 ;
this . verticalVelocity = 0 ;
this . grounded = false ;
this . locomotionState = "flying" ;
this . inWaterVolume = false ;
this . inFogVolume = false ;
this . pointerLocked = false ;
2026-04-11 15:13:23 +02:00
this . suppressNextPointerLockError = false ;
2026-04-11 04:14:48 +02:00
this . initializedFromSpawn = false ;
}
2026-03-31 03:04:15 +02:00
update ( dt : number ) : void {
if ( this . context === null ) {
return ;
}
2026-04-11 18:00:36 +02:00
const runtimeScene = this . context . getRuntimeScene ( ) ;
const playerShape = runtimeScene . playerCollider ;
const playerMovement = runtimeScene . playerMovement ;
2026-04-11 12:30:28 +02:00
const lookInput = resolvePlayerStartLookInput (
2026-04-11 18:00:36 +02:00
runtimeScene . playerInputBindings
2026-04-11 12:30:28 +02:00
) ;
2026-04-11 12:13:06 +02:00
const inputState = resolvePlayerStartMovementActions (
this . pressedKeys ,
2026-04-11 18:00:36 +02:00
runtimeScene . playerInputBindings
2026-04-11 12:13:06 +02:00
) ;
2026-04-11 12:30:28 +02:00
if ( lookInput . horizontal !== 0 || lookInput . vertical !== 0 ) {
this . yawRadians -= lookInput . horizontal * GAMEPAD_LOOK_SPEED * dt ;
this . pitchRadians = clampPitch (
this . pitchRadians + lookInput . vertical * GAMEPAD_LOOK_SPEED * dt
) ;
}
2026-04-11 04:19:50 +02:00
const currentVolumeState = this . context . resolvePlayerVolumeState (
this . feetPosition
) ;
2026-04-11 12:13:06 +02:00
const inputX = inputState . moveRight - inputState . moveLeft ;
const inputZ = inputState . moveForward - inputState . moveBackward ;
2026-03-31 03:04:15 +02:00
const inputLength = Math . hypot ( inputX , inputZ ) ;
let horizontalX = 0 ;
let horizontalZ = 0 ;
2026-04-04 15:53:27 +02:00
if ( inputLength > 0 ) {
const normalizedInputX = inputX / inputLength ;
const normalizedInputZ = inputZ / inputLength ;
2026-04-11 18:00:36 +02:00
const moveDistance = playerMovement . moveSpeed * dt ;
2026-03-31 03:04:15 +02:00
2026-04-11 04:19:50 +02:00
this . forwardVector . set (
Math . sin ( this . yawRadians ) ,
0 ,
Math . cos ( this . yawRadians )
) ;
this . rightVector . set (
- Math . cos ( this . yawRadians ) ,
0 ,
Math . sin ( this . yawRadians )
) ;
horizontalX =
( this . forwardVector . x * normalizedInputZ +
this . rightVector . x * normalizedInputX ) *
moveDistance ;
horizontalZ =
( this . forwardVector . z * normalizedInputZ +
this . rightVector . z * normalizedInputX ) *
moveDistance ;
2026-04-04 15:53:27 +02:00
}
2026-03-31 03:04:15 +02:00
2026-04-04 15:53:09 +02:00
if ( playerShape . mode === "none" ) {
this . verticalVelocity = 0 ;
2026-04-06 08:22:12 +02:00
} else if ( currentVolumeState . inWater ) {
this . verticalVelocity = 0 ;
2026-04-04 15:53:09 +02:00
} else {
this . verticalVelocity -= GRAVITY * dt ;
}
2026-03-31 03:04:15 +02:00
2026-04-04 07:51:38 +02:00
const resolvedMotion = this . context . resolveFirstPersonMotion (
2026-03-31 03:04:15 +02:00
this . feetPosition ,
{
x : horizontalX ,
2026-04-11 04:19:50 +02:00
y :
playerShape . mode === "none" || currentVolumeState . inWater
? 0
: this . verticalVelocity * dt ,
2026-03-31 03:04:15 +02:00
z : horizontalZ
} ,
2026-04-04 15:53:09 +02:00
playerShape
2026-03-31 03:04:15 +02:00
) ;
2026-04-04 07:51:38 +02:00
if ( resolvedMotion === null ) {
this . updateCameraTransform ( ) ;
this . publishTelemetry ( ) ;
return ;
}
2026-03-31 03:04:15 +02:00
this . feetPosition = resolvedMotion . feetPosition ;
2026-04-11 04:19:50 +02:00
const nextVolumeState = this . context . resolvePlayerVolumeState (
this . feetPosition
) ;
2026-04-06 08:22:12 +02:00
this . inWaterVolume = nextVolumeState . inWater ;
this . inFogVolume = nextVolumeState . inFog ;
this . grounded = nextVolumeState . inWater ? false : resolvedMotion . grounded ;
if ( playerShape . mode === "none" ) {
this . locomotionState = "flying" ;
} else if ( this . inWaterVolume ) {
this . locomotionState = "swimming" ;
} else if ( this . grounded ) {
this . locomotionState = "grounded" ;
} else {
this . locomotionState = "flying" ;
}
2026-03-31 03:04:15 +02:00
if ( this . grounded && this . verticalVelocity < 0 ) {
this . verticalVelocity = 0 ;
2026-04-06 08:22:12 +02:00
} else if ( this . inWaterVolume ) {
this . verticalVelocity = 0 ;
2026-03-31 03:04:15 +02:00
}
this . updateCameraTransform ( ) ;
this . publishTelemetry ( ) ;
}
2026-03-31 06:17:31 +02:00
teleportTo ( feetPosition : Vec3 , yawDegrees : number ) {
this . feetPosition = {
. . . feetPosition
} ;
this . yawRadians = ( yawDegrees * Math . PI ) / 180 ;
this . pitchRadians = 0 ;
this . verticalVelocity = 0 ;
this . grounded = false ;
2026-04-06 08:22:12 +02:00
this . locomotionState = "flying" ;
this . inWaterVolume = false ;
this . inFogVolume = false ;
2026-03-31 06:17:31 +02:00
this . updateCameraTransform ( ) ;
this . publishTelemetry ( ) ;
}
2026-03-31 03:04:15 +02:00
private updateCameraTransform() {
if ( this . context === null ) {
return ;
}
2026-04-11 04:19:50 +02:00
const eyePosition = toEyePosition (
this . feetPosition ,
getFirstPersonPlayerEyeHeight (
this . context . getRuntimeScene ( ) . playerCollider
)
) ;
2026-03-31 03:04:15 +02:00
this . cameraRotation . x = this . pitchRadians ;
2026-03-31 03:21:30 +02:00
// Authoring yaw treats 0 degrees as facing +Z, while a three.js camera
// looks down -Z by default. Offset by 180 degrees so runtime view matches
// the authored PlayerStart marker and movement basis.
this . cameraRotation . y = this . yawRadians + Math . PI ;
2026-03-31 03:04:15 +02:00
this . cameraRotation . z = 0 ;
2026-04-11 04:19:50 +02:00
this . context . camera . position . set (
eyePosition . x ,
eyePosition . y ,
eyePosition . z
) ;
2026-03-31 03:04:15 +02:00
this . context . camera . rotation . copy ( this . cameraRotation ) ;
}
private publishTelemetry() {
if ( this . context === null ) {
return ;
}
2026-04-11 04:19:50 +02:00
const eyePosition = toEyePosition (
this . feetPosition ,
getFirstPersonPlayerEyeHeight (
this . context . getRuntimeScene ( ) . playerCollider
)
) ;
const cameraVolumeState =
this . context . resolvePlayerVolumeState ( eyePosition ) ;
2026-04-07 05:29:41 +02:00
2026-03-31 03:04:15 +02:00
this . context . setFirstPersonTelemetry ( {
feetPosition : {
. . . this . feetPosition
} ,
2026-04-07 05:29:41 +02:00
eyePosition ,
2026-03-31 03:04:15 +02:00
grounded : this.grounded ,
2026-04-06 08:22:12 +02:00
locomotionState : this.locomotionState ,
2026-04-11 18:00:36 +02:00
movement : cloneRuntimePlayerMovement (
this . context . getRuntimeScene ( ) . playerMovement
) ,
2026-04-06 08:22:12 +02:00
inWaterVolume : this.inWaterVolume ,
2026-04-07 05:29:41 +02:00
cameraSubmerged : cameraVolumeState.inWater ,
2026-04-06 08:22:12 +02:00
inFogVolume : this.inFogVolume ,
2026-03-31 03:04:15 +02:00
pointerLocked : this.pointerLocked ,
spawn : this.context.getRuntimeScene ( ) . spawn
} ) ;
}
private syncPointerLockState() {
if ( this . context === null ) {
return ;
}
2026-04-11 04:19:50 +02:00
const pointerLocked =
document . pointerLockElement === this . context . domElement ;
2026-03-31 03:04:15 +02:00
this . pointerLocked = pointerLocked ;
this . context . setRuntimeMessage (
pointerLocked
2026-04-11 12:30:28 +02:00
? "Mouse look active. Press Escape to release the cursor or switch to Third Person. The gamepad right stick also controls the camera."
: "Click inside the runner viewport to capture mouse look. If pointer lock fails, the gamepad right stick still controls the camera and Third Person remains available."
2026-03-31 03:04:15 +02:00
) ;
this . publishTelemetry ( ) ;
}
private handleKeyDown = ( event : KeyboardEvent ) = > {
this . pressedKeys . add ( event . code ) ;
} ;
private handleKeyUp = ( event : KeyboardEvent ) = > {
this . pressedKeys . delete ( event . code ) ;
} ;
private handleBlur = ( ) = > {
this . pressedKeys . clear ( ) ;
} ;
private handleMouseMove = ( event : MouseEvent ) = > {
if ( ! this . pointerLocked ) {
return ;
}
this . yawRadians -= event . movementX * LOOK_SENSITIVITY ;
2026-04-11 04:19:50 +02:00
this . pitchRadians = clampPitch (
this . pitchRadians - event . movementY * LOOK_SENSITIVITY
) ;
2026-03-31 03:04:15 +02:00
} ;
private handlePointerLockChange = ( ) = > {
2026-04-11 15:13:23 +02:00
this . suppressNextPointerLockError = false ;
2026-03-31 03:04:15 +02:00
this . syncPointerLockState ( ) ;
} ;
private handlePointerLockError = ( ) = > {
2026-04-11 15:13:23 +02:00
if ( this . suppressNextPointerLockError ) {
this . suppressNextPointerLockError = false ;
return ;
}
2026-03-31 03:04:15 +02:00
this . context ? . setRuntimeMessage (
2026-04-11 11:15:57 +02:00
"Pointer lock was unavailable in this browser context. Third Person remains available as the non-FPS fallback."
2026-03-31 03:04:15 +02:00
) ;
} ;
private handlePointerDown = ( ) = > {
2026-04-11 04:19:50 +02:00
if (
this . context === null ||
document . pointerLockElement === this . context . domElement
) {
2026-03-31 03:04:15 +02:00
return ;
}
2026-04-11 15:13:23 +02:00
this . suppressNextPointerLockError = false ;
2026-04-11 04:19:50 +02:00
const pointerLockCapableElement = this . context
. domElement as HTMLCanvasElement & {
2026-03-31 03:04:44 +02:00
requestPointerLock ( ) : void | Promise < void > ;
} ;
const pointerLockResult = pointerLockCapableElement . requestPointerLock ( ) ;
2026-03-31 03:04:15 +02:00
2026-03-31 03:04:44 +02:00
if ( pointerLockResult instanceof Promise ) {
2026-03-31 03:04:15 +02:00
pointerLockResult . catch ( ( ) = > {
this . context ? . setRuntimeMessage (
2026-04-11 11:15:57 +02:00
"Pointer lock request was denied. Click again or use Third Person for non-locked navigation."
2026-03-31 03:04:15 +02:00
) ;
} ) ;
}
} ;
}