Add various runtime-three components
This commit is contained in:
232
src/runtime-three/first-person-navigation-controller.ts
Normal file
232
src/runtime-three/first-person-navigation-controller.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Euler, Vector3 } from "three";
|
||||
|
||||
import type { Vec3 } from "../core/vector";
|
||||
|
||||
import { FIRST_PERSON_PLAYER_SHAPE, resolveFirstPersonMotion } from "./player-collision";
|
||||
import type { NavigationController, RuntimeControllerContext } 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): Vec3 {
|
||||
return {
|
||||
x: feetPosition.x,
|
||||
y: feetPosition.y + FIRST_PERSON_PLAYER_SHAPE.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 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.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 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;
|
||||
}
|
||||
|
||||
this.verticalVelocity -= GRAVITY * dt;
|
||||
|
||||
const resolvedMotion = resolveFirstPersonMotion(
|
||||
this.feetPosition,
|
||||
{
|
||||
x: horizontalX,
|
||||
y: this.verticalVelocity * dt,
|
||||
z: horizontalZ
|
||||
},
|
||||
FIRST_PERSON_PLAYER_SHAPE,
|
||||
this.context.getRuntimeScene().colliders
|
||||
);
|
||||
|
||||
this.feetPosition = resolvedMotion.feetPosition;
|
||||
this.grounded = resolvedMotion.grounded;
|
||||
|
||||
if (this.grounded && this.verticalVelocity < 0) {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
}
|
||||
|
||||
private updateCameraTransform() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eyePosition = toEyePosition(this.feetPosition);
|
||||
this.cameraRotation.x = this.pitchRadians;
|
||||
this.cameraRotation.y = this.yawRadians;
|
||||
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),
|
||||
grounded: this.grounded,
|
||||
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 pointerLockResult = this.context.domElement.requestPointerLock();
|
||||
|
||||
if (pointerLockResult !== undefined && "catch" in pointerLockResult) {
|
||||
pointerLockResult.catch(() => {
|
||||
this.context?.setRuntimeMessage(
|
||||
"Pointer lock request was denied. Click again or use Orbit Visitor for non-locked navigation."
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user