Files
webeditor3d/src/runtime-three/first-person-navigation-controller.ts

291 lines
9.4 KiB
TypeScript

import { Euler, Vector3 } from "three";
import type { Vec3 } from "../core/vector";
import { getFirstPersonPlayerEyeHeight } from "./player-collision";
import type { NavigationController, RuntimeControllerContext, RuntimeLocomotionState } from "./navigation-controller";
const LOOK_SENSITIVITY = 0.0022;
const MOVE_SPEED = 4.5;
const GRAVITY = 22;
const MAX_PITCH_RADIANS = Math.PI * 0.48;
function clampPitch(pitchRadians: number): number {
return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians));
}
function toEyePosition(feetPosition: Vec3, eyeHeight: number): Vec3 {
return {
x: feetPosition.x,
y: feetPosition.y + eyeHeight,
z: feetPosition.z
};
}
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;
private locomotionState: RuntimeLocomotionState = "flying";
private inWaterVolume = false;
private inFogVolume = false;
private pointerLocked = false;
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;
this.locomotionState = "flying";
this.inWaterVolume = false;
this.inFogVolume = false;
this.initializedFromSpawn = true;
}
window.addEventListener("keydown", this.handleKeyDown);
window.addEventListener("keyup", this.handleKeyUp);
window.addEventListener("blur", this.handleBlur);
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("pointerlockchange", this.handlePointerLockChange);
document.addEventListener("pointerlockerror", this.handlePointerLockError);
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
this.syncPointerLockState();
this.updateCameraTransform();
this.publishTelemetry();
}
deactivate(ctx: RuntimeControllerContext): void {
window.removeEventListener("keydown", this.handleKeyDown);
window.removeEventListener("keyup", this.handleKeyUp);
window.removeEventListener("blur", this.handleBlur);
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("pointerlockchange", this.handlePointerLockChange);
document.removeEventListener("pointerlockerror", this.handlePointerLockError);
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
this.pressedKeys.clear();
if (document.pointerLockElement === ctx.domElement) {
document.exitPointerLock();
}
this.pointerLocked = false;
ctx.setRuntimeMessage(null);
ctx.setFirstPersonTelemetry(null);
this.context = null;
}
update(dt: number): void {
if (this.context === null) {
return;
}
const playerShape = this.context.getRuntimeScene().playerCollider;
const currentVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0);
const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0);
const inputLength = Math.hypot(inputX, inputZ);
let horizontalX = 0;
let horizontalZ = 0;
if (inputLength > 0) {
const normalizedInputX = inputX / inputLength;
const normalizedInputZ = inputZ / inputLength;
const moveDistance = MOVE_SPEED * dt;
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;
}
if (playerShape.mode === "none") {
this.verticalVelocity = 0;
} else if (currentVolumeState.inWater) {
this.verticalVelocity = 0;
} else {
this.verticalVelocity -= GRAVITY * dt;
}
const resolvedMotion = this.context.resolveFirstPersonMotion(
this.feetPosition,
{
x: horizontalX,
y: playerShape.mode === "none" || currentVolumeState.inWater ? 0 : this.verticalVelocity * dt,
z: horizontalZ
},
playerShape
);
if (resolvedMotion === null) {
this.updateCameraTransform();
this.publishTelemetry();
return;
}
this.feetPosition = resolvedMotion.feetPosition;
const nextVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
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";
}
if (this.grounded && this.verticalVelocity < 0) {
this.verticalVelocity = 0;
} else if (this.inWaterVolume) {
this.verticalVelocity = 0;
}
this.updateCameraTransform();
this.publishTelemetry();
}
teleportTo(feetPosition: Vec3, yawDegrees: number) {
this.feetPosition = {
...feetPosition
};
this.yawRadians = (yawDegrees * Math.PI) / 180;
this.pitchRadians = 0;
this.verticalVelocity = 0;
this.grounded = false;
this.locomotionState = "flying";
this.inWaterVolume = false;
this.inFogVolume = false;
this.updateCameraTransform();
this.publishTelemetry();
}
private updateCameraTransform() {
if (this.context === null) {
return;
}
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
this.cameraRotation.x = this.pitchRadians;
// 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;
this.cameraRotation.z = 0;
this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z);
this.context.camera.rotation.copy(this.cameraRotation);
}
private publishTelemetry() {
if (this.context === null) {
return;
}
this.context.setFirstPersonTelemetry({
feetPosition: {
...this.feetPosition
},
eyePosition: toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)),
grounded: this.grounded,
locomotionState: this.locomotionState,
inWaterVolume: this.inWaterVolume,
inFogVolume: this.inFogVolume,
pointerLocked: this.pointerLocked,
spawn: this.context.getRuntimeScene().spawn
});
}
private syncPointerLockState() {
if (this.context === null) {
return;
}
const pointerLocked = document.pointerLockElement === this.context.domElement;
this.pointerLocked = pointerLocked;
this.context.setRuntimeMessage(
pointerLocked
? "Mouse look active. Press Escape to release the cursor or switch to Orbit Visitor."
: "Click inside the runner viewport to capture mouse look. If pointer lock fails, switch to Orbit Visitor."
);
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;
this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY);
};
private handlePointerLockChange = () => {
this.syncPointerLockState();
};
private handlePointerLockError = () => {
this.context?.setRuntimeMessage(
"Pointer lock was unavailable in this browser context. Orbit Visitor remains available as the non-FPS fallback."
);
};
private handlePointerDown = () => {
if (this.context === null || document.pointerLockElement === this.context.domElement) {
return;
}
const pointerLockCapableElement = this.context.domElement as HTMLCanvasElement & {
requestPointerLock(): void | Promise<void>;
};
const pointerLockResult = pointerLockCapableElement.requestPointerLock();
if (pointerLockResult instanceof Promise) {
pointerLockResult.catch(() => {
this.context?.setRuntimeMessage(
"Pointer lock request was denied. Click again or use Orbit Visitor for non-locked navigation."
);
});
}
};
}