2026-04-11 11:15:52 +02:00
|
|
|
import { Vector3 } from "three";
|
|
|
|
|
|
|
|
|
|
import type { Vec3 } from "../core/vector";
|
|
|
|
|
|
2026-04-11 12:30:37 +02:00
|
|
|
import {
|
2026-04-11 18:37:49 +02:00
|
|
|
FIRST_PERSON_PLAYER_SHAPE,
|
|
|
|
|
cloneFirstPersonPlayerShape,
|
|
|
|
|
getFirstPersonPlayerEyeHeight
|
|
|
|
|
} from "./player-collision";
|
|
|
|
|
import {
|
|
|
|
|
resolvePlayerStartActionInputs,
|
2026-04-11 12:30:37 +02:00
|
|
|
resolvePlayerStartLookInput,
|
|
|
|
|
} from "./player-input-bindings";
|
2026-04-11 18:37:49 +02:00
|
|
|
import {
|
|
|
|
|
createIdleRuntimeLocomotionState,
|
|
|
|
|
stepPlayerLocomotion
|
|
|
|
|
} from "./player-locomotion";
|
2026-04-11 19:05:45 +02:00
|
|
|
import { createPlayerControllerTelemetry } from "./player-controller-telemetry";
|
2026-04-12 02:29:42 +02:00
|
|
|
import { smoothGroundedStairHeight } from "./stair-height-smoothing";
|
2026-04-11 11:15:52 +02:00
|
|
|
import type {
|
|
|
|
|
NavigationController,
|
|
|
|
|
NavigationControllerDeactivateOptions,
|
2026-04-25 16:27:27 +02:00
|
|
|
PlayerControllerTelemetry,
|
2026-04-11 11:15:52 +02:00
|
|
|
RuntimeControllerContext,
|
2026-04-25 16:27:27 +02:00
|
|
|
RuntimeLocomotionState,
|
|
|
|
|
RuntimeTargetLookInputResult
|
2026-04-11 11:15:52 +02:00
|
|
|
} from "./navigation-controller";
|
2026-04-11 18:00:48 +02:00
|
|
|
import type { RuntimePlayerMovement } from "./runtime-scene-build";
|
2026-04-11 11:15:52 +02:00
|
|
|
|
|
|
|
|
const LOOK_SENSITIVITY = 0.008;
|
2026-04-11 12:30:37 +02:00
|
|
|
const GAMEPAD_LOOK_SPEED = 2.8;
|
2026-04-11 11:15:52 +02:00
|
|
|
const DEFAULT_CAMERA_DISTANCE = 4.5;
|
|
|
|
|
const MIN_CAMERA_DISTANCE = 1.5;
|
|
|
|
|
const MAX_CAMERA_DISTANCE = 7;
|
|
|
|
|
const DEFAULT_PITCH_RADIANS = 0.35;
|
2026-04-25 19:15:19 +02:00
|
|
|
const MIN_PITCH_RADIANS = -Math.PI * 0.3;
|
2026-04-11 11:15:52 +02:00
|
|
|
const MAX_PITCH_RADIANS = Math.PI * 0.45;
|
2026-04-25 03:33:49 +02:00
|
|
|
export const THIRD_PERSON_CAMERA_COLLISION_RADIUS = 0.2;
|
2026-04-25 18:52:36 +02:00
|
|
|
const CAMERA_COLLISION_RECOVERY_SPEED = 6.5;
|
|
|
|
|
const CAMERA_COLLISION_DISTANCE_EPSILON = 1e-4;
|
2026-04-11 11:15:52 +02:00
|
|
|
const CAMERA_PIVOT_EYE_HEIGHT_FACTOR = 0.85;
|
2026-04-25 17:10:37 +02:00
|
|
|
const TARGET_ASSIST_YAW_SPEED = 2.2;
|
2026-04-25 17:17:28 +02:00
|
|
|
const TARGET_ASSIST_ORBIT_PITCH_RETURN_SPEED = 5.5;
|
2026-04-25 16:53:39 +02:00
|
|
|
const TARGET_ASSIST_VERTICAL_LOOK_SPEED = 3.4;
|
|
|
|
|
const TARGET_ASSIST_VERTICAL_LOOK_LIMIT = 1.25;
|
2026-04-25 17:01:34 +02:00
|
|
|
const TARGET_ASSIST_VERTICAL_COLLISION_FADE_START_RATIO = 0.28;
|
|
|
|
|
const TARGET_ASSIST_VERTICAL_COLLISION_FADE_END_RATIO = 0.72;
|
2026-04-25 16:27:27 +02:00
|
|
|
const TARGET_LOOK_OFFSET_GAMEPAD_SPEED = 1.15;
|
|
|
|
|
const TARGET_LOOK_OFFSET_POINTER_SENSITIVITY = 0.004;
|
|
|
|
|
const TARGET_LOOK_OFFSET_RETURN_SPEED = 5.5;
|
2026-04-25 17:26:39 +02:00
|
|
|
const TARGET_LOOK_OFFSET_YAW_LIMIT = 0.75;
|
|
|
|
|
const TARGET_LOOK_OFFSET_PITCH_LIMIT = 0.42;
|
2026-04-25 16:27:27 +02:00
|
|
|
const POINTER_TARGET_LOOK_INPUT_SCALE = 0.06;
|
2026-04-11 11:15:52 +02:00
|
|
|
|
|
|
|
|
function clampPitch(pitchRadians: number): number {
|
|
|
|
|
return Math.max(
|
|
|
|
|
MIN_PITCH_RADIANS,
|
|
|
|
|
Math.min(MAX_PITCH_RADIANS, pitchRadians)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clampCameraDistance(distance: number): number {
|
|
|
|
|
return Math.max(
|
|
|
|
|
MIN_CAMERA_DISTANCE,
|
|
|
|
|
Math.min(MAX_CAMERA_DISTANCE, distance)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 17:10:37 +02:00
|
|
|
function normalizeAngleRadians(angle: number): number {
|
|
|
|
|
return Math.atan2(Math.sin(angle), Math.cos(angle));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dampAngleRadians(
|
|
|
|
|
current: number,
|
|
|
|
|
target: number,
|
|
|
|
|
strength: number,
|
|
|
|
|
dt: number
|
|
|
|
|
): number {
|
|
|
|
|
if (dt <= 0 || strength <= 0) {
|
|
|
|
|
return current;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const alpha = 1 - Math.exp(-strength * dt);
|
|
|
|
|
return current + normalizeAngleRadians(target - current) * alpha;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 16:27:27 +02:00
|
|
|
function dampScalar(
|
|
|
|
|
current: number,
|
|
|
|
|
target: number,
|
|
|
|
|
strength: number,
|
|
|
|
|
dt: number
|
|
|
|
|
): number {
|
|
|
|
|
if (dt <= 0 || strength <= 0) {
|
|
|
|
|
return current;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const alpha = 1 - Math.exp(-strength * dt);
|
|
|
|
|
|
|
|
|
|
return current + (target - current) * alpha;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 17:01:34 +02:00
|
|
|
function smoothStep01(value: number): number {
|
|
|
|
|
const t = Math.max(0, Math.min(1, value));
|
|
|
|
|
|
|
|
|
|
return t * t * (3 - 2 * t);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 11:15:52 +02:00
|
|
|
function toEyePosition(feetPosition: Vec3, eyeHeight: number): Vec3 {
|
|
|
|
|
return {
|
|
|
|
|
x: feetPosition.x,
|
|
|
|
|
y: feetPosition.y + eyeHeight,
|
|
|
|
|
z: feetPosition.z
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 18:00:48 +02:00
|
|
|
function cloneRuntimePlayerMovement(
|
|
|
|
|
movement: RuntimePlayerMovement
|
|
|
|
|
): RuntimePlayerMovement {
|
|
|
|
|
return {
|
|
|
|
|
templateKind: movement.templateKind,
|
|
|
|
|
moveSpeed: movement.moveSpeed,
|
2026-04-11 20:30:39 +02:00
|
|
|
maxSpeed: movement.maxSpeed,
|
2026-04-11 20:59:15 +02:00
|
|
|
maxStepHeight: movement.maxStepHeight,
|
2026-04-11 18:00:48 +02:00
|
|
|
capabilities: {
|
|
|
|
|
jump: movement.capabilities.jump,
|
|
|
|
|
sprint: movement.capabilities.sprint,
|
|
|
|
|
crouch: movement.capabilities.crouch
|
2026-04-11 20:10:01 +02:00
|
|
|
},
|
|
|
|
|
jump: {
|
|
|
|
|
speed: movement.jump.speed,
|
|
|
|
|
bufferMs: movement.jump.bufferMs,
|
|
|
|
|
coyoteTimeMs: movement.jump.coyoteTimeMs,
|
|
|
|
|
variableHeight: movement.jump.variableHeight,
|
2026-04-11 20:30:39 +02:00
|
|
|
maxHoldMs: movement.jump.maxHoldMs,
|
2026-04-12 02:06:00 +02:00
|
|
|
moveWhileJumping: movement.jump.moveWhileJumping,
|
|
|
|
|
moveWhileFalling: movement.jump.moveWhileFalling,
|
2026-04-12 02:18:53 +02:00
|
|
|
directionOnly: movement.jump.directionOnly,
|
2026-04-11 20:30:39 +02:00
|
|
|
bunnyHop: movement.jump.bunnyHop,
|
|
|
|
|
bunnyHopBoost: movement.jump.bunnyHopBoost
|
2026-04-11 20:10:01 +02:00
|
|
|
},
|
|
|
|
|
sprint: {
|
|
|
|
|
speedMultiplier: movement.sprint.speedMultiplier
|
|
|
|
|
},
|
|
|
|
|
crouch: {
|
|
|
|
|
speedMultiplier: movement.crouch.speedMultiplier
|
2026-04-11 18:00:48 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 11:15:52 +02:00
|
|
|
export class ThirdPersonNavigationController implements NavigationController {
|
|
|
|
|
readonly id = "thirdPerson" as const;
|
|
|
|
|
|
|
|
|
|
private context: RuntimeControllerContext | null = null;
|
|
|
|
|
private readonly pressedKeys = new Set<string>();
|
|
|
|
|
private readonly lookAtVector = new Vector3();
|
|
|
|
|
private feetPosition = {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: 0
|
|
|
|
|
};
|
2026-04-11 18:37:49 +02:00
|
|
|
private standingPlayerShape = cloneFirstPersonPlayerShape(
|
|
|
|
|
FIRST_PERSON_PLAYER_SHAPE
|
|
|
|
|
);
|
|
|
|
|
private activePlayerShape = cloneFirstPersonPlayerShape(
|
|
|
|
|
FIRST_PERSON_PLAYER_SHAPE
|
|
|
|
|
);
|
2026-04-11 11:15:52 +02:00
|
|
|
private yawRadians = 0;
|
|
|
|
|
private cameraYawRadians = 0;
|
|
|
|
|
private pitchRadians = DEFAULT_PITCH_RADIANS;
|
2026-04-25 16:27:27 +02:00
|
|
|
private targetLookOffsetYawRadians = 0;
|
|
|
|
|
private targetLookOffsetPitchRadians = 0;
|
2026-04-25 16:53:39 +02:00
|
|
|
private targetAssistLookOffsetY = 0;
|
2026-04-11 11:15:52 +02:00
|
|
|
private cameraDistance = DEFAULT_CAMERA_DISTANCE;
|
|
|
|
|
private verticalVelocity = 0;
|
|
|
|
|
private grounded = false;
|
2026-04-11 18:37:49 +02:00
|
|
|
private jumpPressed = false;
|
|
|
|
|
private locomotionState: RuntimeLocomotionState =
|
|
|
|
|
createIdleRuntimeLocomotionState("flying");
|
2026-04-11 11:15:52 +02:00
|
|
|
private inWaterVolume = false;
|
|
|
|
|
private inFogVolume = false;
|
|
|
|
|
private dragging = false;
|
|
|
|
|
private lastPointerClientX = 0;
|
|
|
|
|
private lastPointerClientY = 0;
|
|
|
|
|
private initializedFromSpawn = false;
|
2026-04-11 19:05:14 +02:00
|
|
|
private previousTelemetry: PlayerControllerTelemetry | null = null;
|
|
|
|
|
private latestJumpStarted = false;
|
|
|
|
|
private latestHeadBump = false;
|
2026-04-12 02:29:42 +02:00
|
|
|
private smoothedFeetY = 0;
|
2026-04-25 18:53:36 +02:00
|
|
|
private smoothedCameraCollisionDistance: number | null = null;
|
2026-04-11 21:44:10 +02:00
|
|
|
private previousPlanarDisplacement = {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: 0
|
|
|
|
|
};
|
2026-04-11 20:10:01 +02:00
|
|
|
private jumpBufferRemainingMs = 0;
|
|
|
|
|
private coyoteTimeRemainingMs = 0;
|
|
|
|
|
private jumpHoldRemainingMs = 0;
|
2026-04-11 11:15:52 +02:00
|
|
|
|
|
|
|
|
activate(ctx: RuntimeControllerContext): void {
|
|
|
|
|
this.context = ctx;
|
|
|
|
|
|
|
|
|
|
if (!this.initializedFromSpawn) {
|
2026-04-11 18:38:24 +02:00
|
|
|
const runtimeScene = ctx.getRuntimeScene();
|
|
|
|
|
const spawn = runtimeScene.spawn;
|
2026-04-11 11:15:52 +02:00
|
|
|
this.feetPosition = {
|
|
|
|
|
...spawn.position
|
|
|
|
|
};
|
2026-04-11 18:38:24 +02:00
|
|
|
this.standingPlayerShape = cloneFirstPersonPlayerShape(
|
|
|
|
|
runtimeScene.playerCollider
|
|
|
|
|
);
|
|
|
|
|
this.activePlayerShape = cloneFirstPersonPlayerShape(
|
|
|
|
|
runtimeScene.playerCollider
|
|
|
|
|
);
|
2026-04-11 11:15:52 +02:00
|
|
|
this.yawRadians = (spawn.yawDegrees * Math.PI) / 180;
|
|
|
|
|
this.cameraYawRadians = this.yawRadians;
|
|
|
|
|
this.pitchRadians = DEFAULT_PITCH_RADIANS;
|
2026-04-25 16:28:36 +02:00
|
|
|
this.targetLookOffsetYawRadians = 0;
|
|
|
|
|
this.targetLookOffsetPitchRadians = 0;
|
2026-04-25 16:53:39 +02:00
|
|
|
this.targetAssistLookOffsetY = 0;
|
2026-04-11 11:15:52 +02:00
|
|
|
this.cameraDistance = DEFAULT_CAMERA_DISTANCE;
|
|
|
|
|
this.verticalVelocity = 0;
|
|
|
|
|
this.grounded = false;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.jumpPressed = false;
|
2026-04-12 02:29:42 +02:00
|
|
|
this.smoothedFeetY = this.feetPosition.y;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.locomotionState = createIdleRuntimeLocomotionState(
|
|
|
|
|
runtimeScene.playerCollider.mode === "none" ? "flying" : "airborne"
|
|
|
|
|
);
|
2026-04-11 11:15:52 +02:00
|
|
|
this.inWaterVolume = false;
|
|
|
|
|
this.inFogVolume = false;
|
|
|
|
|
this.initializedFromSpawn = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keydown", this.handleKeyDown);
|
|
|
|
|
window.addEventListener("keyup", this.handleKeyUp);
|
|
|
|
|
window.addEventListener("blur", this.handleBlur);
|
|
|
|
|
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
|
|
|
|
ctx.domElement.addEventListener("wheel", this.handleWheel, {
|
|
|
|
|
passive: false
|
|
|
|
|
});
|
|
|
|
|
ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
|
|
|
|
|
window.addEventListener("pointermove", this.handlePointerMove);
|
|
|
|
|
window.addEventListener("pointerup", this.handlePointerUp);
|
|
|
|
|
|
|
|
|
|
ctx.setRuntimeMessage(
|
2026-04-11 12:30:37 +02:00
|
|
|
"Third Person active. Drag to orbit the camera, use the right stick for gamepad camera look, move with your authored bindings, and scroll to zoom."
|
2026-04-11 11:15:52 +02:00
|
|
|
);
|
2026-04-25 18:55:41 +02:00
|
|
|
this.updateCameraTransform(0);
|
2026-04-11 11:15:52 +02:00
|
|
|
this.publishTelemetry();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deactivate(
|
|
|
|
|
ctx: RuntimeControllerContext,
|
|
|
|
|
_options: NavigationControllerDeactivateOptions = {}
|
|
|
|
|
): void {
|
|
|
|
|
void _options;
|
|
|
|
|
window.removeEventListener("keydown", this.handleKeyDown);
|
|
|
|
|
window.removeEventListener("keyup", this.handleKeyUp);
|
|
|
|
|
window.removeEventListener("blur", this.handleBlur);
|
|
|
|
|
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
|
|
|
|
ctx.domElement.removeEventListener("wheel", this.handleWheel);
|
|
|
|
|
ctx.domElement.removeEventListener("contextmenu", this.handleContextMenu);
|
|
|
|
|
window.removeEventListener("pointermove", this.handlePointerMove);
|
|
|
|
|
window.removeEventListener("pointerup", this.handlePointerUp);
|
|
|
|
|
this.pressedKeys.clear();
|
|
|
|
|
this.dragging = false;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.jumpPressed = false;
|
2026-04-11 19:05:45 +02:00
|
|
|
this.latestJumpStarted = false;
|
|
|
|
|
this.latestHeadBump = false;
|
2026-04-11 21:44:10 +02:00
|
|
|
this.previousPlanarDisplacement = {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: 0
|
|
|
|
|
};
|
2026-04-11 20:10:01 +02:00
|
|
|
this.jumpBufferRemainingMs = 0;
|
|
|
|
|
this.coyoteTimeRemainingMs = 0;
|
|
|
|
|
this.jumpHoldRemainingMs = 0;
|
2026-04-11 19:05:45 +02:00
|
|
|
this.previousTelemetry = null;
|
2026-04-25 18:54:08 +02:00
|
|
|
this.smoothedCameraCollisionDistance = null;
|
2026-04-11 11:15:52 +02:00
|
|
|
ctx.setRuntimeMessage(null);
|
2026-04-11 19:05:45 +02:00
|
|
|
ctx.setPlayerControllerTelemetry(null);
|
2026-04-11 11:15:52 +02:00
|
|
|
this.context = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetSceneState(): void {
|
|
|
|
|
this.pressedKeys.clear();
|
|
|
|
|
this.feetPosition = {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: 0
|
|
|
|
|
};
|
|
|
|
|
this.yawRadians = 0;
|
|
|
|
|
this.cameraYawRadians = 0;
|
|
|
|
|
this.pitchRadians = DEFAULT_PITCH_RADIANS;
|
2026-04-25 16:28:36 +02:00
|
|
|
this.targetLookOffsetYawRadians = 0;
|
|
|
|
|
this.targetLookOffsetPitchRadians = 0;
|
2026-04-25 16:53:39 +02:00
|
|
|
this.targetAssistLookOffsetY = 0;
|
2026-04-11 11:15:52 +02:00
|
|
|
this.cameraDistance = DEFAULT_CAMERA_DISTANCE;
|
|
|
|
|
this.verticalVelocity = 0;
|
|
|
|
|
this.grounded = false;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.jumpPressed = false;
|
2026-04-12 02:29:42 +02:00
|
|
|
this.smoothedFeetY = 0;
|
2026-04-25 18:54:38 +02:00
|
|
|
this.smoothedCameraCollisionDistance = null;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.standingPlayerShape = cloneFirstPersonPlayerShape(
|
|
|
|
|
FIRST_PERSON_PLAYER_SHAPE
|
|
|
|
|
);
|
|
|
|
|
this.activePlayerShape = cloneFirstPersonPlayerShape(
|
|
|
|
|
FIRST_PERSON_PLAYER_SHAPE
|
|
|
|
|
);
|
|
|
|
|
this.locomotionState = createIdleRuntimeLocomotionState("flying");
|
2026-04-11 11:15:52 +02:00
|
|
|
this.inWaterVolume = false;
|
|
|
|
|
this.inFogVolume = false;
|
|
|
|
|
this.dragging = false;
|
|
|
|
|
this.lastPointerClientX = 0;
|
|
|
|
|
this.lastPointerClientY = 0;
|
|
|
|
|
this.initializedFromSpawn = false;
|
2026-04-11 19:05:45 +02:00
|
|
|
this.previousTelemetry = null;
|
|
|
|
|
this.latestJumpStarted = false;
|
|
|
|
|
this.latestHeadBump = false;
|
2026-04-11 21:44:10 +02:00
|
|
|
this.previousPlanarDisplacement = {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: 0
|
|
|
|
|
};
|
2026-04-11 20:10:01 +02:00
|
|
|
this.jumpBufferRemainingMs = 0;
|
|
|
|
|
this.coyoteTimeRemainingMs = 0;
|
|
|
|
|
this.jumpHoldRemainingMs = 0;
|
2026-04-11 11:15:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
update(dt: number): void {
|
|
|
|
|
if (this.context === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 18:00:48 +02:00
|
|
|
const runtimeScene = this.context.getRuntimeScene();
|
2026-04-11 18:38:24 +02:00
|
|
|
this.standingPlayerShape = cloneFirstPersonPlayerShape(
|
|
|
|
|
runtimeScene.playerCollider
|
|
|
|
|
);
|
2026-04-11 18:00:48 +02:00
|
|
|
const playerMovement = runtimeScene.playerMovement;
|
2026-04-11 12:30:37 +02:00
|
|
|
const lookInput = resolvePlayerStartLookInput(
|
2026-04-11 18:00:48 +02:00
|
|
|
runtimeScene.playerInputBindings
|
2026-04-11 12:30:37 +02:00
|
|
|
);
|
2026-04-11 18:38:24 +02:00
|
|
|
const inputState = resolvePlayerStartActionInputs(
|
2026-04-11 12:13:19 +02:00
|
|
|
this.pressedKeys,
|
2026-04-11 18:00:48 +02:00
|
|
|
runtimeScene.playerInputBindings
|
2026-04-11 12:13:19 +02:00
|
|
|
);
|
2026-04-11 12:30:37 +02:00
|
|
|
|
2026-04-25 04:14:46 +02:00
|
|
|
const cameraDrivenExternally = this.context.isCameraDrivenExternally() === true;
|
2026-04-25 15:50:37 +02:00
|
|
|
const lookInputActive = lookInput.horizontal !== 0 || lookInput.vertical !== 0;
|
2026-04-25 16:29:10 +02:00
|
|
|
let targetLookResult: RuntimeTargetLookInputResult | null = null;
|
2026-04-25 15:50:37 +02:00
|
|
|
|
|
|
|
|
if (!cameraDrivenExternally && lookInputActive) {
|
2026-04-25 16:29:10 +02:00
|
|
|
targetLookResult =
|
|
|
|
|
this.context.handleRuntimeTargetLookInput?.({
|
|
|
|
|
horizontal: lookInput.horizontal,
|
|
|
|
|
vertical: lookInput.vertical
|
|
|
|
|
}) ?? null;
|
2026-04-25 15:50:37 +02:00
|
|
|
} else if (!cameraDrivenExternally) {
|
2026-04-25 16:29:10 +02:00
|
|
|
targetLookResult =
|
|
|
|
|
this.context.handleRuntimeTargetLookInput?.({
|
|
|
|
|
horizontal: 0,
|
|
|
|
|
vertical: 0
|
|
|
|
|
}) ?? null;
|
2026-04-25 15:50:37 +02:00
|
|
|
}
|
2026-04-25 04:14:46 +02:00
|
|
|
|
2026-04-25 16:29:10 +02:00
|
|
|
if (
|
|
|
|
|
!cameraDrivenExternally &&
|
|
|
|
|
lookInputActive &&
|
|
|
|
|
targetLookResult?.activeTargetLocked === true
|
|
|
|
|
) {
|
|
|
|
|
if (
|
|
|
|
|
targetLookResult.switchedTarget !== true &&
|
|
|
|
|
targetLookResult.switchInputHeld !== true
|
|
|
|
|
) {
|
2026-04-25 17:27:00 +02:00
|
|
|
this.applyTargetLookOffsetDelta(
|
|
|
|
|
-lookInput.horizontal * TARGET_LOOK_OFFSET_GAMEPAD_SPEED * dt,
|
|
|
|
|
-lookInput.vertical * TARGET_LOOK_OFFSET_GAMEPAD_SPEED * dt
|
2026-04-25 16:29:10 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} else if (!cameraDrivenExternally && lookInputActive) {
|
2026-04-25 15:40:55 +02:00
|
|
|
this.cameraYawRadians -= lookInput.horizontal * GAMEPAD_LOOK_SPEED * dt;
|
2026-04-11 12:30:37 +02:00
|
|
|
this.pitchRadians = clampPitch(
|
|
|
|
|
this.pitchRadians - lookInput.vertical * GAMEPAD_LOOK_SPEED * dt
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 16:29:10 +02:00
|
|
|
if (
|
|
|
|
|
cameraDrivenExternally ||
|
|
|
|
|
!lookInputActive ||
|
|
|
|
|
targetLookResult?.activeTargetLocked !== true ||
|
|
|
|
|
targetLookResult.switchedTarget === true ||
|
|
|
|
|
targetLookResult.switchInputHeld === true
|
|
|
|
|
) {
|
|
|
|
|
this.targetLookOffsetYawRadians = dampScalar(
|
|
|
|
|
this.targetLookOffsetYawRadians,
|
|
|
|
|
0,
|
|
|
|
|
TARGET_LOOK_OFFSET_RETURN_SPEED,
|
|
|
|
|
dt
|
|
|
|
|
);
|
|
|
|
|
this.targetLookOffsetPitchRadians = dampScalar(
|
|
|
|
|
this.targetLookOffsetPitchRadians,
|
|
|
|
|
0,
|
|
|
|
|
TARGET_LOOK_OFFSET_RETURN_SPEED,
|
|
|
|
|
dt
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 04:14:46 +02:00
|
|
|
if (!cameraDrivenExternally) {
|
|
|
|
|
const targetAssist =
|
|
|
|
|
this.context.resolveThirdPersonTargetAssist?.() ?? null;
|
|
|
|
|
|
|
|
|
|
if (targetAssist !== null) {
|
|
|
|
|
const targetYaw = Math.atan2(
|
|
|
|
|
targetAssist.targetPosition.x - this.feetPosition.x,
|
|
|
|
|
targetAssist.targetPosition.z - this.feetPosition.z
|
|
|
|
|
);
|
2026-04-25 17:17:28 +02:00
|
|
|
this.pitchRadians = dampScalar(
|
|
|
|
|
this.pitchRadians,
|
|
|
|
|
DEFAULT_PITCH_RADIANS,
|
|
|
|
|
TARGET_ASSIST_ORBIT_PITCH_RETURN_SPEED * targetAssist.strength,
|
|
|
|
|
dt
|
|
|
|
|
);
|
2026-04-25 17:10:37 +02:00
|
|
|
this.cameraYawRadians = dampAngleRadians(
|
|
|
|
|
this.cameraYawRadians,
|
|
|
|
|
targetYaw,
|
|
|
|
|
TARGET_ASSIST_YAW_SPEED * targetAssist.strength,
|
|
|
|
|
dt
|
|
|
|
|
);
|
2026-04-25 16:53:55 +02:00
|
|
|
const eyeHeight = getFirstPersonPlayerEyeHeight(this.activePlayerShape);
|
|
|
|
|
const pivotY =
|
|
|
|
|
this.smoothedFeetY + eyeHeight * CAMERA_PIVOT_EYE_HEIGHT_FACTOR;
|
|
|
|
|
const targetLookOffsetY = Math.max(
|
|
|
|
|
-TARGET_ASSIST_VERTICAL_LOOK_LIMIT,
|
|
|
|
|
Math.min(
|
|
|
|
|
TARGET_ASSIST_VERTICAL_LOOK_LIMIT,
|
|
|
|
|
targetAssist.targetPosition.y - pivotY
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
this.targetAssistLookOffsetY = dampScalar(
|
|
|
|
|
this.targetAssistLookOffsetY,
|
|
|
|
|
targetLookOffsetY,
|
|
|
|
|
TARGET_ASSIST_VERTICAL_LOOK_SPEED * targetAssist.strength,
|
|
|
|
|
dt
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
this.targetAssistLookOffsetY = dampScalar(
|
|
|
|
|
this.targetAssistLookOffsetY,
|
|
|
|
|
0,
|
|
|
|
|
TARGET_ASSIST_VERTICAL_LOOK_SPEED,
|
|
|
|
|
dt
|
|
|
|
|
);
|
2026-04-25 04:14:46 +02:00
|
|
|
}
|
2026-04-25 16:53:55 +02:00
|
|
|
} else {
|
|
|
|
|
this.targetAssistLookOffsetY = dampScalar(
|
|
|
|
|
this.targetAssistLookOffsetY,
|
|
|
|
|
0,
|
|
|
|
|
TARGET_ASSIST_VERTICAL_LOOK_SPEED,
|
|
|
|
|
dt
|
|
|
|
|
);
|
2026-04-25 04:14:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 17:04:28 +02:00
|
|
|
const movementYawRadians =
|
2026-04-25 04:14:46 +02:00
|
|
|
cameraDrivenExternally
|
2026-04-22 17:04:28 +02:00
|
|
|
? this.context.getCameraYawRadians()
|
|
|
|
|
: this.cameraYawRadians;
|
|
|
|
|
|
2026-04-11 18:38:24 +02:00
|
|
|
const locomotionStep = stepPlayerLocomotion(
|
2026-04-11 11:15:52 +02:00
|
|
|
{
|
2026-04-11 18:38:24 +02:00
|
|
|
dt,
|
|
|
|
|
feetPosition: this.feetPosition,
|
2026-04-22 17:04:28 +02:00
|
|
|
movementYawRadians,
|
2026-04-12 02:18:53 +02:00
|
|
|
airDirectionYawRadians: this.yawRadians,
|
2026-04-11 18:38:24 +02:00
|
|
|
standingShape: this.standingPlayerShape,
|
|
|
|
|
verticalVelocity: this.verticalVelocity,
|
2026-04-11 19:34:22 +02:00
|
|
|
previousLocomotionState: this.locomotionState,
|
2026-04-11 21:44:10 +02:00
|
|
|
previousPlanarDisplacement: this.previousPlanarDisplacement,
|
2026-04-11 20:10:01 +02:00
|
|
|
jumpBufferRemainingMs: this.jumpBufferRemainingMs,
|
|
|
|
|
coyoteTimeRemainingMs: this.coyoteTimeRemainingMs,
|
|
|
|
|
jumpHoldRemainingMs: this.jumpHoldRemainingMs,
|
2026-04-11 18:38:24 +02:00
|
|
|
crouched: this.locomotionState.crouched,
|
|
|
|
|
wasJumpPressed: this.jumpPressed,
|
|
|
|
|
input: inputState,
|
|
|
|
|
movement: playerMovement,
|
|
|
|
|
resolveMotion: (feetPosition, motion, shape) =>
|
|
|
|
|
this.context?.resolveFirstPersonMotion(feetPosition, motion, shape) ??
|
|
|
|
|
null,
|
|
|
|
|
resolveVolumeState: (feetPosition) =>
|
|
|
|
|
this.context?.resolvePlayerVolumeState(feetPosition) ?? {
|
|
|
|
|
inWater: false,
|
2026-04-11 22:13:10 +02:00
|
|
|
inFog: false,
|
|
|
|
|
waterSurfaceHeight: null
|
2026-04-11 18:38:24 +02:00
|
|
|
},
|
|
|
|
|
probeGround: (feetPosition, shape, maxDistance) =>
|
2026-04-11 18:42:37 +02:00
|
|
|
this.context?.probePlayerGround?.(feetPosition, shape, maxDistance) ?? {
|
2026-04-11 18:38:24 +02:00
|
|
|
grounded: false,
|
|
|
|
|
distance: null,
|
|
|
|
|
normal: null,
|
|
|
|
|
slopeDegrees: null
|
|
|
|
|
},
|
|
|
|
|
canOccupyShape: (feetPosition, shape) =>
|
2026-04-11 18:42:37 +02:00
|
|
|
this.context?.canOccupyPlayerShape?.(feetPosition, shape) ?? true
|
2026-04-11 18:38:24 +02:00
|
|
|
}
|
2026-04-11 11:15:52 +02:00
|
|
|
);
|
|
|
|
|
|
2026-04-11 18:38:24 +02:00
|
|
|
if (locomotionStep === null) {
|
2026-04-25 18:55:41 +02:00
|
|
|
this.updateCameraTransform(dt);
|
2026-04-11 11:15:52 +02:00
|
|
|
this.publishTelemetry();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 18:38:24 +02:00
|
|
|
this.feetPosition = locomotionStep.feetPosition;
|
|
|
|
|
this.activePlayerShape = locomotionStep.activeShape;
|
|
|
|
|
this.verticalVelocity = locomotionStep.verticalVelocity;
|
2026-04-11 20:10:01 +02:00
|
|
|
this.jumpBufferRemainingMs = locomotionStep.jumpBufferRemainingMs;
|
|
|
|
|
this.coyoteTimeRemainingMs = locomotionStep.coyoteTimeRemainingMs;
|
|
|
|
|
this.jumpHoldRemainingMs = locomotionStep.jumpHoldRemainingMs;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.jumpPressed = locomotionStep.jumpPressed;
|
2026-04-11 19:05:45 +02:00
|
|
|
this.latestJumpStarted = locomotionStep.jumpStarted;
|
|
|
|
|
this.latestHeadBump = locomotionStep.headBump;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.locomotionState = locomotionStep.locomotionState;
|
2026-04-11 21:44:10 +02:00
|
|
|
this.previousPlanarDisplacement = locomotionStep.planarDisplacement;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.grounded = locomotionStep.locomotionState.grounded;
|
|
|
|
|
this.inWaterVolume = locomotionStep.inWaterVolume;
|
|
|
|
|
this.inFogVolume = locomotionStep.inFogVolume;
|
2026-04-12 02:29:42 +02:00
|
|
|
this.smoothedFeetY = smoothGroundedStairHeight({
|
|
|
|
|
currentSmoothedFeetY: this.smoothedFeetY,
|
|
|
|
|
targetFeetY: this.feetPosition.y,
|
|
|
|
|
grounded: this.grounded,
|
|
|
|
|
dt,
|
|
|
|
|
maxStepHeight: playerMovement.maxStepHeight
|
|
|
|
|
});
|
2026-04-11 18:38:24 +02:00
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
Math.hypot(
|
|
|
|
|
locomotionStep.planarDisplacement.x,
|
|
|
|
|
locomotionStep.planarDisplacement.z
|
|
|
|
|
) > 1e-5
|
|
|
|
|
) {
|
|
|
|
|
this.yawRadians = Math.atan2(
|
|
|
|
|
locomotionStep.planarDisplacement.x,
|
|
|
|
|
locomotionStep.planarDisplacement.z
|
|
|
|
|
);
|
2026-04-11 11:15:52 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 18:55:41 +02:00
|
|
|
this.updateCameraTransform(dt);
|
2026-04-11 11:15:52 +02:00
|
|
|
this.publishTelemetry();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
teleportTo(feetPosition: Vec3, yawDegrees: number) {
|
|
|
|
|
this.feetPosition = {
|
|
|
|
|
...feetPosition
|
|
|
|
|
};
|
|
|
|
|
this.yawRadians = (yawDegrees * Math.PI) / 180;
|
|
|
|
|
this.cameraYawRadians = this.yawRadians;
|
|
|
|
|
this.pitchRadians = DEFAULT_PITCH_RADIANS;
|
2026-04-25 16:28:36 +02:00
|
|
|
this.targetLookOffsetYawRadians = 0;
|
|
|
|
|
this.targetLookOffsetPitchRadians = 0;
|
2026-04-25 16:53:39 +02:00
|
|
|
this.targetAssistLookOffsetY = 0;
|
2026-04-11 11:15:52 +02:00
|
|
|
this.verticalVelocity = 0;
|
|
|
|
|
this.grounded = false;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.jumpPressed = false;
|
2026-04-12 02:29:42 +02:00
|
|
|
this.smoothedFeetY = this.feetPosition.y;
|
2026-04-25 18:54:53 +02:00
|
|
|
this.smoothedCameraCollisionDistance = null;
|
2026-04-11 18:38:24 +02:00
|
|
|
this.activePlayerShape = cloneFirstPersonPlayerShape(
|
|
|
|
|
this.context?.getRuntimeScene().playerCollider ?? FIRST_PERSON_PLAYER_SHAPE
|
|
|
|
|
);
|
|
|
|
|
this.standingPlayerShape = cloneFirstPersonPlayerShape(
|
|
|
|
|
this.context?.getRuntimeScene().playerCollider ?? FIRST_PERSON_PLAYER_SHAPE
|
|
|
|
|
);
|
|
|
|
|
this.locomotionState = createIdleRuntimeLocomotionState(
|
|
|
|
|
this.activePlayerShape.mode === "none" ? "flying" : "airborne"
|
|
|
|
|
);
|
2026-04-11 19:05:45 +02:00
|
|
|
this.previousTelemetry = null;
|
|
|
|
|
this.latestJumpStarted = false;
|
|
|
|
|
this.latestHeadBump = false;
|
2026-04-11 21:44:10 +02:00
|
|
|
this.previousPlanarDisplacement = {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: 0
|
|
|
|
|
};
|
2026-04-11 20:10:01 +02:00
|
|
|
this.jumpBufferRemainingMs = 0;
|
|
|
|
|
this.coyoteTimeRemainingMs = 0;
|
|
|
|
|
this.jumpHoldRemainingMs = 0;
|
2026-04-11 11:15:52 +02:00
|
|
|
this.inWaterVolume = false;
|
|
|
|
|
this.inFogVolume = false;
|
2026-04-25 18:55:41 +02:00
|
|
|
this.updateCameraTransform(0);
|
2026-04-11 11:15:52 +02:00
|
|
|
this.publishTelemetry();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 17:26:39 +02:00
|
|
|
private applyTargetLookOffsetDelta(yawDelta: number, pitchDelta: number) {
|
|
|
|
|
const nextYaw = this.targetLookOffsetYawRadians + yawDelta;
|
|
|
|
|
const nextPitch = this.targetLookOffsetPitchRadians + pitchDelta;
|
|
|
|
|
const clampedYaw = Math.max(
|
|
|
|
|
-TARGET_LOOK_OFFSET_YAW_LIMIT,
|
|
|
|
|
Math.min(TARGET_LOOK_OFFSET_YAW_LIMIT, nextYaw)
|
|
|
|
|
);
|
|
|
|
|
const clampedPitch = Math.max(
|
|
|
|
|
-TARGET_LOOK_OFFSET_PITCH_LIMIT,
|
|
|
|
|
Math.min(TARGET_LOOK_OFFSET_PITCH_LIMIT, nextPitch)
|
|
|
|
|
);
|
|
|
|
|
const boundaryReached = clampedYaw !== nextYaw || clampedPitch !== nextPitch;
|
|
|
|
|
|
|
|
|
|
this.targetLookOffsetYawRadians = clampedYaw;
|
|
|
|
|
this.targetLookOffsetPitchRadians = clampedPitch;
|
|
|
|
|
|
|
|
|
|
if (boundaryReached) {
|
|
|
|
|
this.context?.handleRuntimeTargetLookBoundaryReached?.();
|
2026-04-25 17:39:30 +02:00
|
|
|
this.cameraYawRadians += this.targetLookOffsetYawRadians;
|
|
|
|
|
this.pitchRadians = clampPitch(
|
|
|
|
|
this.pitchRadians + this.targetLookOffsetPitchRadians
|
|
|
|
|
);
|
|
|
|
|
this.targetLookOffsetYawRadians = 0;
|
|
|
|
|
this.targetLookOffsetPitchRadians = 0;
|
2026-04-25 17:26:39 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 18:56:04 +02:00
|
|
|
private resolveSmoothedCameraCollisionPosition(
|
|
|
|
|
pivot: Vec3,
|
|
|
|
|
desiredCameraPosition: Vec3,
|
|
|
|
|
resolvedCameraPosition: Vec3,
|
|
|
|
|
dt: number
|
|
|
|
|
): Vec3 {
|
|
|
|
|
const desiredDelta = {
|
|
|
|
|
x: desiredCameraPosition.x - pivot.x,
|
|
|
|
|
y: desiredCameraPosition.y - pivot.y,
|
|
|
|
|
z: desiredCameraPosition.z - pivot.z
|
|
|
|
|
};
|
|
|
|
|
const desiredDistance = Math.hypot(
|
|
|
|
|
desiredDelta.x,
|
|
|
|
|
desiredDelta.y,
|
|
|
|
|
desiredDelta.z
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (desiredDistance <= CAMERA_COLLISION_DISTANCE_EPSILON) {
|
|
|
|
|
this.smoothedCameraCollisionDistance = null;
|
|
|
|
|
return resolvedCameraPosition;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolvedDistance = Math.hypot(
|
|
|
|
|
resolvedCameraPosition.x - pivot.x,
|
|
|
|
|
resolvedCameraPosition.y - pivot.y,
|
|
|
|
|
resolvedCameraPosition.z - pivot.z
|
|
|
|
|
);
|
|
|
|
|
const previousDistance = this.smoothedCameraCollisionDistance;
|
|
|
|
|
const nextDistance =
|
|
|
|
|
previousDistance === null ||
|
|
|
|
|
dt <= 0 ||
|
|
|
|
|
resolvedDistance < previousDistance
|
|
|
|
|
? resolvedDistance
|
|
|
|
|
: dampScalar(
|
|
|
|
|
previousDistance,
|
|
|
|
|
resolvedDistance,
|
|
|
|
|
CAMERA_COLLISION_RECOVERY_SPEED,
|
|
|
|
|
dt
|
|
|
|
|
);
|
|
|
|
|
const clampedDistance = Math.min(
|
|
|
|
|
Math.max(0, nextDistance),
|
|
|
|
|
Math.min(resolvedDistance, desiredDistance)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.smoothedCameraCollisionDistance = clampedDistance;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
x: pivot.x + (desiredDelta.x / desiredDistance) * clampedDistance,
|
|
|
|
|
y: pivot.y + (desiredDelta.y / desiredDistance) * clampedDistance,
|
|
|
|
|
z: pivot.z + (desiredDelta.z / desiredDistance) * clampedDistance
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private updateCameraTransform(dt = 0) {
|
2026-04-11 11:15:52 +02:00
|
|
|
if (this.context === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 18:38:24 +02:00
|
|
|
const eyeHeight = getFirstPersonPlayerEyeHeight(this.activePlayerShape);
|
2026-04-11 11:15:52 +02:00
|
|
|
const pivot = {
|
|
|
|
|
x: this.feetPosition.x,
|
2026-04-12 02:29:42 +02:00
|
|
|
y: this.smoothedFeetY + eyeHeight * CAMERA_PIVOT_EYE_HEIGHT_FACTOR,
|
2026-04-11 11:15:52 +02:00
|
|
|
z: this.feetPosition.z
|
|
|
|
|
};
|
2026-04-25 16:29:27 +02:00
|
|
|
const resolvedCameraYawRadians =
|
|
|
|
|
this.cameraYawRadians + this.targetLookOffsetYawRadians;
|
|
|
|
|
const resolvedPitchRadians = clampPitch(
|
|
|
|
|
this.pitchRadians + this.targetLookOffsetPitchRadians
|
|
|
|
|
);
|
2026-04-11 11:15:52 +02:00
|
|
|
const horizontalDistance =
|
2026-04-25 16:29:27 +02:00
|
|
|
Math.cos(resolvedPitchRadians) * this.cameraDistance;
|
2026-04-11 11:15:52 +02:00
|
|
|
const desiredCameraPosition = {
|
2026-04-25 16:29:27 +02:00
|
|
|
x: pivot.x - Math.sin(resolvedCameraYawRadians) * horizontalDistance,
|
|
|
|
|
y: pivot.y + Math.sin(resolvedPitchRadians) * this.cameraDistance,
|
|
|
|
|
z: pivot.z - Math.cos(resolvedCameraYawRadians) * horizontalDistance
|
2026-04-11 11:15:52 +02:00
|
|
|
};
|
2026-04-25 18:56:04 +02:00
|
|
|
const rawResolvedCameraPosition =
|
2026-04-11 11:15:52 +02:00
|
|
|
this.context.resolveThirdPersonCameraCollision(
|
|
|
|
|
pivot,
|
|
|
|
|
desiredCameraPosition,
|
2026-04-25 03:33:49 +02:00
|
|
|
THIRD_PERSON_CAMERA_COLLISION_RADIUS
|
2026-04-11 11:15:52 +02:00
|
|
|
);
|
2026-04-25 18:56:04 +02:00
|
|
|
const resolvedCameraPosition = this.resolveSmoothedCameraCollisionPosition(
|
|
|
|
|
pivot,
|
|
|
|
|
desiredCameraPosition,
|
|
|
|
|
rawResolvedCameraPosition,
|
|
|
|
|
dt
|
|
|
|
|
);
|
2026-04-25 17:01:49 +02:00
|
|
|
const resolvedCameraDistance = Math.hypot(
|
|
|
|
|
resolvedCameraPosition.x - pivot.x,
|
|
|
|
|
resolvedCameraPosition.y - pivot.y,
|
|
|
|
|
resolvedCameraPosition.z - pivot.z
|
|
|
|
|
);
|
|
|
|
|
const collisionDistanceRatio =
|
|
|
|
|
resolvedCameraDistance / Math.max(this.cameraDistance, Number.EPSILON);
|
|
|
|
|
const targetAssistVerticalCollisionScale = smoothStep01(
|
|
|
|
|
(collisionDistanceRatio -
|
|
|
|
|
TARGET_ASSIST_VERTICAL_COLLISION_FADE_START_RATIO) /
|
|
|
|
|
(TARGET_ASSIST_VERTICAL_COLLISION_FADE_END_RATIO -
|
|
|
|
|
TARGET_ASSIST_VERTICAL_COLLISION_FADE_START_RATIO)
|
|
|
|
|
);
|
2026-04-11 11:15:52 +02:00
|
|
|
|
|
|
|
|
this.context.camera.position.set(
|
|
|
|
|
resolvedCameraPosition.x,
|
|
|
|
|
resolvedCameraPosition.y,
|
|
|
|
|
resolvedCameraPosition.z
|
|
|
|
|
);
|
2026-04-25 16:54:16 +02:00
|
|
|
this.lookAtVector.set(
|
|
|
|
|
pivot.x,
|
2026-04-25 17:01:49 +02:00
|
|
|
pivot.y +
|
|
|
|
|
this.targetAssistLookOffsetY * targetAssistVerticalCollisionScale,
|
2026-04-25 16:54:16 +02:00
|
|
|
pivot.z
|
|
|
|
|
);
|
2026-04-11 11:15:52 +02:00
|
|
|
this.context.camera.lookAt(this.lookAtVector);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private publishTelemetry() {
|
|
|
|
|
if (this.context === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const eyePosition = toEyePosition(
|
|
|
|
|
this.feetPosition,
|
2026-04-11 18:38:24 +02:00
|
|
|
getFirstPersonPlayerEyeHeight(this.activePlayerShape)
|
2026-04-11 11:15:52 +02:00
|
|
|
);
|
|
|
|
|
const cameraVolumeState = this.context.resolvePlayerVolumeState({
|
|
|
|
|
x: this.context.camera.position.x,
|
|
|
|
|
y: this.context.camera.position.y,
|
|
|
|
|
z: this.context.camera.position.z
|
|
|
|
|
});
|
2026-04-11 22:13:10 +02:00
|
|
|
const cameraSubmerged =
|
|
|
|
|
cameraVolumeState.inWater &&
|
|
|
|
|
cameraVolumeState.waterSurfaceHeight !== null &&
|
|
|
|
|
this.context.camera.position.y < cameraVolumeState.waterSurfaceHeight;
|
2026-04-11 11:15:52 +02:00
|
|
|
|
2026-04-11 19:05:45 +02:00
|
|
|
const telemetry = createPlayerControllerTelemetry({
|
2026-04-11 11:15:52 +02:00
|
|
|
feetPosition: {
|
|
|
|
|
...this.feetPosition
|
|
|
|
|
},
|
|
|
|
|
eyePosition,
|
2026-04-25 03:53:09 +02:00
|
|
|
yawDegrees: (this.yawRadians * 180) / Math.PI,
|
2026-04-11 11:15:52 +02:00
|
|
|
grounded: this.grounded,
|
|
|
|
|
locomotionState: this.locomotionState,
|
2026-04-11 18:00:48 +02:00
|
|
|
movement: cloneRuntimePlayerMovement(
|
|
|
|
|
this.context.getRuntimeScene().playerMovement
|
|
|
|
|
),
|
2026-04-11 11:15:52 +02:00
|
|
|
inWaterVolume: this.inWaterVolume,
|
2026-04-11 22:13:10 +02:00
|
|
|
cameraSubmerged,
|
2026-04-11 11:15:52 +02:00
|
|
|
inFogVolume: this.inFogVolume,
|
|
|
|
|
pointerLocked: false,
|
2026-04-11 19:05:45 +02:00
|
|
|
spawn: this.context.getRuntimeScene().spawn,
|
|
|
|
|
previousLocomotionState: this.previousTelemetry?.locomotionState ?? null,
|
|
|
|
|
previousInWaterVolume: this.previousTelemetry?.inWaterVolume ?? false,
|
|
|
|
|
jumpStarted: this.latestJumpStarted,
|
|
|
|
|
headBump: this.latestHeadBump
|
2026-04-11 11:15:52 +02:00
|
|
|
});
|
2026-04-11 19:05:45 +02:00
|
|
|
|
|
|
|
|
this.context.setPlayerControllerTelemetry(telemetry);
|
|
|
|
|
this.previousTelemetry = telemetry;
|
|
|
|
|
this.latestJumpStarted = false;
|
|
|
|
|
this.latestHeadBump = false;
|
2026-04-11 11:15:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
this.pressedKeys.add(event.code);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handleKeyUp = (event: KeyboardEvent) => {
|
|
|
|
|
this.pressedKeys.delete(event.code);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handleBlur = () => {
|
|
|
|
|
this.pressedKeys.clear();
|
|
|
|
|
this.dragging = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handlePointerDown = (event: PointerEvent) => {
|
2026-04-14 22:38:21 +02:00
|
|
|
if (
|
|
|
|
|
event.button !== 0 ||
|
2026-04-22 17:04:28 +02:00
|
|
|
this.context?.isInputSuspended() === true ||
|
|
|
|
|
this.context?.isCameraDrivenExternally() === true
|
2026-04-14 22:38:21 +02:00
|
|
|
) {
|
2026-04-11 11:15:52 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.dragging = true;
|
|
|
|
|
this.lastPointerClientX = event.clientX;
|
|
|
|
|
this.lastPointerClientY = event.clientY;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handlePointerMove = (event: PointerEvent) => {
|
2026-04-14 22:38:21 +02:00
|
|
|
if (
|
|
|
|
|
!this.dragging ||
|
2026-04-22 17:04:28 +02:00
|
|
|
this.context?.isInputSuspended() === true ||
|
|
|
|
|
this.context?.isCameraDrivenExternally() === true
|
2026-04-14 22:38:21 +02:00
|
|
|
) {
|
2026-04-11 11:15:52 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const deltaX = event.clientX - this.lastPointerClientX;
|
|
|
|
|
const deltaY = event.clientY - this.lastPointerClientY;
|
|
|
|
|
this.lastPointerClientX = event.clientX;
|
|
|
|
|
this.lastPointerClientY = event.clientY;
|
|
|
|
|
|
2026-04-25 16:29:41 +02:00
|
|
|
const targetLookResult =
|
|
|
|
|
this.context?.handleRuntimeTargetLookInput?.({
|
|
|
|
|
horizontal: deltaX * POINTER_TARGET_LOOK_INPUT_SCALE,
|
|
|
|
|
vertical: -deltaY * POINTER_TARGET_LOOK_INPUT_SCALE
|
|
|
|
|
}) ?? null;
|
2026-04-25 15:50:37 +02:00
|
|
|
|
2026-04-25 16:29:41 +02:00
|
|
|
if (targetLookResult?.activeTargetLocked === true) {
|
|
|
|
|
if (
|
|
|
|
|
targetLookResult.switchedTarget !== true &&
|
|
|
|
|
targetLookResult.switchInputHeld !== true
|
|
|
|
|
) {
|
2026-04-25 17:27:00 +02:00
|
|
|
this.applyTargetLookOffsetDelta(
|
|
|
|
|
-deltaX * TARGET_LOOK_OFFSET_POINTER_SENSITIVITY,
|
|
|
|
|
deltaY * TARGET_LOOK_OFFSET_POINTER_SENSITIVITY
|
2026-04-25 16:29:41 +02:00
|
|
|
);
|
|
|
|
|
}
|
2026-04-25 15:50:37 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 15:40:55 +02:00
|
|
|
this.cameraYawRadians -= deltaX * LOOK_SENSITIVITY;
|
2026-04-11 11:15:52 +02:00
|
|
|
this.pitchRadians = clampPitch(
|
|
|
|
|
this.pitchRadians + deltaY * LOOK_SENSITIVITY
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handlePointerUp = () => {
|
|
|
|
|
this.dragging = false;
|
2026-04-25 16:29:41 +02:00
|
|
|
this.context?.handleRuntimeTargetLookInput?.({
|
|
|
|
|
horizontal: 0,
|
|
|
|
|
vertical: 0
|
|
|
|
|
});
|
2026-04-11 11:15:52 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handleWheel = (event: WheelEvent) => {
|
2026-04-22 17:04:28 +02:00
|
|
|
if (
|
|
|
|
|
this.context?.isInputSuspended() === true ||
|
|
|
|
|
this.context?.isCameraDrivenExternally() === true
|
|
|
|
|
) {
|
2026-04-14 22:38:21 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 11:15:52 +02:00
|
|
|
event.preventDefault();
|
|
|
|
|
this.cameraDistance = clampCameraDistance(
|
|
|
|
|
this.cameraDistance + event.deltaY * 0.01
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handleContextMenu = (event: MouseEvent) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
};
|
|
|
|
|
}
|