import { Camera } from '../scene/camera.js';
import { Object3D } from '../scene/object3d.js';
/**
* Default ground Y position for movement and jumping.
*
* @type {number}
*/
const DEFAULT_GROUND_Y = 0.0;
/**
* Default camera azimuth (yaw) in radians.
*
* @type {number}
*/
const DEFAULT_AZIMUTH_RADIANS = 0.0;
/**
* Default camera polar (pitch) in radians.
*
* @type {number}
*/
const DEFAULT_POLAR_RADIANS = -0.35;
/**
* Default minimum pitch angle in radians.
*
* @type {number}
*/
const DEFAULT_MIN_POLAR_RADIANS = -1.25;
/**
* Default maximum pitch angle in radians.
*
* @type {number}
*/
const DEFAULT_MAX_POLAR_RADIANS = 0.55;
/**
* Default rotation speed multiplier.
*
* @type {number}
*/
const DEFAULT_ROTATION_SPEED = 1.0;
/**
* Default walking speed in world units per second.
*
* @type {number}
*/
const DEFAULT_MOVE_SPEED = 3.0;
/**
* Default running speed multiplier.
*
* @type {number}
*/
const DEFAULT_RUN_SPEED_MULTIPLIER = 1.8;
/**
* Default jump speed (vertical velocity).
*
* @type {number}
*/
const DEFAULT_JUMP_SPEED = 4.2;
/**
* Default gravity acceleration magnitude (positive value).
*
* @type {number}
*/
const DEFAULT_GRAVITY_ACCELERATION = 9.8;
/**
* Rotation change per pixel, in radians.
*
* @type {number}
*/
const ROTATION_RADIANS_PER_PIXEL = 0.005;
/**
* Default walking bobbing height.
*
* @type {number}
*/
const DEFAULT_BOBBING_AMPLITUDE_WALK = 0.05;
/**
* Default running bobbing height.
*
* @type {number}
*/
const DEFAULT_BOBBING_AMPLITUDE_RUN = 0.09;
/**
* Default walking bobbing speed (radians per second).
*
* @type {number}
*/
const DEFAULT_BOBBING_FREQUENCY_WALK = 9.0;
/**
* Default running bobbing speed (radians per second).
*
* @type {number}
*/
const DEFAULT_BOBBING_FREQUENCY_RUN = 13.0;
/**
* Default bobbing pitch amplitude in radians.
*
* @type {number}
*/
const DEFAULT_BOBBING_PITCH_AMPLITUDE_RADIANS = 0.02;
/**
* Primary pointer button id used for rotating the camera.
*
* @type {number}
*/
const ROTATE_POINTER_BUTTON = 0;
/**
* Pointer capture reset value.
*
* @type {number}
*/
const POINTER_ID_RESET_VALUE = -1;
/**
* Key action: move forward.
*
* @type {string}
*/
const ACTION_FORWARD = 'forward';
/**
* Key action: move backward.
*
* @type {string}
*/
const ACTION_BACKWARD = 'backward';
/**
* Key action: move left.
*
* @type {string}
*/
const ACTION_LEFT = 'left';
/**
* Key action: move right.
*
* @type {string}
*/
const ACTION_RIGHT = 'right';
/**
* Key action: run (left shift).
*
* @type {string}
*/
const ACTION_RUN = 'run';
/**
* Key action: jump (space).
*
* @type {string}
*/
const ACTION_JUMP = 'jump';
/**
* Key action registry.
*
* @type {{ FORWARD: string, BACKWARD: string, LEFT: string, RIGHT: string, RUN: string, JUMP: string }}
*/
const ACTIONS = Object.freeze({
FORWARD : ACTION_FORWARD,
BACKWARD : ACTION_BACKWARD,
LEFT : ACTION_LEFT,
RIGHT : ACTION_RIGHT,
RUN : ACTION_RUN,
JUMP : ACTION_JUMP
});
/**
* `KeyboardEvent.code` values used by the controls.
*
* @type {{ FORWARD: string, BACKWARD: string, LEFT: string, RIGHT: string, RUN: string, JUMP: string }}
*/
const KEY_CODES = Object.freeze({
FORWARD : 'KeyW',
BACKWARD : 'KeyS',
LEFT : 'KeyA',
RIGHT : 'KeyD',
RUN : 'ShiftLeft',
JUMP : 'Space'
});
/**
* Keyboard event type: `keydown`.
*
* @type {string}
*/
const EVENT_KEYDOWN = 'keydown';
/**
* Keyboard event type: `keyup`.
*
* @type {string}
*/
const EVENT_KEYUP = 'keyup';
/**
* Pointer event type: `pointerdown`.
*
* @type {string}
*/
const EVENT_POINTERDOWN = 'pointerdown';
/**
* Pointer event type: `pointermove`.
*
* @type {string}
*/
const EVENT_POINTERMOVE = 'pointermove';
/**
* Pointer event type: `pointerup`.
*
* @type {string}
*/
const EVENT_POINTERUP = 'pointerup';
/**
* Context menu event type.
*
* @type {string}
*/
const EVENT_CONTEXT_MENU = 'contextmenu';
/**
* Window blur event type.
*
* @type {string}
*/
const EVENT_WINDOW_BLUR = 'blur';
/**
* Touch action CSS value used to disable browser gestures.
*
* @type {string}
*/
const TOUCH_ACTION_NONE = 'none';
/**
* Input vector component for forward motion.
*
* @type {number}
*/
const INPUT_FORWARD = 1;
/**
* Input vector component for backward motion.
*
* @type {number}
*/
const INPUT_BACKWARD = -1;
/**
* No movement input value.
*
* @type {number}
*/
const INPUT_NONE = 0;
/**
* Default movement speed scale (no sprint).
*
* @type {number}
*/
const SPEED_SCALE_DEFAULT = 1;
/**
* Non-zero epsilon used for normalization checks.
*
* @type {number}
*/
const INPUT_EPSILON = 0.0001;
/**
* Start index for loops.
*
* @type {number}
*/
const LOOP_START_INDEX = 0;
/**
* Index increment value for loops.
*
* @type {number}
*/
const LOOP_INDEX_INCREMENT = 1;
/**
* Inclusive lower bound for non-negative values.
*
* @type {number}
*/
const MINIMUM_NON_NEGATIVE_VALUE = 0;
/**
* Lower bound for positive-only checks (must be `> 0`).
*
* @type {number}
*/
const MINIMUM_POSITIVE_VALUE = 0;
/**
* Empty string length used for non-empty checks.
*
* @type {number}
*/
const EMPTY_STRING_LENGTH = 0;
/**
* `KeyboardEvent.key` fallback values used by the controls.
*
* @type {KeyValues}
*/
const KEY_VALUES = Object.freeze({
FORWARD : 'w',
BACKWARD : 's',
LEFT : 'a',
RIGHT : 'd',
RUN : 'shift',
JUMP : ' ',
SPACEBAR : 'spacebar'
});
/**
* Options used by {@link KeyboardControls}.
*
* @typedef {Object} KeyboardControlsOptions
* @property {number} [groundY = 0.0] - Ground height for jumping and landing.
* @property {number} [azimuthRadians = 0] - Initial yaw angle in radians.
* @property {number} [polarRadians = -0.35] - Initial pitch angle in radians.
* @property {number} [minPolarRadians = -1.25] - Minimum pitch angle in radians.
* @property {number} [maxPolarRadians = 0.55] - Maximum pitch angle in radians.
* @property {number} [rotationSpeed = 1.0] - Mouse rotation speed multiplier.
* @property {number} [moveSpeed = 3.0] - Walking speed in world units per second.
* @property {number} [runSpeedMultiplier = 1.8] - Speed multiplier when running (Shift).
* @property {number} [jumpSpeed = 4.2] - Jump velocity.
* @property {number} [gravity = 9.8] - Gravity acceleration magnitude.
* @property {number} [bobbingAmplitudeWalk = 0.05] - Camera bobbing height while walking.
* @property {number} [bobbingAmplitudeRun = 0.09] - Camera bobbing height while running.
* @property {number} [bobbingFrequencyWalk = 9.0] - Bobbing angular speed while walking.
* @property {number} [bobbingFrequencyRun = 13.0] - Bobbing angular speed while running.
* @property {number} [bobbingPitchAmplitudeRadians = 0.02] - Camera pitch bobbing amplitude.
*/
/**
* Per-frame movement intent derived from input.
*
* @typedef {Object} MovementData
* @property {number} moveX
* @property {number} moveZ
* @property {boolean} isMoving
* @property {number} speed
*/
/**
* Derived camera state computed for the current frame.
*
* @typedef {Object} CameraState
* @property {number} azimuthRadians
* @property {number} polarRadians
* @property {number} bobbingOffset
* @property {number} bobbingPitch
*/
/**
* `KeyboardEvent.key` fallback values used by the controls.
*
* @typedef {Object} KeyValues
* @property {string} FORWARD - Forward key value.
* @property {string} BACKWARD - Backward key value.
* @property {string} LEFT - Left key value.
* @property {string} RIGHT - Right key value.
* @property {string} RUN - Run key value.
* @property {string} JUMP - Jump key value (space).
* @property {string} SPACEBAR - Deprecated legacy key value for space.
*/
/**
* Shared `keyboard + pointer` controls for character cameras.
*/
export class KeyboardControls {
/**
* Controlled camera instance.
*
* @type {Camera}
* @private
*/
#camera;
/**
* Controlled target object (player).
*
* @type {Object3D}
* @private
*/
#target;
/**
* DOM element, that receives pointer input (usually the canvas).
*
* @type {HTMLElement}
* @private
*/
#element;
/**
* Controls class name for error messages.
*
* @type {string}
* @private
*/
#controlsName;
/**
* Required camera constructor for validation.
*
* @type {Function}
* @private
*/
#cameraConstructor;
/**
* Camera mode value, that enables bobbing.
*
* @type {string | null}
* @private
*/
#bobbingMode;
/**
* Ground Y coordinate for landing.
*
* @type {number}
* @private
*/
#groundY;
/**
* Azimuth angle (yaw) in radians.
*
* @type {number}
* @private
*/
#azimuthRadians;
/**
* Polar angle (pitch) in radians.
*
* @type {number}
* @private
*/
#polarRadians;
/**
* Minimum allowed polar angle in radians.
*
* @type {number}
* @private
*/
#minPolarRadians;
/**
* Maximum allowed polar angle in radians.
*
* @type {number}
* @private
*/
#maxPolarRadians;
/**
* Rotation speed multiplier.
*
* @type {number}
* @private
*/
#rotationSpeed;
/**
* Base movement speed (walking).
*
* @type {number}
* @private
*/
#moveSpeed;
/**
* Running speed multiplier.
*
* @type {number}
* @private
*/
#runSpeedMultiplier;
/**
* Jump velocity.
*
* @type {number}
* @private
*/
#jumpSpeed;
/**
* Gravity acceleration magnitude.
*
* @type {number}
* @private
*/
#gravity;
/**
* Current vertical velocity.
*
* @type {number}
* @private
*/
#verticalVelocity = INPUT_NONE;
/**
* True when the target is grounded.
*
* @type {boolean}
* @private
*/
#isGrounded = true;
/**
* Camera bobbing height while walking.
*
* @type {number}
* @private
*/
#bobbingAmplitudeWalk;
/**
* Camera bobbing height while running.
*
* @type {number}
* @private
*/
#bobbingAmplitudeRun;
/**
* Camera bobbing angular speed while walking.
*
* @type {number}
* @private
*/
#bobbingFrequencyWalk;
/**
* Camera bobbing angular speed while running.
*
* @type {number}
* @private
*/
#bobbingFrequencyRun;
/**
* Camera pitch bobbing amplitude in radians.
*
* @type {number}
* @private
*/
#bobbingPitchAmplitude;
/**
* Current bobbing phase in radians.
*
* @type {number}
* @private
*/
#bobbingPhase = INPUT_NONE;
/**
* Current camera bobbing offset.
*
* @type {number}
* @private
*/
#bobbingOffset = INPUT_NONE;
/**
* Action states map.
*
* @type {Map<string, boolean>}
* @private
*/
#actionStates = new Map();
/**
* Captured pointer id used during dragging.
*
* @type {number}
* @private
*/
#capturedPointerId = POINTER_ID_RESET_VALUE;
/**
* Previous pointer X position in client pixels.
*
* @type {number}
* @private
*/
#previousPointerX = INPUT_NONE;
/**
* Previous pointer Y position in client pixels.
*
* @type {number}
* @private
*/
#previousPointerY = INPUT_NONE;
/**
* True when input listeners are enabled.
*
* @type {boolean}
* @private
*/
#isEnabled = true;
/**
* Cached pointerdown handler reference used for `removeEventListener`.
*
* @type {function(PointerEvent): void}
* @private
*/
#onPointerDown;
/**
* Cached pointermove handler reference used for `removeEventListener`.
*
* @type {function(PointerEvent): void}
* @private
*/
#onPointerMove;
/**
* Cached pointerup handler reference used for `removeEventListener`.
*
* @type {function(PointerEvent): void}
* @private
*/
#onPointerUp;
/**
* Cached keydown handler reference used for `removeEventListener`.
*
* @type {function(KeyboardEvent): void}
* @private
*/
#onKeyDown;
/**
* Cached keyup handler reference used for `removeEventListener`.
*
* @type {function(KeyboardEvent): void}
* @private
*/
#onKeyUp;
/**
* Cached contextmenu handler reference used for `removeEventListener`.
*
* @type {function(MouseEvent): void}
* @private
*/
#onContextMenu;
/**
* Cached blur handler reference used for `removeEventListener`.
*
* @type {function(): void}
* @private
*/
#onBlur;
/**
* @param {Camera} camera - Controlled camera.
* @param {Object3D} target - Target object, that the camera follows.
* @param {HTMLElement} element - DOM element, that receives pointer input.
* @param {KeyboardControlsOptions} [options] - Optional controls configuration.
* @param {Object} [config] - Internal configuration for derived controls.
* @param {Function} [config.cameraConstructor = Camera] - Expected camera constructor for validation.
* @param {string} [config.controlsName = 'KeyboardControls'] - Controls class name for error messages.
* @param {(string|null)} [config.bobbingMode = null] - Camera mode, that enables bobbing (or null to disable).
*/
constructor(camera, target, element, options = {}, config = {}) {
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
throw new TypeError('`KeyboardControls` expects `options` as a plain object.');
}
const {
cameraConstructor = Camera,
controlsName = 'KeyboardControls',
bobbingMode = null
} = config;
if (typeof cameraConstructor !== 'function') {
throw new TypeError('`KeyboardControls` expects `cameraConstructor` as a function.');
}
if (typeof controlsName !== 'string' || controlsName.length === 0) {
throw new TypeError('`KeyboardControls` expects `controlsName` as a non-empty string.');
}
if (!(camera instanceof cameraConstructor)) {
throw new TypeError(`\`${controlsName}\` expects \`camera\` as a \`${cameraConstructor.name}\` instance.`);
}
if (!(target instanceof Object3D)) {
throw new TypeError(`\`${controlsName}\` expects \`target\` as an \`Object3D\` instance.`);
}
if (!(element instanceof HTMLElement)) {
throw new TypeError(`\`${controlsName}\` expects \`element\` as an \`HTMLElement\`.`);
}
if (bobbingMode !== null && typeof bobbingMode !== 'string') {
throw new TypeError('`KeyboardControls` expects `bobbingMode` as a string or null.');
}
const {
groundY = DEFAULT_GROUND_Y,
azimuthRadians = DEFAULT_AZIMUTH_RADIANS,
polarRadians = DEFAULT_POLAR_RADIANS,
minPolarRadians = DEFAULT_MIN_POLAR_RADIANS,
maxPolarRadians = DEFAULT_MAX_POLAR_RADIANS,
rotationSpeed = DEFAULT_ROTATION_SPEED,
moveSpeed = DEFAULT_MOVE_SPEED,
runSpeedMultiplier = DEFAULT_RUN_SPEED_MULTIPLIER,
jumpSpeed = DEFAULT_JUMP_SPEED,
gravity = DEFAULT_GRAVITY_ACCELERATION,
bobbingAmplitudeWalk = DEFAULT_BOBBING_AMPLITUDE_WALK,
bobbingAmplitudeRun = DEFAULT_BOBBING_AMPLITUDE_RUN,
bobbingFrequencyWalk = DEFAULT_BOBBING_FREQUENCY_WALK,
bobbingFrequencyRun = DEFAULT_BOBBING_FREQUENCY_RUN,
bobbingPitchAmplitudeRadians = DEFAULT_BOBBING_PITCH_AMPLITUDE_RADIANS
} = options;
if (typeof groundY !== 'number') {
throw new TypeError(`\`${controlsName}\` expects \`groundY\` as a number.`);
}
if (typeof azimuthRadians !== 'number' || typeof polarRadians !== 'number') {
throw new TypeError(`\`${controlsName}\` expects \`azimuthRadians\` and \`polarRadians\` as numbers.`);
}
if (typeof minPolarRadians !== 'number' || typeof maxPolarRadians !== 'number') {
throw new TypeError(`\`${controlsName}\` expects \`minPolarRadians\` and \`maxPolarRadians\` as numbers.`);
}
if (minPolarRadians > maxPolarRadians) {
throw new RangeError(`\`${controlsName}\` expects \`minPolarRadians\` to be <= \`maxPolarRadians\`.`);
}
if (typeof rotationSpeed !== 'number' || rotationSpeed <= MINIMUM_POSITIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`rotationSpeed\` as a positive number.`);
}
if (typeof moveSpeed !== 'number' || moveSpeed <= MINIMUM_POSITIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`moveSpeed\` as a positive number.`);
}
if (typeof runSpeedMultiplier !== 'number' || runSpeedMultiplier <= MINIMUM_POSITIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`runSpeedMultiplier\` as a positive number.`);
}
if (typeof jumpSpeed !== 'number' || jumpSpeed <= MINIMUM_POSITIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`jumpSpeed\` as a positive number.`);
}
if (typeof gravity !== 'number' || gravity <= MINIMUM_POSITIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`gravity\` as a positive number.`);
}
if (typeof bobbingAmplitudeWalk !== 'number' || bobbingAmplitudeWalk < MINIMUM_NON_NEGATIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`bobbingAmplitudeWalk\` as a non-negative number.`);
}
if (typeof bobbingAmplitudeRun !== 'number' || bobbingAmplitudeRun < MINIMUM_NON_NEGATIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`bobbingAmplitudeRun\` as a non-negative number.`);
}
if (typeof bobbingFrequencyWalk !== 'number' || bobbingFrequencyWalk <= MINIMUM_POSITIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`bobbingFrequencyWalk\` as a positive number.`);
}
if (typeof bobbingFrequencyRun !== 'number' || bobbingFrequencyRun <= MINIMUM_POSITIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`bobbingFrequencyRun\` as a positive number.`);
}
if (typeof bobbingPitchAmplitudeRadians !== 'number' || bobbingPitchAmplitudeRadians < MINIMUM_NON_NEGATIVE_VALUE) {
throw new RangeError(`\`${controlsName}\` expects \`bobbingPitchAmplitudeRadians\` as a non-negative number.`);
}
this.#camera = camera;
this.#target = target;
this.#element = element;
this.#controlsName = controlsName;
this.#cameraConstructor = cameraConstructor;
this.#bobbingMode = bobbingMode;
this.#groundY = groundY;
this.#azimuthRadians = azimuthRadians;
this.#polarRadians = KeyboardControls.#clamp(polarRadians, minPolarRadians, maxPolarRadians);
this.#minPolarRadians = minPolarRadians;
this.#maxPolarRadians = maxPolarRadians;
this.#rotationSpeed = rotationSpeed;
this.#moveSpeed = moveSpeed;
this.#runSpeedMultiplier = runSpeedMultiplier;
this.#jumpSpeed = jumpSpeed;
this.#gravity = gravity;
this.#bobbingAmplitudeWalk = bobbingAmplitudeWalk;
this.#bobbingAmplitudeRun = bobbingAmplitudeRun;
this.#bobbingFrequencyWalk = bobbingFrequencyWalk;
this.#bobbingFrequencyRun = bobbingFrequencyRun;
this.#bobbingPitchAmplitude = bobbingPitchAmplitudeRadians;
this.#element.style.touchAction = TOUCH_ACTION_NONE;
this.#initActionStates();
this.#onPointerDown = (event) => this.#handlePointerDown(event);
this.#onPointerMove = (event) => this.#handlePointerMove(event);
this.#onPointerUp = (event) => this.#handlePointerUp(event);
this.#onKeyDown = (event) => this.#handleKeyDown(event);
this.#onKeyUp = (event) => this.#handleKeyUp(event);
this.#onContextMenu = (event) => event.preventDefault();
this.#onBlur = () => this.#resetInputStates();
this.#addEventListeners();
}
/**
* Updates the controlled camera and target based on input and time delta.
*
* @param {number} deltaSeconds - Time delta in seconds.
*/
update(deltaSeconds) {
if (typeof deltaSeconds !== 'number' || Number.isFinite(deltaSeconds) !== true) {
throw new TypeError(`\`${this.#controlsName}.update\` expects \`deltaSeconds\` as a finite number.`);
}
const movement = this.#computeMovement(deltaSeconds);
this.#applyMovement(movement, deltaSeconds);
const cameraState = this.#computeCameraState(deltaSeconds, movement.isMoving);
this.applyCameraTransform(movement, cameraState, this.#camera, this.#target);
}
/**
* Enables or disables input listeners without destroying the controls.
*
* @param {boolean} enabled - True to enable input, false to disable.
*/
setEnabled(enabled) {
if (typeof enabled !== 'boolean') {
throw new TypeError(`\`${this.#controlsName}.setEnabled\` expects \`enabled\` as a boolean.`);
}
if (this.#isEnabled === enabled) {
return;
}
this.#isEnabled = enabled;
if (enabled) {
this.#addEventListeners();
return;
}
this.#removeEventListeners();
this.#capturedPointerId = POINTER_ID_RESET_VALUE;
this.#resetInputStates();
}
/**
* Replaces the controlled camera.
*
* @param {Camera} camera - New controlled camera instance.
*/
setCamera(camera) {
if (!(camera instanceof this.#cameraConstructor)) {
throw new TypeError(`\`${this.#controlsName}.setCamera\` expects a \`${this.#cameraConstructor.name}\` instance.`);
}
this.#camera = camera;
}
/**
* Replaces the target object.
*
* @param {Object3D} target - New target object.
*/
setTarget(target) {
if (!(target instanceof Object3D)) {
throw new TypeError(`\`${this.#controlsName}.setTarget\` expects an \`Object3D\` instance.`);
}
this.#target = target;
}
/**
* Disposes the controller by removing all event listeners.
*/
dispose() {
this.#removeEventListeners();
this.#capturedPointerId = POINTER_ID_RESET_VALUE;
this.#resetInputStates();
this.#isEnabled = false;
}
/**
* Updates target rotation based on movement and orientation.
*
* @param {Object3D} target - Controlled target.
* @param {MovementData} movement - Movement data.
* @param {number} azimuthRadians - Current yaw angle in radians.
*/
updateTargetRotation(target, movement, azimuthRadians) {
if (!(target instanceof Object3D)) {
throw new TypeError(`\`${this.#controlsName}.updateTargetRotation\` expects \`target\` as an \`Object3D\` instance.`);
}
KeyboardControls.assertMovementData(movement, `${this.#controlsName}.updateTargetRotation`);
if (typeof azimuthRadians !== 'number') {
throw new TypeError(`\`${this.#controlsName}.updateTargetRotation\` expects \`azimuthRadians\` as a number.`);
}
}
/**
* Applies camera transforms for the active camera type.
*
* @param {MovementData} movement - Movement data.
* @param {CameraState} cameraState - Derived camera state.
* @param {Camera} camera - Controlled camera.
* @param {Object3D} target - Controlled target.
*/
applyCameraTransform(movement, cameraState, camera, target) {
KeyboardControls.assertMovementData(movement , `${this.#controlsName}.applyCameraTransform`);
KeyboardControls.assertCameraState(cameraState , `${this.#controlsName}.applyCameraTransform`);
if (!(camera instanceof this.#cameraConstructor)) {
throw new TypeError(`\`${this.#controlsName}.applyCameraTransform\` expects \`camera\` as a \`${this.#cameraConstructor.name}\` instance.`);
}
if (!(target instanceof Object3D)) {
throw new TypeError(`\`${this.#controlsName}.applyCameraTransform\` expects \`target\` as an \`Object3D\` instance.`);
}
throw new Error('`KeyboardControls.applyCameraTransform` must be implemented in a derived class.');
}
/**
* Initializes action state registry.
*
* @private
*/
#initActionStates() {
const actionValues = Object.values(ACTIONS);
for (let index = LOOP_START_INDEX; index < actionValues.length; index += LOOP_INDEX_INCREMENT) {
this.#actionStates.set(actionValues[index], false);
}
}
/**
* Clears all action states.
*
* @private
*/
#resetInputStates() {
const entries = this.#actionStates.entries();
for (const [action] of entries) {
this.#actionStates.set(action, false);
}
}
/**
* Adds input event listeners.
*
* @private
*/
#addEventListeners() {
this.#element.addEventListener(EVENT_POINTERDOWN, this.#onPointerDown);
window.addEventListener(EVENT_POINTERMOVE, this.#onPointerMove);
window.addEventListener(EVENT_POINTERUP, this.#onPointerUp);
window.addEventListener(EVENT_KEYDOWN, this.#onKeyDown);
window.addEventListener(EVENT_KEYUP, this.#onKeyUp);
this.#element.addEventListener(EVENT_CONTEXT_MENU, this.#onContextMenu);
window.addEventListener(EVENT_WINDOW_BLUR, this.#onBlur);
}
/**
* Removes input event listeners.
*
* @private
*/
#removeEventListeners() {
this.#element.removeEventListener(EVENT_POINTERDOWN, this.#onPointerDown);
window.removeEventListener(EVENT_POINTERMOVE, this.#onPointerMove);
window.removeEventListener(EVENT_POINTERUP, this.#onPointerUp);
window.removeEventListener(EVENT_KEYDOWN, this.#onKeyDown);
window.removeEventListener(EVENT_KEYUP, this.#onKeyUp);
this.#element.removeEventListener(EVENT_CONTEXT_MENU, this.#onContextMenu);
window.removeEventListener(EVENT_WINDOW_BLUR, this.#onBlur);
}
/**
* Computes movement intent from the current action states.
*
* @param {number} deltaSeconds - Time delta in seconds.
* @returns {MovementData}
* @private
*/
#computeMovement(deltaSeconds) {
const forwardInput = this.#getActionValue(ACTIONS.FORWARD, ACTIONS.BACKWARD);
const rightInput = this.#getActionValue(ACTIONS.RIGHT, ACTIONS.LEFT);
const inputLength = Math.hypot(forwardInput, rightInput);
const isMoving = inputLength > INPUT_EPSILON;
const speedScale = this.#actionStates.get(ACTIONS.RUN) ? this.#runSpeedMultiplier : SPEED_SCALE_DEFAULT;
const speed = this.#moveSpeed * speedScale * deltaSeconds;
if (!isMoving) {
return {
moveX : INPUT_NONE,
moveZ : INPUT_NONE,
isMoving : false,
speed : speed
};
}
const normalizedForward = forwardInput / inputLength;
const normalizedRight = rightInput / inputLength;
const yawSin = Math.sin(this.#azimuthRadians);
const yawCos = Math.cos(this.#azimuthRadians);
const forwardX = -yawSin;
const forwardZ = -yawCos;
const rightX = yawCos;
const rightZ = -yawSin;
const moveX = (forwardX * normalizedForward + rightX * normalizedRight) * speed;
const moveZ = (forwardZ * normalizedForward + rightZ * normalizedRight) * speed;
return {
moveX,
moveZ,
isMoving : true,
speed : speed
};
}
/**
* Applies movement and jump physics to the target.
*
* @param {MovementData} movement - Movement data.
* @param {number} deltaSeconds - Time delta in seconds.
* @private
*/
#applyMovement(movement, deltaSeconds) {
KeyboardControls.assertMovementData(movement, `${this.#controlsName}.#applyMovement`);
if (typeof deltaSeconds !== 'number' || Number.isFinite(deltaSeconds) !== true) {
throw new TypeError(`\`${this.#controlsName}.#applyMovement\` expects \`deltaSeconds\` as a finite number.`);
}
const targetPosition = this.#target.position;
targetPosition.x += movement.moveX;
targetPosition.z += movement.moveZ;
this.updateTargetRotation(this.#target, movement, this.#azimuthRadians);
if (this.#actionStates.get(ACTIONS.JUMP) && this.#isGrounded) {
this.#verticalVelocity = this.#jumpSpeed;
this.#isGrounded = false;
}
if (!this.#isGrounded) {
this.#verticalVelocity -= this.#gravity * deltaSeconds;
targetPosition.y += this.#verticalVelocity * deltaSeconds;
if (targetPosition.y <= this.#groundY) {
targetPosition.y = this.#groundY;
this.#verticalVelocity = INPUT_NONE;
this.#isGrounded = true;
}
}
}
/**
* Computes bobbing offsets and pitch for the current frame.
*
* @param {number} deltaSeconds - Time delta in seconds.
* @param {boolean} isMoving - True when target is moving.
* @returns {CameraState}
* @private
*/
#computeCameraState(deltaSeconds, isMoving) {
if (typeof deltaSeconds !== 'number' || Number.isFinite(deltaSeconds) !== true) {
throw new TypeError(`\`${this.#controlsName}.#computeCameraState\` expects \`deltaSeconds\` as a finite number.`);
}
if (typeof isMoving !== 'boolean') {
throw new TypeError(`\`${this.#controlsName}.#computeCameraState\` expects \`isMoving\` as a boolean.`);
}
let bobbingOffset = INPUT_NONE;
let bobbingPitch = INPUT_NONE;
if (this.#bobbingMode && this.#camera.mode === this.#bobbingMode && isMoving) {
const isRunning = this.#actionStates.get(ACTIONS.RUN);
const frequency = isRunning ? this.#bobbingFrequencyRun : this.#bobbingFrequencyWalk;
const amplitude = isRunning ? this.#bobbingAmplitudeRun : this.#bobbingAmplitudeWalk;
this.#bobbingPhase += frequency * deltaSeconds;
this.#bobbingOffset = Math.sin(this.#bobbingPhase) * amplitude;
bobbingOffset = this.#bobbingOffset;
bobbingPitch = Math.sin(this.#bobbingPhase) * this.#bobbingPitchAmplitude;
} else {
this.#bobbingPhase = INPUT_NONE;
this.#bobbingOffset = INPUT_NONE;
}
return {
azimuthRadians : this.#azimuthRadians,
polarRadians : this.#polarRadians,
bobbingOffset,
bobbingPitch
};
}
/**
* Returns a signed input value based on forward/backward action states.
*
* @param {string} positiveAction - Action mapped to `+1`.
* @param {string} negativeAction - Action mapped to `-1`.
* @returns {number} - Input value in range [-1, 1].
* @private
*/
#getActionValue(positiveAction, negativeAction) {
const positive = this.#actionStates.get(positiveAction) ? INPUT_FORWARD : INPUT_NONE;
const negative = this.#actionStates.get(negativeAction) ? INPUT_BACKWARD : INPUT_NONE;
return positive + negative;
}
/**
* @param {PointerEvent} event - Pointer event.
* @private
*/
#handlePointerDown(event) {
if (event === null || typeof event !== 'object') {
throw new TypeError(`\`${this.#controlsName}.#handlePointerDown\` expects \`event\` as an object.`);
}
if (event.button !== ROTATE_POINTER_BUTTON) {
return;
}
if (this.#capturedPointerId !== POINTER_ID_RESET_VALUE) {
return;
}
this.#capturedPointerId = event.pointerId;
this.#previousPointerX = event.clientX;
this.#previousPointerY = event.clientY;
this.#element.setPointerCapture(event.pointerId);
event.preventDefault();
}
/**
* @param {PointerEvent} event - Pointer event.
* @private
*/
#handlePointerMove(event) {
if (event === null || typeof event !== 'object') {
throw new TypeError(`\`${this.#controlsName}.#handlePointerMove\` expects \`event\` as an object.`);
}
if (event.pointerId !== this.#capturedPointerId) {
return;
}
const deltaX = event.clientX - this.#previousPointerX;
const deltaY = event.clientY - this.#previousPointerY;
this.#previousPointerX = event.clientX;
this.#previousPointerY = event.clientY;
const rotationStep = ROTATION_RADIANS_PER_PIXEL * this.#rotationSpeed;
this.#azimuthRadians -= deltaX * rotationStep;
this.#polarRadians -= deltaY * rotationStep;
this.#polarRadians = KeyboardControls.#clamp(
this.#polarRadians,
this.#minPolarRadians,
this.#maxPolarRadians
);
event.preventDefault();
}
/**
* @param {PointerEvent} event - Pointer event.
* @private
*/
#handlePointerUp(event) {
if (event === null || typeof event !== 'object') {
throw new TypeError(`\`${this.#controlsName}.#handlePointerUp\` expects \`event\` as an object.`);
}
if (event.pointerId !== this.#capturedPointerId) {
return;
}
this.#capturedPointerId = POINTER_ID_RESET_VALUE;
event.preventDefault();
}
/**
* @param {KeyboardEvent} event - Keyboard event.
* @private
*/
#handleKeyDown(event) {
if (event === null || typeof event !== 'object') {
throw new TypeError(`\`${this.#controlsName}.#handleKeyDown\` expects \`event\` as an object.`);
}
const action = KeyboardControls.#mapEventToAction(event);
if (!action) {
return;
}
this.#actionStates.set(action, true);
event.preventDefault();
}
/**
* @param {KeyboardEvent} event - Keyboard event.
* @private
*/
#handleKeyUp(event) {
if (event === null || typeof event !== 'object') {
throw new TypeError(`\`${this.#controlsName}.#handleKeyUp\` expects \`event\` as an object.`);
}
const action = KeyboardControls.#mapEventToAction(event);
if (!action) {
return;
}
this.#actionStates.set(action, false);
event.preventDefault();
}
/**
* Maps keyboard events to action constants.
*
* @param {KeyboardEvent} event - Keyboard event.
* @returns {string | null} - Action id or null if not mapped.
* @private
*/
static #mapEventToAction(event) {
if (event === null || typeof event !== 'object') {
throw new TypeError('`KeyboardControls.#mapEventToAction` expects `event` as an object.');
}
if (typeof event.code === 'string' && event.code.length > EMPTY_STRING_LENGTH) {
return KeyboardControls.#mapCodeToAction(event.code);
}
if (typeof event.key === 'string' && event.key.length > EMPTY_STRING_LENGTH) {
return KeyboardControls.#mapKeyToAction(event.key);
}
return null;
}
/**
* Maps `KeyboardEvent.key` values to actions.
*
* @param {string} key - `KeyboardEvent.key` value.
* @returns {string | null} - Action id or null, if not mapped.
* @private
*/
static #mapKeyToAction(key) {
if (typeof key !== 'string') {
throw new TypeError('`KeyboardControls.#mapKeyToAction` expects `key` as a string.');
}
switch (key.toLowerCase()) {
case KEY_VALUES.FORWARD:
return ACTIONS.FORWARD;
case KEY_VALUES.BACKWARD:
return ACTIONS.BACKWARD;
case KEY_VALUES.LEFT:
return ACTIONS.LEFT;
case KEY_VALUES.RIGHT:
return ACTIONS.RIGHT;
case KEY_VALUES.RUN:
return ACTIONS.RUN;
case KEY_VALUES.JUMP:
case KEY_VALUES.SPACEBAR:
return ACTIONS.JUMP;
default:
return null;
}
}
/**
* Maps `KeyboardEvent.code` values to actions.
*
* @param {string} code - KeyboardEvent.code value.
* @returns {string | null}
* @private
*/
static #mapCodeToAction(code) {
if (typeof code !== 'string') {
throw new TypeError('`KeyboardControls.#mapCodeToAction` expects `code` as a string.');
}
switch (code) {
case KEY_CODES.FORWARD:
return ACTIONS.FORWARD;
case KEY_CODES.BACKWARD:
return ACTIONS.BACKWARD;
case KEY_CODES.LEFT:
return ACTIONS.LEFT;
case KEY_CODES.RIGHT:
return ACTIONS.RIGHT;
case KEY_CODES.RUN:
return ACTIONS.RUN;
case KEY_CODES.JUMP:
return ACTIONS.JUMP;
default:
return null;
}
}
/**
* @param {number} value - Value to clamp.
* @param {number} min - Inclusive lower bound.
* @param {number} max - Inclusive upper bound.
* @returns {number} - Clamped value.
* @private
*/
static #clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/**
* @param {unknown} movement - Candidate movement data.
* @param {string} context - Error message context.
* @returns {void}
* @throws {TypeError} When `movement` does not match {@link MovementData}.
*/
static assertMovementData(movement, context) {
if (movement === null || typeof movement !== 'object' || Array.isArray(movement)) {
throw new TypeError(`\`${context}\` expects \`movement\` as an object.`);
}
if (typeof movement.moveX !== 'number' || typeof movement.moveZ !== 'number') {
throw new TypeError(`\`${context}\` expects \`movement.moveX\` and \`movement.moveZ\` as numbers.`);
}
if (typeof movement.isMoving !== 'boolean') {
throw new TypeError(`\`${context}\` expects \`movement.isMoving\` as a boolean.`);
}
if (typeof movement.speed !== 'number') {
throw new TypeError(`\`${context}\` expects \`movement.speed\` as a number.`);
}
}
/**
* @param {unknown} cameraState - Candidate camera state.
* @param {string} context - Error message context.
* @returns {void}
* @throws {TypeError} When `cameraState` does not match {@link CameraState}.
*/
static assertCameraState(cameraState, context) {
if (cameraState === null || typeof cameraState !== 'object' || Array.isArray(cameraState)) {
throw new TypeError(`\`${context}\` expects \`cameraState\` as an object.`);
}
if (typeof cameraState.azimuthRadians !== 'number' || typeof cameraState.polarRadians !== 'number') {
throw new TypeError(`\`${context}\` expects \`cameraState.azimuthRadians\` and \`cameraState.polarRadians\` as numbers.`);
}
if (typeof cameraState.bobbingOffset !== 'number' || typeof cameraState.bobbingPitch !== 'number') {
throw new TypeError(`\`${context}\` expects \`cameraState.bobbingOffset\` and \`cameraState.bobbingPitch\` as numbers.`);
}
}
}