diff --git a/client/src/core/Camera.ts b/client/src/core/Camera.ts index 2f57f884..57dd7914 100644 --- a/client/src/core/Camera.ts +++ b/client/src/core/Camera.ts @@ -2,6 +2,7 @@ import { Euler, Object3D, PerspectiveCamera, Quaternion, Raycaster, Vector2, Vec import Entity from "../entities/Entity"; import EventRouter from '../events/EventRouter'; import Game from "../Game"; +import MobileManager from '../mobile/MobileManager'; import { NetworkManagerEventType } from '../network/NetworkManager'; import type { Ray } from 'three'; import type { NetworkManagerEventPayload } from '../network/NetworkManager'; @@ -11,12 +12,18 @@ const MIN_ZOOM = 3.0; const MAX_ZOOM = 10.0; const INITIAL_ZOOM = 6.0; const CAMERA_LERP_TIME = 0.2; +const CAMERA_COLLISION_RAYCAST_INTERVAL_DESKTOP_S = 1 / 30; +const CAMERA_COLLISION_RAYCAST_INTERVAL_MOBILE_S = 1 / 15; +const CAMERA_COLLISION_RAYCAST_ORIGIN_DELTA_SQ_THRESHOLD = 0.1 * 0.1; +const CAMERA_COLLISION_RAYCAST_DIRECTION_DOT_THRESHOLD = 0.9995; +const CAMERA_COLLISION_RAYCAST_DISTANCE_DELTA_THRESHOLD = 0.1; // Working variables const vec2 = new Vector2(); const vec3 = new Vector3(); const vec3b = new Vector3(); const vec3c = new Vector3(); +const vec3d = new Vector3(); const modelViewEuler = new Euler(0, 0, 0, 'YXZ'); const yawOnlyEuler = new Euler(0, 0, 0, 'YXZ'); const entityYawEuler = new Euler(0, 0, 0, 'YXZ'); @@ -77,6 +84,17 @@ export default class Camera { private _gameCameraYaw: number = 0; private _gameCameraViewDir: Vector3 = new Vector3(); private _gameCameraCollisionDistance: number = Infinity; // Current collision-adjusted distance + private _gameCameraCollisionTargetDistance: number = Infinity; + private _gameCameraCollisionRaycastHasSample: boolean = false; + private _gameCameraCollisionRaycastIntervalRemainingS: number = 0; + private _gameCameraCollisionRaycastIntervalS: number = + MobileManager.isMobile + ? CAMERA_COLLISION_RAYCAST_INTERVAL_MOBILE_S + : CAMERA_COLLISION_RAYCAST_INTERVAL_DESKTOP_S; + private _gameCameraCollisionRaycastDesiredDistance: number = 0; + private _gameCameraCollisionRaycastOrigin: Vector3 = new Vector3(); + private _gameCameraCollisionRaycastDirection: Vector3 = new Vector3(); + private _gameCameraShoulderPositionOffset: Vector3 = new Vector3(); private _spectatorCamera: PerspectiveCamera; private _spectatorCameraPitch: number = 0; @@ -116,6 +134,10 @@ export default class Camera { return this._gameCameraAttachedEntity; } + public get gameCameraYaw(): number { + return this._gameCameraYaw; + } + public get isGameCameraActive(): boolean { return this._activeCamera === this._gameCamera; } @@ -496,6 +518,67 @@ export default class Camera { } } + private _shouldSampleGameCameraCollision( + lookAtTarget: Vector3, + direction: Vector3, + desiredDistance: number, + frameDeltaS: number, + ): boolean { + this._gameCameraCollisionRaycastIntervalRemainingS = Math.max( + 0, + this._gameCameraCollisionRaycastIntervalRemainingS - frameDeltaS, + ); + + if (!this._gameCameraCollisionRaycastHasSample) { + return true; + } + + if (this._gameCameraCollisionRaycastIntervalRemainingS <= 0) { + return true; + } + + if ( + lookAtTarget.distanceToSquared(this._gameCameraCollisionRaycastOrigin) + > CAMERA_COLLISION_RAYCAST_ORIGIN_DELTA_SQ_THRESHOLD + ) { + return true; + } + + if ( + direction.dot(this._gameCameraCollisionRaycastDirection) + < CAMERA_COLLISION_RAYCAST_DIRECTION_DOT_THRESHOLD + ) { + return true; + } + + return Math.abs(desiredDistance - this._gameCameraCollisionRaycastDesiredDistance) + > CAMERA_COLLISION_RAYCAST_DISTANCE_DELTA_THRESHOLD; + } + + private _sampleGameCameraCollisionDistance( + lookAtTarget: Vector3, + direction: Vector3, + desiredDistance: number, + ): void { + this._raycaster.set(lookAtTarget, direction); + this._raycaster.far = desiredDistance; + + let targetDistance = desiredDistance; + const intersects = this._raycaster.intersectObjects(this._game.chunkMeshManager.solidMeshesInScene, false); + if (intersects.length > 0) { + // Account for near plane so the camera frustum doesn't graze the block face. + const nearPadding = this._gameCamera.near + 0.1; + targetDistance = Math.max(0.5, intersects[0].distance - nearPadding); + } + + this._gameCameraCollisionTargetDistance = targetDistance; + this._gameCameraCollisionRaycastOrigin.copy(lookAtTarget); + this._gameCameraCollisionRaycastDirection.copy(direction); + this._gameCameraCollisionRaycastDesiredDistance = desiredDistance; + this._gameCameraCollisionRaycastIntervalRemainingS = this._gameCameraCollisionRaycastIntervalS; + this._gameCameraCollisionRaycastHasSample = true; + } + private _updateGameCamera(frameDeltaS: number): void { if (!this._gameCameraAttachedEntity && !this._gameCameraAttachedPosition) { return console.warn(`Camera._updateGameCamera(): No camera attachment or position set for game camera.`); @@ -508,7 +591,7 @@ export default class Camera { // Calculate look direction and orientation if we have a look target if (lookAtPosition) { - lookAtDirection = new Vector3().subVectors(attachedPosition, lookAtPosition).normalize(); + lookAtDirection = vec3d.subVectors(attachedPosition, lookAtPosition).normalize(); this._updateGameCameraOrientation( Math.asin(lookAtDirection.y), @@ -579,7 +662,7 @@ export default class Camera { if (this._gameCameraMode === CameraMode.THIRD_PERSON) { const radius = this._gameCameraRadialZoom - 1; const heightOffset = 1.25; - const lookAtTarget = (lookAtPosition || attachedPosition).clone(); + const lookAtTarget = vec3c.copy(lookAtPosition || attachedPosition); // Default +y 0.5 offset for better default player perspective for now. // Devs can adjust this with their own provided offset for gameCameraOffset in the sdk. @@ -600,9 +683,9 @@ export default class Camera { // Apply visual rotation to camera position around the look target (skip if no rotation, if not identity quat) if (this._gameCameraShoulderRotationOffset.w !== 1) { - const positionOffset = this._gameCamera.position.clone().sub(lookAtTarget); - positionOffset.applyQuaternion(this._gameCameraShoulderRotationOffset); - this._gameCamera.position.copy(lookAtTarget).add(positionOffset); + this._gameCameraShoulderPositionOffset.copy(this._gameCamera.position).sub(lookAtTarget); + this._gameCameraShoulderPositionOffset.applyQuaternion(this._gameCameraShoulderRotationOffset); + this._gameCamera.position.copy(lookAtTarget).add(this._gameCameraShoulderPositionOffset); } // Third-person offset shifts perspective while preserving orbit around the target. @@ -617,23 +700,18 @@ export default class Camera { const desiredDistance = this._gameCamera.position.distanceTo(lookAtTarget); if (this._gameCameraCollidesWithBlocks) { - this._raycaster.set(lookAtTarget, direction); - this._raycaster.far = desiredDistance; - - const collisionMeshes = this._game.chunkMeshManager.solidMeshesInScene; - // Determine target distance based on collision - let targetDistance = desiredDistance; - const intersects = this._raycaster.intersectObjects(collisionMeshes, false); - if (intersects.length > 0) { - // Account for near plane so the camera frustum doesn't graze the block face. - const nearPadding = this._gameCamera.near + 0.1; - targetDistance = Math.max(0.5, intersects[0].distance - nearPadding); + if (this._shouldSampleGameCameraCollision(lookAtTarget, direction, desiredDistance, frameDeltaS)) { + this._sampleGameCameraCollisionDistance(lookAtTarget, direction, desiredDistance); } - + + if (!Number.isFinite(this._gameCameraCollisionDistance)) { + this._gameCameraCollisionDistance = this._gameCameraCollisionTargetDistance; + } + // Smooth camera movement both in/out to reduce jarring jumps. const inSpeed = 20.0; // Faster to avoid noticeable clipping. const outSpeed = 10.0; // Slower for smooth recovery. - const delta = targetDistance - this._gameCameraCollisionDistance; + const delta = this._gameCameraCollisionTargetDistance - this._gameCameraCollisionDistance; if (delta !== 0) { const maxStep = frameDeltaS * (delta < 0 ? inSpeed : outSpeed); const step = Math.sign(delta) * Math.min(Math.abs(delta), maxStep); @@ -650,6 +728,9 @@ export default class Camera { } else { // Reset collision distance when collision is disabled. this._gameCameraCollisionDistance = desiredDistance; + this._gameCameraCollisionTargetDistance = desiredDistance; + this._gameCameraCollisionRaycastHasSample = false; + this._gameCameraCollisionRaycastIntervalRemainingS = 0; } // Look at target - this maintains proper orientation diff --git a/client/src/entities/Entity.ts b/client/src/entities/Entity.ts index 3934acc0..570159aa 100644 --- a/client/src/entities/Entity.ts +++ b/client/src/entities/Entity.ts @@ -1386,6 +1386,24 @@ export default class Entity { this._interpolatingRotation = !this._currentRotation.equals(this._targetRotation); } + + // Applies a client-side predicted transform without touching authoritative server tick tracking. + public applyClientPredictedTransform(position: Vector3Like, rotation?: QuaternionLike): void { + this._currentPosition.copy(position); + this._targetPosition.copy(position); + this._entityRoot.position.copy(this._currentPosition); + this._interpolatingPosition = false; + + if (rotation) { + this._currentRotation.copy(rotation); + this._targetRotation.copy(rotation); + this._entityRoot.quaternion.copy(this._currentRotation); + this._interpolatingRotation = false; + } + + this._needsMatrixUpdate.add(this._entityRoot); + this._needsWorldBoundingBoxUpdate = true; + } public setRotationInterpolationMs(interpolationMs: number | null): void { this._rotationInterpolationTimeS = this._resolveInterpolationTimeS(interpolationMs); diff --git a/client/src/entities/EntityManager.ts b/client/src/entities/EntityManager.ts index 5f9816db..3fc92cd3 100644 --- a/client/src/entities/EntityManager.ts +++ b/client/src/entities/EntityManager.ts @@ -14,6 +14,10 @@ import { type WorkerEventPayload, WorkerEventType, } from '../workers/ChunkWorkerConstants'; +import { + resolveDeterministicMovementDirection, + resolveDeterministicMovementYaw, +} from '../shared/movement/DeterministicMovementCore'; // Working variables const fromVec2 = new Vector2(); @@ -41,6 +45,95 @@ const DEFAULT_OUTLINE_OPTIONS: OutlineOptions = { occluded: true, }; +const LOCAL_PREDICTION_DEFAULT_WALK_SPEED = 4; +const LOCAL_PREDICTION_DEFAULT_RUN_SPEED = 8; +const LOCAL_PREDICTION_MIN_SPEED = 0.2; +const LOCAL_PREDICTION_MAX_SPEED = 20; +const LOCAL_PREDICTION_SPEED_REJECT_THRESHOLD = 30; +const LOCAL_PREDICTION_SPEED_ADAPT_RATE = 0.2; +const LOCAL_PREDICTION_SPEED_DIRECTION_ALIGNMENT_MIN_DOT = 0.6; +const LOCAL_PREDICTION_SPEED_VERTICAL_REJECT_THRESHOLD = 1.5; +const LOCAL_PREDICTION_MAX_FRAME_DELTA_S = 1 / 10; +const LOCAL_PREDICTION_SUBSTEP_DELTA_S = 1 / 60; +const LOCAL_PREDICTION_MAX_SUBSTEPS = 6; +const LOCAL_PREDICTION_REPLAY_COMMAND_MAX_DELTA_S = 1 / 8; +const LOCAL_PREDICTION_REPLAY_MAX_SUBSTEPS_PER_COMMAND = 12; +const LOCAL_PREDICTION_MOVING_HORIZONTAL_ERROR_DEAD_ZONE_SQ = 0.18 * 0.18; +const LOCAL_PREDICTION_IDLE_HORIZONTAL_ERROR_DEAD_ZONE_SQ = 0.08 * 0.08; +const LOCAL_PREDICTION_HORIZONTAL_SNAP_DISTANCE_SQ = 2.5 * 2.5; +const LOCAL_PREDICTION_MOVING_HORIZONTAL_CORRECTION_RATE = 10; +const LOCAL_PREDICTION_IDLE_HORIZONTAL_CORRECTION_RATE = 16; +const LOCAL_PREDICTION_MOVING_VERTICAL_ERROR_DEAD_ZONE = 0.03; +const LOCAL_PREDICTION_IDLE_VERTICAL_ERROR_DEAD_ZONE = 0.01; +const LOCAL_PREDICTION_VERTICAL_SNAP_DISTANCE = 2.5; +const LOCAL_PREDICTION_MOVING_VERTICAL_CORRECTION_RATE = 18; +const LOCAL_PREDICTION_IDLE_VERTICAL_CORRECTION_RATE = 26; +const LOCAL_PREDICTION_VERTICAL_VELOCITY_ADAPT_RATE = 0.35; +const LOCAL_PREDICTION_VERTICAL_VELOCITY_REJECT_THRESHOLD = 80; +const LOCAL_PREDICTION_MOVING_ROTATION_ERROR_DEAD_ZONE = 0.5; +const LOCAL_PREDICTION_IDLE_ROTATION_ERROR_DEAD_ZONE = 0.05; +const LOCAL_PREDICTION_ROTATION_SNAP_ANGLE = 1.2; +const LOCAL_PREDICTION_MOVING_ROTATION_CORRECTION_RATE = 4; +const LOCAL_PREDICTION_IDLE_ROTATION_CORRECTION_RATE = 12; +const LOCAL_PREDICTION_COMMAND_BUFFER_SIZE = 96; +const LOCAL_PREDICTION_PRE_ACK_RECONCILE_GRACE_S = 0.2; +const LOCAL_PREDICTION_ACK_SUPPORT_DETECTION_TIMEOUT_S = 2.0; +const INPUT_MANAGER_MOVEMENT_PACKET_SENT_EVENT = 'INPUT_MANAGER.MOVEMENT_PACKET_SENT'; + +type LocalPredictionCommand = { + sequenceNumber: number; + deltaTimeS: number; + yaw: number; + joystickDirection: number | null; + w: boolean; + a: boolean; + s: boolean; + d: boolean; + sh: boolean; +}; + +type MovementPacketSentPayload = { + sequenceNumber: number; + deltaTimeS: number; + yaw: number; + joystickDirection: number | null; + w: boolean; + a: boolean; + s: boolean; + d: boolean; + sh: boolean; +}; + +type LocalPredictionState = { + entityId?: number; + predictedPosition: Vector3; + predictedRotation: Quaternion; + authoritativePosition: Vector3; + authoritativeRotation: Quaternion; + estimatedVerticalVelocity: number; + estimatedWalkSpeed: number; + estimatedRunSpeed: number; + worldTimestepS: number; + supportsInputAcknowledgements: boolean; + lastAcknowledgedHadMovementInput?: boolean; + lastAcknowledgedMovementRunning?: boolean; + lastAcknowledgedMovementDirectionX?: number; + lastAcknowledgedMovementDirectionZ?: number; + pendingSpeedCalibrationAcknowledgedInputSequenceNumber?: number; + hasPredictedTransform: boolean; + hasAuthoritativePosition: boolean; + hasAuthoritativeRotation: boolean; + lastAuthoritativePositionServerTick: number; + lastAuthoritativeRotationServerTick: number; + commandBuffer: LocalPredictionCommand[]; + commandBufferHead: number; + commandBufferCount: number; + lastAcknowledgedInputSequenceNumber: number; + preAckReconcileGraceRemainingS: number; + ackSupportDetectionElapsedS: number; + shouldBufferCommandsBeforeAck: boolean; +}; + export default class EntityManager { private _game: Game; private _entities: Map = new Map(); @@ -51,6 +144,44 @@ export default class EntityManager { private _needsLightLevelRefresh: boolean = false; private _hasLightLevelVolumeUpdatedOnce: boolean = false; private _needsSkyLightRefresh: boolean = false; + private _localPredictionState: LocalPredictionState = { + predictedPosition: new Vector3(), + predictedRotation: new Quaternion(), + authoritativePosition: new Vector3(), + authoritativeRotation: new Quaternion(), + estimatedVerticalVelocity: 0, + estimatedWalkSpeed: LOCAL_PREDICTION_DEFAULT_WALK_SPEED, + estimatedRunSpeed: LOCAL_PREDICTION_DEFAULT_RUN_SPEED, + worldTimestepS: 1 / 60, + supportsInputAcknowledgements: false, + lastAcknowledgedHadMovementInput: undefined, + lastAcknowledgedMovementRunning: undefined, + lastAcknowledgedMovementDirectionX: undefined, + lastAcknowledgedMovementDirectionZ: undefined, + pendingSpeedCalibrationAcknowledgedInputSequenceNumber: undefined, + hasPredictedTransform: false, + hasAuthoritativePosition: false, + hasAuthoritativeRotation: false, + lastAuthoritativePositionServerTick: 0, + lastAuthoritativeRotationServerTick: 0, + commandBuffer: new Array(LOCAL_PREDICTION_COMMAND_BUFFER_SIZE).fill(undefined).map(() => ({ + sequenceNumber: -1, + deltaTimeS: 0, + yaw: 0, + joystickDirection: null, + w: false, + a: false, + s: false, + d: false, + sh: false, + })), + commandBufferHead: 0, + commandBufferCount: 0, + lastAcknowledgedInputSequenceNumber: -1, + preAckReconcileGraceRemainingS: 0, + ackSupportDetectionElapsedS: 0, + shouldBufferCommandsBeforeAck: true, + }; private _shouldSuppressEnvironmentAnimations: boolean; public constructor(game: Game) { @@ -136,6 +267,16 @@ export default class EntityManager { this._onEntitiesPacket, ); + EventRouter.instance.on( + INPUT_MANAGER_MOVEMENT_PACKET_SENT_EVENT, + this._onMovementPacketSent as unknown as () => void, + ); + + EventRouter.instance.on( + NetworkManagerEventType.WorldPacket, + this._onWorldPacket, + ); + EventRouter.instance.on( WorkerEventType.BlockEntityBuilt, this._onBlockEntityBuilt, @@ -160,12 +301,22 @@ export default class EntityManager { private _onAnimate = (payload: RendererEventPayload.IAnimate): void => { EntityStats.reset(); EntityStats.count = this._entities.size; + this._updateLocalPredictionEntityBinding(); // Entities are updated using a multi-pass approach. // First pass: Update local position and rotation for (const entityId of this._dynamicEntities) { - this._entities.get(entityId)!.update(payload.frameDeltaS); + const entity = this._entities.get(entityId); + if (!entity || entity instanceof StaticEntity) { + continue; + } + + entity.update(payload.frameDeltaS); + + if (this._localPredictionState.entityId === entity.id) { + this._applyLocalPrediction(entity, payload.frameDeltaS); + } } // Second pass: Apply view distance. @@ -238,6 +389,15 @@ export default class EntityManager { } } + private _onWorldPacket = (payload: NetworkManagerEventPayload.IWorldPacket): void => { + const timestep = payload.deserializedWorld.timestep; + if (typeof timestep !== 'number' || !Number.isFinite(timestep) || timestep <= 0) { + return; + } + + this._localPredictionState.worldTimestepS = Math.min(Math.max(timestep, 1 / 240), 1); + } + private _updateEntity = (deserializedEntity: DeserializedEntity, serverTick: number): void => { let entity = this._entities.get(deserializedEntity.id); if (!entity) { @@ -318,10 +478,15 @@ export default class EntityManager { throw new Error(`EntityManager: Static Environment Entity must not be removed. ${entity.id}`); } + if (this._localPredictionState.entityId === entity.id) { + this._resetLocalPredictionState(); + } + entity.release(); this._entities.delete(entity.id); this._dynamicEntities.delete(entity.id); this._outlines.delete(entity.id); + return; } if (entity.isBlockEntity && deserializedEntity.blockTextureUri !== undefined) { @@ -383,23 +548,41 @@ export default class EntityManager { const shouldInterpolateTransform = deserializedEntity.parentEntityId === undefined && deserializedEntity.parentNodeName === undefined; + const shouldUseLocalPrediction = + entity instanceof Entity && + this._localPredictionState.entityId === entity.id && + !entity.attached && + entity.parentEntityId == null && + entity.parentNodeName == null; + + if (shouldUseLocalPrediction && deserializedEntity.acknowledgedInputSequenceNumber !== undefined) { + this._setLocalAcknowledgedInputSequenceNumber(deserializedEntity.acknowledgedInputSequenceNumber); + } if (deserializedEntity.position) { - // do not interpolate if we are also attaching or detaching to/from a parent - entity.setPosition( - deserializedEntity.position, - shouldInterpolateTransform, - serverTick, - ); + if (shouldUseLocalPrediction) { + this._setLocalAuthoritativePosition(deserializedEntity.position, serverTick); + } else { + // do not interpolate if we are also attaching or detaching to/from a parent + entity.setPosition( + deserializedEntity.position, + shouldInterpolateTransform, + serverTick, + ); + } } if (deserializedEntity.rotation) { - // do not interpolate if we are also attaching or detaching to/from a parent - entity.setRotation( - deserializedEntity.rotation, - shouldInterpolateTransform, - serverTick, - ); + if (shouldUseLocalPrediction) { + this._setLocalAuthoritativeRotation(deserializedEntity.rotation, serverTick); + } else { + // do not interpolate if we are also attaching or detaching to/from a parent + entity.setRotation( + deserializedEntity.rotation, + shouldInterpolateTransform, + serverTick, + ); + } } if (deserializedEntity.scale) { @@ -420,6 +603,579 @@ export default class EntityManager { } } + private _updateLocalPredictionEntityBinding(): void { + const nextEntityId = this._game.camera.gameCameraAttachedEntity?.id; + if (this._localPredictionState.entityId === nextEntityId) { + if (nextEntityId !== undefined && !this._entities.has(nextEntityId)) { + this._resetLocalPredictionState(); + } + return; + } + + this._resetLocalPredictionState(nextEntityId); + + if (nextEntityId === undefined) { + return; + } + + const entity = this._entities.get(nextEntityId); + if (!entity || entity instanceof StaticEntity) { + return; + } + + this._localPredictionState.predictedPosition.copy(entity.position); + this._localPredictionState.predictedRotation.copy(entity.rotation); + this._localPredictionState.authoritativePosition.copy(entity.position); + this._localPredictionState.authoritativeRotation.copy(entity.rotation); + this._localPredictionState.hasPredictedTransform = true; + this._localPredictionState.hasAuthoritativePosition = true; + this._localPredictionState.hasAuthoritativeRotation = true; + } + + private _resetLocalPredictionState(nextEntityId?: number): void { + this._localPredictionState.entityId = nextEntityId; + this._localPredictionState.estimatedVerticalVelocity = 0; + this._localPredictionState.estimatedWalkSpeed = LOCAL_PREDICTION_DEFAULT_WALK_SPEED; + this._localPredictionState.estimatedRunSpeed = LOCAL_PREDICTION_DEFAULT_RUN_SPEED; + this._localPredictionState.supportsInputAcknowledgements = false; + this._localPredictionState.lastAcknowledgedHadMovementInput = undefined; + this._localPredictionState.lastAcknowledgedMovementRunning = undefined; + this._localPredictionState.lastAcknowledgedMovementDirectionX = undefined; + this._localPredictionState.lastAcknowledgedMovementDirectionZ = undefined; + this._localPredictionState.pendingSpeedCalibrationAcknowledgedInputSequenceNumber = undefined; + this._localPredictionState.hasPredictedTransform = false; + this._localPredictionState.hasAuthoritativePosition = false; + this._localPredictionState.hasAuthoritativeRotation = false; + this._localPredictionState.lastAuthoritativePositionServerTick = 0; + this._localPredictionState.lastAuthoritativeRotationServerTick = 0; + this._localPredictionState.commandBufferHead = 0; + this._localPredictionState.commandBufferCount = 0; + this._localPredictionState.lastAcknowledgedInputSequenceNumber = -1; + this._localPredictionState.preAckReconcileGraceRemainingS = 0; + this._localPredictionState.ackSupportDetectionElapsedS = 0; + this._localPredictionState.shouldBufferCommandsBeforeAck = true; + } + + private _setLocalAuthoritativePosition(position: { x: number; y: number; z: number }, serverTick: number): void { + if (serverTick <= this._localPredictionState.lastAuthoritativePositionServerTick) { + return; + } + + const previousPositionServerTick = this._localPredictionState.lastAuthoritativePositionServerTick; + const previousAuthoritativePositionX = this._localPredictionState.authoritativePosition.x; + const previousAuthoritativePositionY = this._localPredictionState.authoritativePosition.y; + const previousAuthoritativePositionZ = this._localPredictionState.authoritativePosition.z; + const hadPreviousAuthoritativePosition = this._localPredictionState.hasAuthoritativePosition; + + this._localPredictionState.lastAuthoritativePositionServerTick = serverTick; + this._localPredictionState.authoritativePosition.copy(position); + this._localPredictionState.hasAuthoritativePosition = true; + + if (hadPreviousAuthoritativePosition) { + const sampledTickDelta = serverTick - previousPositionServerTick; + const sampledDeltaTimeS = sampledTickDelta * this._localPredictionState.worldTimestepS; + + if (sampledDeltaTimeS > 0) { + const dx = position.x - previousAuthoritativePositionX; + const dy = position.y - previousAuthoritativePositionY; + const dz = position.z - previousAuthoritativePositionZ; + const sampledHorizontalSpeed = Math.sqrt((dx * dx) + (dz * dz)) / sampledDeltaTimeS; + const sampledVerticalVelocity = dy / sampledDeltaTimeS; + // Only let walk/run speed adapt from the first position delta after a newly + // acknowledged movement command. This avoids learning platform/impulse motion. + const calibrationAcknowledgedInputSequenceNumber = + this._localPredictionState.pendingSpeedCalibrationAcknowledgedInputSequenceNumber; + this._localPredictionState.pendingSpeedCalibrationAcknowledgedInputSequenceNumber = undefined; + + this._updateLocalPredictionVerticalVelocityEstimate(sampledVerticalVelocity); + + this._updateLocalPredictionSpeedEstimate( + sampledHorizontalSpeed, + dx, + dy, + dz, + calibrationAcknowledgedInputSequenceNumber, + ); + } + } + + if (!this._localPredictionState.hasPredictedTransform) { + this._localPredictionState.predictedPosition.copy(position); + this._localPredictionState.hasPredictedTransform = true; + } + + if ( + this._localPredictionState.supportsInputAcknowledgements && + this._localPredictionState.commandBufferCount > 0 + ) { + this._rebuildPredictedStateFromAuthoritativeAndReplay(); + } + } + + private _setLocalAuthoritativeRotation(rotation: { x: number; y: number; z: number; w: number }, serverTick: number): void { + if (serverTick <= this._localPredictionState.lastAuthoritativeRotationServerTick) { + return; + } + + this._localPredictionState.lastAuthoritativeRotationServerTick = serverTick; + this._localPredictionState.authoritativeRotation.copy(rotation); + this._localPredictionState.hasAuthoritativeRotation = true; + + if (!this._localPredictionState.hasPredictedTransform) { + this._localPredictionState.predictedRotation.copy(rotation); + this._localPredictionState.hasPredictedTransform = true; + } + + if ( + this._localPredictionState.supportsInputAcknowledgements && + this._localPredictionState.commandBufferCount > 0 + ) { + this._rebuildPredictedStateFromAuthoritativeAndReplay(); + } + } + + private _setLocalAcknowledgedInputSequenceNumber(acknowledgedInputSequenceNumber: number): void { + this._localPredictionState.supportsInputAcknowledgements = true; + this._localPredictionState.shouldBufferCommandsBeforeAck = true; + this._localPredictionState.ackSupportDetectionElapsedS = 0; + this._localPredictionState.preAckReconcileGraceRemainingS = 0; + + if (acknowledgedInputSequenceNumber <= this._localPredictionState.lastAcknowledgedInputSequenceNumber) { + return; + } + + this._localPredictionState.lastAcknowledgedInputSequenceNumber = acknowledgedInputSequenceNumber; + const lastAcknowledgedCommand = this._dropAcknowledgedPredictionCommands(acknowledgedInputSequenceNumber); + this._localPredictionState.lastAcknowledgedHadMovementInput = + !!lastAcknowledgedCommand && ( + lastAcknowledgedCommand.w || + lastAcknowledgedCommand.a || + lastAcknowledgedCommand.s || + lastAcknowledgedCommand.d || + typeof lastAcknowledgedCommand.joystickDirection === 'number' + ); + this._localPredictionState.lastAcknowledgedMovementRunning = lastAcknowledgedCommand?.sh; + this._setLastAcknowledgedMovementDirection(lastAcknowledgedCommand); + const hasAcknowledgedMovementDirection = + this._localPredictionState.lastAcknowledgedMovementDirectionX !== undefined && + this._localPredictionState.lastAcknowledgedMovementDirectionZ !== undefined; + this._localPredictionState.pendingSpeedCalibrationAcknowledgedInputSequenceNumber = + this._localPredictionState.lastAcknowledgedHadMovementInput && hasAcknowledgedMovementDirection + ? acknowledgedInputSequenceNumber + : undefined; + this._rebuildPredictedStateFromAuthoritativeAndReplay(); + } + + private _dropAcknowledgedPredictionCommands(acknowledgedInputSequenceNumber: number): LocalPredictionCommand | undefined { + let lastDroppedCommand: LocalPredictionCommand | undefined; + + while (this._localPredictionState.commandBufferCount > 0) { + const command = this._localPredictionState.commandBuffer[this._localPredictionState.commandBufferHead]; + if (command.sequenceNumber > acknowledgedInputSequenceNumber) { + break; + } + + this._localPredictionState.commandBufferHead = + (this._localPredictionState.commandBufferHead + 1) % LOCAL_PREDICTION_COMMAND_BUFFER_SIZE; + this._localPredictionState.commandBufferCount--; + lastDroppedCommand = command; + } + + return lastDroppedCommand; + } + + private _rebuildPredictedStateFromAuthoritativeAndReplay(): void { + if (!this._localPredictionState.hasAuthoritativePosition && !this._localPredictionState.hasAuthoritativeRotation) { + return; + } + + if (this._localPredictionState.hasAuthoritativePosition) { + this._localPredictionState.predictedPosition.copy(this._localPredictionState.authoritativePosition); + } + + if (this._localPredictionState.hasAuthoritativeRotation) { + this._localPredictionState.predictedRotation.copy(this._localPredictionState.authoritativeRotation); + } + + for (let i = 0; i < this._localPredictionState.commandBufferCount; i++) { + const command = this._localPredictionState.commandBuffer[ + (this._localPredictionState.commandBufferHead + i) % LOCAL_PREDICTION_COMMAND_BUFFER_SIZE + ]; + + this._replayPredictedCommand(command); + } + } + + private _replayPredictedCommand(command: LocalPredictionCommand): void { + let remainingDeltaS = Math.min( + Math.max(command.deltaTimeS, 0), + LOCAL_PREDICTION_REPLAY_COMMAND_MAX_DELTA_S, + ); + let substeps = 0; + + while (remainingDeltaS > 0 && substeps < LOCAL_PREDICTION_REPLAY_MAX_SUBSTEPS_PER_COMMAND) { + const stepDeltaS = Math.min(LOCAL_PREDICTION_SUBSTEP_DELTA_S, remainingDeltaS); + + this._localPredictionState.predictedPosition.y += this._localPredictionState.estimatedVerticalVelocity * stepDeltaS; + this._applyPredictedMovementFromInputs( + stepDeltaS, + command.yaw, + command.joystickDirection, + command.w, + command.a, + command.s, + command.d, + command.sh, + ); + + remainingDeltaS -= stepDeltaS; + substeps++; + } + } + + private _onMovementPacketSent = (payload: MovementPacketSentPayload): void => { + if (this._localPredictionState.entityId === undefined) { + return; + } + + if (!this._entities.has(this._localPredictionState.entityId)) { + return; + } + + if ( + !this._localPredictionState.supportsInputAcknowledgements && + !this._localPredictionState.shouldBufferCommandsBeforeAck + ) { + return; + } + + if (payload.sequenceNumber <= this._localPredictionState.lastAcknowledgedInputSequenceNumber) { + return; + } + + const bufferWriteIndex = + (this._localPredictionState.commandBufferHead + this._localPredictionState.commandBufferCount) + % LOCAL_PREDICTION_COMMAND_BUFFER_SIZE; + + if (this._localPredictionState.commandBufferCount === LOCAL_PREDICTION_COMMAND_BUFFER_SIZE) { + this._localPredictionState.commandBufferHead = + (this._localPredictionState.commandBufferHead + 1) % LOCAL_PREDICTION_COMMAND_BUFFER_SIZE; + this._localPredictionState.commandBufferCount--; + } + + const command = this._localPredictionState.commandBuffer[bufferWriteIndex]; + command.sequenceNumber = payload.sequenceNumber; + command.deltaTimeS = payload.deltaTimeS; + command.yaw = payload.yaw; + command.joystickDirection = payload.joystickDirection; + command.w = payload.w; + command.a = payload.a; + command.s = payload.s; + command.d = payload.d; + command.sh = payload.sh; + + this._localPredictionState.commandBufferCount++; + + if (!this._localPredictionState.supportsInputAcknowledgements) { + this._localPredictionState.preAckReconcileGraceRemainingS = LOCAL_PREDICTION_PRE_ACK_RECONCILE_GRACE_S; + this._localPredictionState.ackSupportDetectionElapsedS += payload.deltaTimeS; + + if (this._localPredictionState.ackSupportDetectionElapsedS >= LOCAL_PREDICTION_ACK_SUPPORT_DETECTION_TIMEOUT_S) { + this._localPredictionState.shouldBufferCommandsBeforeAck = false; + this._localPredictionState.commandBufferHead = 0; + this._localPredictionState.commandBufferCount = 0; + this._localPredictionState.preAckReconcileGraceRemainingS = 0; + } + } + } + + private _applyLocalPrediction(entity: Entity, deltaTimeS: number): void { + if ( + entity.attached || + entity.parentEntityId != null || + entity.parentNodeName != null + ) { + return; + } + + if (!this._localPredictionState.hasPredictedTransform) { + this._localPredictionState.predictedPosition.copy(entity.position); + this._localPredictionState.predictedRotation.copy(entity.rotation); + this._localPredictionState.hasPredictedTransform = true; + } + + const clampedDeltaS = Math.min(deltaTimeS, LOCAL_PREDICTION_MAX_FRAME_DELTA_S); + const inputState = this._game.inputManager.inputState; + let isActivelyMoving = false; + let remainingDeltaS = clampedDeltaS; + let substeps = 0; + + while (remainingDeltaS > 0 && substeps < LOCAL_PREDICTION_MAX_SUBSTEPS) { + const stepDeltaS = Math.min(LOCAL_PREDICTION_SUBSTEP_DELTA_S, remainingDeltaS); + + this._localPredictionState.predictedPosition.y += this._localPredictionState.estimatedVerticalVelocity * stepDeltaS; + isActivelyMoving = this._applyPredictedMovementFromInputs( + stepDeltaS, + this._game.camera.gameCameraYaw, + this._game.inputManager.joystickDirection, + !!inputState.w, + !!inputState.a, + !!inputState.s, + !!inputState.d, + !!inputState.sh, + ) || isActivelyMoving; + + remainingDeltaS -= stepDeltaS; + substeps++; + } + + if ( + !this._localPredictionState.supportsInputAcknowledgements && + this._localPredictionState.preAckReconcileGraceRemainingS > 0 + ) { + this._localPredictionState.preAckReconcileGraceRemainingS = Math.max( + 0, + this._localPredictionState.preAckReconcileGraceRemainingS - clampedDeltaS, + ); + } + + // When ack replay is active, avoid pulling toward delayed authoritative state + // while there are pending commands. Before first ack, apply a short grace window + // to reduce start-of-movement tug while still preserving a smooth fallback. + const shouldContinuouslyReconcile = + this._localPredictionState.supportsInputAcknowledgements + ? this._localPredictionState.commandBufferCount === 0 + : ( + this._localPredictionState.commandBufferCount === 0 || + this._localPredictionState.preAckReconcileGraceRemainingS <= 0 + ); + + if (shouldContinuouslyReconcile) { + this._reconcileLocalPrediction(isActivelyMoving, clampedDeltaS); + } + + entity.applyClientPredictedTransform( + this._localPredictionState.predictedPosition, + this._localPredictionState.predictedRotation, + ); + } + + private _applyPredictedMovementFromInputs( + deltaTimeS: number, + yaw: number, + joystickDirection: number | null, + w: boolean, + a: boolean, + s: boolean, + d: boolean, + sh: boolean, + ): boolean { + const movementDirection = resolveDeterministicMovementDirection({ + yaw, + joystickDirection, + w, + a, + s, + d, + }); + const isActivelyMoving = movementDirection.lengthSq > 0; + if (!isActivelyMoving) { + return false; + } + + const walkSpeed = Math.max(LOCAL_PREDICTION_MIN_SPEED, this._localPredictionState.estimatedWalkSpeed); + const runSpeed = Math.max(walkSpeed, this._localPredictionState.estimatedRunSpeed); + const movementSpeed = sh ? runSpeed : walkSpeed; + this._localPredictionState.predictedPosition.x += movementDirection.x * movementSpeed * deltaTimeS; + this._localPredictionState.predictedPosition.z += movementDirection.z * movementSpeed * deltaTimeS; + + const movementYaw = resolveDeterministicMovementYaw(movementDirection.x, movementDirection.z); + const halfMovementYaw = movementYaw * 0.5; + this._localPredictionState.predictedRotation.set(0, Math.sin(halfMovementYaw), 0, Math.cos(halfMovementYaw)); + + return true; + } + + private _setLastAcknowledgedMovementDirection(command?: LocalPredictionCommand): void { + this._localPredictionState.lastAcknowledgedMovementDirectionX = undefined; + this._localPredictionState.lastAcknowledgedMovementDirectionZ = undefined; + + if (!command) { + return; + } + + const movementDirection = resolveDeterministicMovementDirection({ + yaw: command.yaw, + joystickDirection: command.joystickDirection, + w: command.w, + a: command.a, + s: command.s, + d: command.d, + }); + if (movementDirection.lengthSq <= 0) { + return; + } + + this._localPredictionState.lastAcknowledgedMovementDirectionX = movementDirection.x; + this._localPredictionState.lastAcknowledgedMovementDirectionZ = movementDirection.z; + } + + private _updateLocalPredictionVerticalVelocityEstimate(sampledVerticalVelocity: number): void { + if (!Number.isFinite(sampledVerticalVelocity)) { + return; + } + + if (Math.abs(sampledVerticalVelocity) > LOCAL_PREDICTION_VERTICAL_VELOCITY_REJECT_THRESHOLD) { + this._localPredictionState.estimatedVerticalVelocity = 0; + return; + } + + this._localPredictionState.estimatedVerticalVelocity += + (sampledVerticalVelocity - this._localPredictionState.estimatedVerticalVelocity) * LOCAL_PREDICTION_VERTICAL_VELOCITY_ADAPT_RATE; + } + + private _updateLocalPredictionSpeedEstimate( + sampledHorizontalSpeed: number, + dx: number, + dy: number, + dz: number, + calibrationAcknowledgedInputSequenceNumber?: number, + ): void { + if (calibrationAcknowledgedInputSequenceNumber === undefined) { + return; + } + + if ( + !this._localPredictionState.supportsInputAcknowledgements || + !this._localPredictionState.lastAcknowledgedHadMovementInput + ) { + return; + } + + if ( + !Number.isFinite(sampledHorizontalSpeed) || + sampledHorizontalSpeed < LOCAL_PREDICTION_MIN_SPEED || + sampledHorizontalSpeed > LOCAL_PREDICTION_SPEED_REJECT_THRESHOLD + ) { + return; + } + + if (Math.abs(dy) > LOCAL_PREDICTION_SPEED_VERTICAL_REJECT_THRESHOLD) { + return; + } + + const movementDirectionX = this._localPredictionState.lastAcknowledgedMovementDirectionX; + const movementDirectionZ = this._localPredictionState.lastAcknowledgedMovementDirectionZ; + if (movementDirectionX === undefined || movementDirectionZ === undefined) { + return; + } + + const sampleHorizontalDistanceSq = (dx * dx) + (dz * dz); + if (sampleHorizontalDistanceSq <= 0) { + return; + } + + const sampleHorizontalDistance = Math.sqrt(sampleHorizontalDistanceSq); + const sampledDirectionX = dx / sampleHorizontalDistance; + const sampledDirectionZ = dz / sampleHorizontalDistance; + const movementAlignmentDot = + (sampledDirectionX * movementDirectionX) + + (sampledDirectionZ * movementDirectionZ); + + if (movementAlignmentDot < LOCAL_PREDICTION_SPEED_DIRECTION_ALIGNMENT_MIN_DOT) { + return; + } + + const clampedSampledSpeed = Math.min(sampledHorizontalSpeed, LOCAL_PREDICTION_MAX_SPEED); + const shouldUpdateRunSpeed = !!this._localPredictionState.lastAcknowledgedMovementRunning; + + if (shouldUpdateRunSpeed) { + this._localPredictionState.estimatedRunSpeed += + (clampedSampledSpeed - this._localPredictionState.estimatedRunSpeed) * LOCAL_PREDICTION_SPEED_ADAPT_RATE; + } else { + this._localPredictionState.estimatedWalkSpeed += + (clampedSampledSpeed - this._localPredictionState.estimatedWalkSpeed) * LOCAL_PREDICTION_SPEED_ADAPT_RATE; + } + + this._localPredictionState.estimatedWalkSpeed = Math.min( + this._localPredictionState.estimatedWalkSpeed, + this._localPredictionState.estimatedRunSpeed, + ); + this._localPredictionState.estimatedWalkSpeed = Math.max( + this._localPredictionState.estimatedWalkSpeed, + LOCAL_PREDICTION_MIN_SPEED, + ); + this._localPredictionState.estimatedRunSpeed = Math.max( + this._localPredictionState.estimatedRunSpeed, + this._localPredictionState.estimatedWalkSpeed, + ); + } + + private _reconcileLocalPrediction(isActivelyMoving: boolean, deltaTimeS: number): void { + if (this._localPredictionState.hasAuthoritativePosition) { + const predictedPosition = this._localPredictionState.predictedPosition; + const authoritativePosition = this._localPredictionState.authoritativePosition; + const dx = authoritativePosition.x - predictedPosition.x; + const dz = authoritativePosition.z - predictedPosition.z; + const horizontalErrorSq = (dx * dx) + (dz * dz); + + if (horizontalErrorSq > LOCAL_PREDICTION_HORIZONTAL_SNAP_DISTANCE_SQ) { + predictedPosition.x = authoritativePosition.x; + predictedPosition.z = authoritativePosition.z; + } else { + const horizontalDeadZoneSq = isActivelyMoving + ? LOCAL_PREDICTION_MOVING_HORIZONTAL_ERROR_DEAD_ZONE_SQ + : LOCAL_PREDICTION_IDLE_HORIZONTAL_ERROR_DEAD_ZONE_SQ; + + if (horizontalErrorSq > horizontalDeadZoneSq) { + const correctionT = Math.min( + 1, + deltaTimeS * (isActivelyMoving ? LOCAL_PREDICTION_MOVING_HORIZONTAL_CORRECTION_RATE : LOCAL_PREDICTION_IDLE_HORIZONTAL_CORRECTION_RATE), + ); + predictedPosition.x += dx * correctionT; + predictedPosition.z += dz * correctionT; + } + } + + const verticalError = authoritativePosition.y - predictedPosition.y; + const absVerticalError = Math.abs(verticalError); + if (absVerticalError > LOCAL_PREDICTION_VERTICAL_SNAP_DISTANCE) { + predictedPosition.y = authoritativePosition.y; + } else { + const verticalDeadZone = isActivelyMoving + ? LOCAL_PREDICTION_MOVING_VERTICAL_ERROR_DEAD_ZONE + : LOCAL_PREDICTION_IDLE_VERTICAL_ERROR_DEAD_ZONE; + + if (absVerticalError > verticalDeadZone) { + const correctionT = Math.min( + 1, + deltaTimeS * (isActivelyMoving ? LOCAL_PREDICTION_MOVING_VERTICAL_CORRECTION_RATE : LOCAL_PREDICTION_IDLE_VERTICAL_CORRECTION_RATE), + ); + predictedPosition.y += verticalError * correctionT; + } + } + } + + if (this._localPredictionState.hasAuthoritativeRotation) { + const rotationError = this._localPredictionState.predictedRotation.angleTo(this._localPredictionState.authoritativeRotation); + if (rotationError > LOCAL_PREDICTION_ROTATION_SNAP_ANGLE) { + this._localPredictionState.predictedRotation.copy(this._localPredictionState.authoritativeRotation); + } else { + const deadZone = isActivelyMoving + ? LOCAL_PREDICTION_MOVING_ROTATION_ERROR_DEAD_ZONE + : LOCAL_PREDICTION_IDLE_ROTATION_ERROR_DEAD_ZONE; + + if (rotationError > deadZone) { + const correctionT = Math.min( + 1, + deltaTimeS * (isActivelyMoving ? LOCAL_PREDICTION_MOVING_ROTATION_CORRECTION_RATE : LOCAL_PREDICTION_IDLE_ROTATION_CORRECTION_RATE), + ); + this._localPredictionState.predictedRotation.slerp(this._localPredictionState.authoritativeRotation, correctionT); + } + } + } + } + private _onBlockEntityBuilt = (payload: WorkerEventPayload.IBlockEntityBuilt): void => { const entity = this._entities.get(payload.entityId); diff --git a/client/src/gltf/GLTFManager.ts b/client/src/gltf/GLTFManager.ts index d087456a..fde16595 100644 --- a/client/src/gltf/GLTFManager.ts +++ b/client/src/gltf/GLTFManager.ts @@ -438,7 +438,7 @@ class GLTFAlphaBlendingAndClippingMaterialPlugin implements GLTFLoaderPlugin { name: pbrMaterial.name, opacity: pbrMaterial.opacity, side: pbrMaterial.side, - transparent: pbrMaterial.transparent, + transparent: pbrMaterial.transparent ?? false, userData: pbrMaterial.userData, visible: pbrMaterial.visible, }); diff --git a/client/src/input/InputManager.ts b/client/src/input/InputManager.ts index a480f43c..fb4f9a46 100644 --- a/client/src/input/InputManager.ts +++ b/client/src/input/InputManager.ts @@ -9,6 +9,8 @@ const INTERACT_TAP_MAX_DURATION_MS = 200; // Max distance squared in pixels for a drag (vs tap) - 30px radius const INTERACT_DRAG_CANCEL_MAX_DISTANCE_SQ = 900; +const MOVEMENT_STATE_DIRTY_RESEND_TICKS = 3; +const MOVEMENT_PACKET_MAX_DELTA_S = 1 / 10; type InputState = { w?: boolean; // w @@ -144,6 +146,26 @@ const SUPPORTED_INPUT_MAP: { [key: string]: keyof InputState } = { }; const SUPPORTED_INPUTS = Object.values(SUPPORTED_INPUT_MAP); +const NETWORKED_MOVEMENT_INPUT_KEYS: (keyof InputState)[] = [ 'w', 'a', 's', 'd', 'sp', 'sh', 'c' ]; +const NETWORKED_MOVEMENT_INPUT_KEY_SET = new Set(NETWORKED_MOVEMENT_INPUT_KEYS); + +export enum InputManagerEventType { + MovementPacketSent = 'INPUT_MANAGER.MOVEMENT_PACKET_SENT', +} + +export namespace InputManagerEventPayload { + export interface IMovementPacketSent { + sequenceNumber: number; + deltaTimeS: number; + yaw: number; + joystickDirection: number | null; + w: boolean; + a: boolean; + s: boolean; + d: boolean; + sh: boolean; + } +} export default class InputManager { private _game: Game; @@ -151,6 +173,9 @@ export default class InputManager { private _isPointerLockFrozen = false; private _inputEnabled: boolean = true; private _inputState: InputState = {}; + private _joystickDirection: number | null = null; + private _wasMovementInputPressed: boolean = false; + private _movementStateDirtyResendTicks: number = 0; private _networkedInputEnabled: boolean = true; private _continuousInputState: ContinuousInputState = {}; private _onPressCallback: Map void> = new Map(); @@ -171,6 +196,7 @@ export default class InputManager { public get inputEnabled(): boolean { return this._inputEnabled; } public get inputState(): Readonly { return this._inputState; } public get isPointerLocked(): boolean { return this._isPointerLocked; } + public get joystickDirection(): number | null { return this._joystickDirection; } public enableInput(enabled: boolean): void { if (!enabled) { @@ -186,6 +212,12 @@ export default class InputManager { public enableNetworkedInput(enabled: boolean): void { this._networkedInputEnabled = enabled; + + if (!enabled) { + this._movementStateDirtyResendTicks = 0; + this._wasMovementInputPressed = false; + this._continuousInputState = {}; + } } public freezePointerLock(freeze: boolean): void { @@ -219,6 +251,11 @@ export default class InputManager { } public setJoystickDirection(radians: number | null): void { + if (this._joystickDirection !== radians) { + this._movementStateDirtyResendTicks = MOVEMENT_STATE_DIRTY_RESEND_TICKS; + } + + this._joystickDirection = radians; this._continuousInputState.jd = radians; } @@ -289,16 +326,95 @@ export default class InputManager { // twitch-inputs on desktop if not 60 might feel bad though. // we can change this to 30 when we have client prediction. const inputUpdateHz = MobileManager.isMobile ? 30 : 60; + let previousQueueTickTimeS = performance.now() / 1000; setInterval(() => { - if ( - this._continuousInputState.cp === undefined && - this._continuousInputState.cy === undefined && - this._continuousInputState.jd === undefined - ) return; + const nowS = performance.now() / 1000; + const queueDeltaS = Math.min( + Math.max(nowS - previousQueueTickTimeS, 1 / 240), + MOVEMENT_PACKET_MAX_DELTA_S, + ); + previousQueueTickTimeS = nowS; + + if (!this._networkedInputEnabled) { + this._continuousInputState = {}; + this._movementStateDirtyResendTicks = 0; + this._wasMovementInputPressed = false; + return; + } + + const hasCameraOrientationChanges = + this._continuousInputState.cp !== undefined || + this._continuousInputState.cy !== undefined; + + const hasMovementInputPressed = + !!this._inputState.w || + !!this._inputState.a || + !!this._inputState.s || + !!this._inputState.d || + !!this._inputState.sp || + !!this._inputState.sh || + !!this._inputState.c || + this._joystickDirection !== null; + + const shouldResendMovementState = this._movementStateDirtyResendTicks > 0; + const shouldSendMovementState = hasMovementInputPressed || shouldResendMovementState; + const becameIdle = this._wasMovementInputPressed && !hasMovementInputPressed; + + if (!hasCameraOrientationChanges && !shouldSendMovementState) { + this._wasMovementInputPressed = hasMovementInputPressed; + return; + } + + const inputPacket: Record = {}; + + if (shouldSendMovementState) { + inputPacket.w = !!this._inputState.w; + inputPacket.a = !!this._inputState.a; + inputPacket.s = !!this._inputState.s; + inputPacket.d = !!this._inputState.d; + inputPacket.sp = !!this._inputState.sp; + inputPacket.sh = !!this._inputState.sh; + inputPacket.c = !!this._inputState.c; + + if (this._joystickDirection !== null || shouldResendMovementState) { + inputPacket.jd = this._joystickDirection; + } + } + + if (this._continuousInputState.cp !== undefined) { + inputPacket.cp = this._continuousInputState.cp; + } + + if (this._continuousInputState.cy !== undefined) { + inputPacket.cy = this._continuousInputState.cy; + } + + const sequenceNumber = this._game.networkManager.sendInputPacket( + inputPacket, + becameIdle && shouldSendMovementState, + ); + + if (shouldSendMovementState && sequenceNumber !== undefined) { + EventRouter.instance.emit(InputManagerEventType.MovementPacketSent, { + sequenceNumber, + deltaTimeS: queueDeltaS, + yaw: this._game.camera.gameCameraYaw, + joystickDirection: this._joystickDirection, + w: !!this._inputState.w, + a: !!this._inputState.a, + s: !!this._inputState.s, + d: !!this._inputState.d, + sh: !!this._inputState.sh, + }); + } - this._game.networkManager.sendInputPacket(this._continuousInputState); this._continuousInputState = {}; + this._wasMovementInputPressed = hasMovementInputPressed; + + if (this._movementStateDirtyResendTicks > 0) { + this._movementStateDirtyResendTicks--; + } }, 1000 / inputUpdateHz); } @@ -335,7 +451,11 @@ export default class InputManager { this._inputState[mappedInput] = isPressed; if (this._networkedInputEnabled) { - this._game.networkManager.sendInputPacket({ [mappedInput]: isPressed }); + if (NETWORKED_MOVEMENT_INPUT_KEY_SET.has(mappedInput)) { + this._movementStateDirtyResendTicks = MOVEMENT_STATE_DIRTY_RESEND_TICKS; + } else { + this._game.networkManager.sendInputPacket({ [mappedInput]: isPressed }); + } } } } @@ -387,4 +507,4 @@ export default class InputManager { private _onPointerCancel = (event: PointerEvent) => { this._interactPointers.delete(event.pointerId); } -} \ No newline at end of file +} diff --git a/client/src/network/Deserializer.ts b/client/src/network/Deserializer.ts index f15cbfb0..4977f63a 100644 --- a/client/src/network/Deserializer.ts +++ b/client/src/network/Deserializer.ts @@ -89,6 +89,7 @@ export type DeserializedChunk = { export type DeserializedChunks = DeserializedChunk[]; export type DeserializedEntity = { + acknowledgedInputSequenceNumber?: number; id: number; blockTextureUri?: string; blockHalfExtents?: THREE.Vector3Like; @@ -381,7 +382,10 @@ export default class Deserializer { } public static deserializeEntity(entity: protocol.EntitySchema): DeserializedEntity { + const entityWithInputAck = entity as protocol.EntitySchema & { aq?: number }; + return { + acknowledgedInputSequenceNumber: entityWithInputAck.aq, id: entity.i, blockTextureUri: entity.bt, blockHalfExtents: entity.bh ? this.deserializeVector(entity.bh) : undefined, diff --git a/client/src/network/NetworkManager.ts b/client/src/network/NetworkManager.ts index 361281ef..8ad5ba77 100644 --- a/client/src/network/NetworkManager.ts +++ b/client/src/network/NetworkManager.ts @@ -30,6 +30,12 @@ const packr = new Packr({ useFloat32: FLOAT32_OPTIONS.ALWAYS }); const HEARTBEAT_INTERVAL_MS = 5000; let heartbeatReported = false; +const SEQUENCED_MOVEMENT_INPUT_KEYS = new Set([ + 'w', 'a', 's', 'd', + 'sp', 'sh', 'c', + 'jd', +]); +const UNSEQUENCED_UNRELIABLE_INPUT_KEYS = new Set([ 'cp', 'cy' ]); type ServerFeatures = { supportsSceneInteract?: boolean; @@ -185,25 +191,30 @@ export default class NetworkManager { return patch >= patchMin; } - public sendInputPacket(changedInputState: Record): void { - let reliable = false; + public sendInputPacket(changedInputState: Record, reliableOverride?: boolean): number | undefined { + let hasSequencedMovementInput = false; + let hasReliableNonMovementInput = false; - // If the input includes anything other than camera movements or joystick direction, send reliably - // Exception: jd=null (joystick stop movement) must be reliable to not risk it being dropped for (const key in changedInputState) { - if (key !== 'cp' && key !== 'cy' && (key !== 'jd' || changedInputState[key] === null)) { - reliable = true; - break; + if (SEQUENCED_MOVEMENT_INPUT_KEYS.has(key)) { + hasSequencedMovementInput = true; + } else if (!UNSEQUENCED_UNRELIABLE_INPUT_KEYS.has(key)) { + hasReliableNonMovementInput = true; } } - if (changedInputState.jd !== undefined) { // Only joystick packets need sequence numbers for ordering - changedInputState.sq = this._lastInputSequenceNumber; + let sequenceNumber: number | undefined; + if (hasSequencedMovementInput) { + sequenceNumber = this._lastInputSequenceNumber++; + changedInputState.sq = sequenceNumber; } + // Movement snapshots are sent unreliably for low latency. + // Action/state packets stay reliable unless mixed with movement data. + const reliable = reliableOverride ?? hasReliableNonMovementInput; this.sendPacket(protocol.createPacket(protocol.inputPacketDefinition, changedInputState), reliable); - this._lastInputSequenceNumber++; + return sequenceNumber; } public sendChatMessagePacket(message: string): void { @@ -572,7 +583,7 @@ export default class NetworkManager { private async _reconnect(): Promise { // Check if server is still up - if not, it's an unexpected disconnect (crash) - const serverHealthy = await Servers.isCurrentServerHealthy().catch(() => false); + await Servers.isCurrentServerHealthy().catch(() => false); const url = new URL(window.location.href); @@ -593,4 +604,4 @@ export default class NetworkManager { this._syncStartTimeS = performance.now() / 1000; this.sendPacket(protocol.createPacket(protocol.syncRequestPacketDefinition, null)); } -} \ No newline at end of file +} diff --git a/client/src/shared/movement/DeterministicMovementCore.ts b/client/src/shared/movement/DeterministicMovementCore.ts new file mode 100644 index 00000000..629406ad --- /dev/null +++ b/client/src/shared/movement/DeterministicMovementCore.ts @@ -0,0 +1,49 @@ +export type DeterministicMovementInput = { + yaw: number; + joystickDirection: number | null; + w: boolean; + a: boolean; + s: boolean; + d: boolean; +}; + +export type DeterministicMovementDirection = { + x: number; + z: number; + lengthSq: number; +}; + +export const resolveDeterministicMovementDirection = ( + input: DeterministicMovementInput, +): DeterministicMovementDirection => { + let x = 0; + let z = 0; + + if (typeof input.joystickDirection === 'number') { + const movementAngle = input.yaw + input.joystickDirection; + x = -Math.sin(movementAngle); + z = -Math.cos(movementAngle); + } else { + const sinYaw = Math.sin(input.yaw); + const cosYaw = Math.cos(input.yaw); + + if (input.w) { x -= sinYaw; z -= cosYaw; } + if (input.s) { x += sinYaw; z += cosYaw; } + if (input.a) { x -= cosYaw; z += sinYaw; } + if (input.d) { x += cosYaw; z -= sinYaw; } + } + + const lengthSq = (x * x) + (z * z); + if (lengthSq > 1) { + const inverseLength = 1 / Math.sqrt(lengthSq); + x *= inverseLength; + z *= inverseLength; + return { x, z, lengthSq: 1 }; + } + + return { x, z, lengthSq }; +}; + +export const resolveDeterministicMovementYaw = (directionX: number, directionZ: number): number => { + return Math.atan2(-directionX, -directionZ); +}; diff --git a/protocol/schemas/Entity.ts b/protocol/schemas/Entity.ts index 01624d1e..b61cf73e 100644 --- a/protocol/schemas/Entity.ts +++ b/protocol/schemas/Entity.ts @@ -13,6 +13,7 @@ import type { RgbColorSchema } from './RgbColor'; import type { VectorSchema } from './Vector'; export type EntitySchema = { + aq?: number; // last applied input sequence number (owner-only) i: number; // entity id bh?: VectorSchema; // block half extents bt?: string; // block texture uri @@ -41,6 +42,7 @@ export type EntitySchema = { export const entitySchema: JSONSchemaType = { type: 'object', properties: { + aq: { type: 'number', nullable: true }, i: { type: 'number' }, bh: { ...vectorSchema, nullable: true }, bt: { type: 'string', nullable: true }, @@ -67,4 +69,4 @@ export const entitySchema: JSONSchemaType = { }, required: [ 'i' ], additionalProperties: false, -} \ No newline at end of file +} diff --git a/server/src/networking/NetworkSynchronizer.ts b/server/src/networking/NetworkSynchronizer.ts index 0c64e071..04558947 100644 --- a/server/src/networking/NetworkSynchronizer.ts +++ b/server/src/networking/NetworkSynchronizer.ts @@ -39,6 +39,11 @@ import type Vector3Like from '@/shared/types/math/Vector3Like'; const DEFAULT_NETWORK_SYNC_RATE = 30; const TICKS_PER_NETWORK_SYNC = Math.round(DEFAULT_TICK_RATE / DEFAULT_NETWORK_SYNC_RATE); // eg 60hz / 30hz = 2 tick syncs +const PROTOCOL_ENTITY_SCHEMA = (protocol as unknown as { entitySchema?: { properties?: Record } }).entitySchema; +const PROTOCOL_SUPPORTS_ENTITY_INPUT_ACK = Object.prototype.hasOwnProperty.call( + PROTOCOL_ENTITY_SCHEMA?.properties ?? {}, + 'aq', +); type SyncQueue = { broadcast: IterationMap; @@ -150,6 +155,7 @@ export default class NetworkSynchronizer { */ const currentTick = this._world.loop.currentTick; + this._queuePlayerInputAcknowledgements(); // 1. entities /** @@ -1558,4 +1564,28 @@ export default class NetworkSynchronizer { this._createOrGetQueuedEntitySync(entity, playerCamera.player).m = modelUri; } } + + private _queuePlayerInputAcknowledgements(): void { + if (!PROTOCOL_SUPPORTS_ENTITY_INPUT_ACK) { + return; + } + + for (const playerEntity of this._world.entityManager.getAllPlayerEntities()) { + if (!playerEntity.isSpawned || playerEntity.id === undefined) { + continue; + } + + if (playerEntity.player.world !== this._world) { + continue; + } + + const acknowledgedInputSequence = playerEntity.player.lastAppliedInputSequenceNumber; + if (acknowledgedInputSequence === undefined) { + continue; + } + + const entitySync = this._createOrGetQueuedEntitySync(playerEntity, playerEntity.player); + (entitySync as protocol.EntitySchema & { aq?: number }).aq = acknowledgedInputSequence; + } + } } diff --git a/server/src/players/Player.ts b/server/src/players/Player.ts index 1221c941..3e5aaebe 100644 --- a/server/src/players/Player.ts +++ b/server/src/players/Player.ts @@ -33,6 +33,33 @@ export const SUPPORTED_INPUTS = [ 'jd', // Joystick direction (radians) ] as const satisfies readonly (keyof InputSchema)[]; +const SEQUENCED_MOVEMENT_INPUTS = [ + 'w', + 'a', + 's', + 'd', + 'sp', + 'sh', + 'c', + 'jd', +] as const satisfies readonly (keyof InputSchema)[]; +const SEQUENCED_MOVEMENT_INPUT_SET = new Set(SEQUENCED_MOVEMENT_INPUTS); +const MAX_QUEUED_SEQUENCED_MOVEMENT_COMMANDS = 64; + +type SequencedMovementInputCommand = { + sequenceNumber: number; + w: boolean; + a: boolean; + s: boolean; + d: boolean; + sp: boolean; + sh: boolean; + c: boolean; + jd: number | null; + cp?: number; + cy?: number; +}; + /** * The input state of a `Player`. * @@ -164,7 +191,13 @@ export default class Player extends EventRouter implements protocol.Serializable private _interactEnabled: boolean = true; /** @internal */ - private _lastUnreliableInputSequenceNumber: number = 0; + private _lastUnreliableInputSequenceNumber: number = -1; + + /** @internal */ + private _lastAppliedInputSequenceNumber: number = -1; + + /** @internal */ + private _queuedSequencedMovementInputs: SequencedMovementInputCommand[] = []; /** @internal */ private _maxInteractDistance: number = 20; @@ -224,6 +257,11 @@ export default class Player extends EventRouter implements protocol.Serializable */ public get maxInteractDistance(): number { return this._maxInteractDistance; } + /** @internal */ + public get lastAppliedInputSequenceNumber(): number | undefined { + return this._lastAppliedInputSequenceNumber >= 0 ? this._lastAppliedInputSequenceNumber : undefined; + } + /** * The current `World` the player is in, or undefined if not yet joined. * @@ -378,7 +416,9 @@ export default class Player extends EventRouter implements protocol.Serializable return; } - this._lastUnreliableInputSequenceNumber = 0; + this._lastUnreliableInputSequenceNumber = -1; + this._lastAppliedInputSequenceNumber = -1; + this._queuedSequencedMovementInputs = []; if (!this._worldSwitched) { this.emitWithWorld(this._world, PlayerEvent.RECONNECTED_WORLD, { @@ -405,6 +445,7 @@ export default class Player extends EventRouter implements protocol.Serializable */ public resetInputs() { this._input = {}; + this._queuedSequencedMovementInputs = []; } /** @@ -465,6 +506,59 @@ export default class Player extends EventRouter implements protocol.Serializable return Serializer.serializePlayer(this); } + /** @internal */ + public markInputAppliedForSimulation(): void { + if ( + this._queuedSequencedMovementInputs.length === 0 && + this._lastUnreliableInputSequenceNumber >= 0 + ) { + this._lastAppliedInputSequenceNumber = this._lastUnreliableInputSequenceNumber; + } + } + + /** @internal */ + public applyQueuedInputForSimulation(): void { + if (this._queuedSequencedMovementInputs.length === 0) { + this.markInputAppliedForSimulation(); + return; + } + + let sawJumpPressed = false; + for (let i = 0; i < this._queuedSequencedMovementInputs.length; i++) { + if (this._queuedSequencedMovementInputs[i].sp) { + sawJumpPressed = true; + } + } + + const command = this._queuedSequencedMovementInputs[this._queuedSequencedMovementInputs.length - 1]; + this._queuedSequencedMovementInputs.length = 0; + + this._input.w = command.w; + this._input.a = command.a; + this._input.s = command.s; + this._input.d = command.d; + this._input.sp = command.sp || sawJumpPressed; + this._input.sh = command.sh; + this._input.c = command.c; + if (command.jd === null) { + delete this._input.jd; + } else { + this._input.jd = command.jd; + } + + if (command.cp !== undefined) { + this._input.cp = command.cp; + this.camera.setOrientationPitch(command.cp); + } + + if (command.cy !== undefined) { + this._input.cy = command.cy; + this.camera.setOrientationYaw(command.cy); + } + + this._lastAppliedInputSequenceNumber = command.sequenceNumber; + } + /** @internal */ private _leaveWorld() { if (!this._world) { @@ -520,17 +614,74 @@ export default class Player extends EventRouter implements protocol.Serializable // that the sequence number is greater than the last received. // If not, ignore the packet. if (input.sq !== undefined) { - if (input.sq < this._lastUnreliableInputSequenceNumber) return; + if (input.sq <= this._lastUnreliableInputSequenceNumber) return; this._lastUnreliableInputSequenceNumber = input.sq; } - Object.assign(this._input, input); + const hasSequencedMovementInput = input.sq !== undefined && this._hasSequencedMovementInput(input); + if (hasSequencedMovementInput) { + this._enqueueSequencedMovementInputCommand(input); + } + + for (const key in input) { + if (key === 'sq') { + continue; + } + + // Sequenced movement state is applied on simulation ticks from the command queue. + if (hasSequencedMovementInput && SEQUENCED_MOVEMENT_INPUT_SET.has(key)) { + continue; + } + + // Camera orientation in sequenced movement packets is applied atomically with movement. + if (hasSequencedMovementInput && (key === 'cp' || key === 'cy')) { + continue; + } + + (this._input as Record)[key] = input[key as keyof InputSchema] as unknown; + } - if (input.cp !== undefined) this.camera.setOrientationPitch(input.cp); - if (input.cy !== undefined) this.camera.setOrientationYaw(input.cy); + if (!hasSequencedMovementInput && input.cp !== undefined) this.camera.setOrientationPitch(input.cp); + if (!hasSequencedMovementInput && input.cy !== undefined) this.camera.setOrientationYaw(input.cy); if (this.world && input.ird && input.iro) this.interact(); }; + /** @internal */ + private _hasSequencedMovementInput(input: InputSchema): boolean { + for (const key of SEQUENCED_MOVEMENT_INPUTS) { + if (key in input) { + return true; + } + } + + return false; + } + + /** @internal */ + private _enqueueSequencedMovementInputCommand(input: InputSchema): void { + const inputWithNullableJoystick = input as InputSchema & { jd?: number | null }; + + const command: SequencedMovementInputCommand = { + sequenceNumber: input.sq!, + w: input.w ?? !!this._input.w, + a: input.a ?? !!this._input.a, + s: input.s ?? !!this._input.s, + d: input.d ?? !!this._input.d, + sp: input.sp ?? !!this._input.sp, + sh: input.sh ?? !!this._input.sh, + c: input.c ?? !!this._input.c, + jd: inputWithNullableJoystick.jd !== undefined ? inputWithNullableJoystick.jd : (this._input.jd ?? null), + cp: input.cp, + cy: input.cy, + }; + + if (this._queuedSequencedMovementInputs.length >= MAX_QUEUED_SEQUENCED_MOVEMENT_COMMANDS) { + this._queuedSequencedMovementInputs.shift(); + } + + this._queuedSequencedMovementInputs.push(command); + } + /** @internal */ private interact = () => { if (!this.world || !this._input.ird || !this._input.iro) return; diff --git a/server/src/worlds/entities/PlayerEntity.ts b/server/src/worlds/entities/PlayerEntity.ts index e30127ff..41e8c681 100644 --- a/server/src/worlds/entities/PlayerEntity.ts +++ b/server/src/worlds/entities/PlayerEntity.ts @@ -149,6 +149,8 @@ export default class PlayerEntity extends Entity { return ErrorHandler.error(`PlayerEntity.tick(): PlayerEntity "${this.name}" must have a controller.`); } + this.player.applyQueuedInputForSimulation(); + if (this._tickWithPlayerInputEnabled) { const { input, camera } = this.player; diff --git a/server/src/worlds/entities/controllers/DefaultPlayerEntityController.ts b/server/src/worlds/entities/controllers/DefaultPlayerEntityController.ts index a957eb0c..381d3098 100644 --- a/server/src/worlds/entities/controllers/DefaultPlayerEntityController.ts +++ b/server/src/worlds/entities/controllers/DefaultPlayerEntityController.ts @@ -7,6 +7,7 @@ import { EntityModelAnimationBlendMode, EntityModelAnimationLoopMode } from '@/w import ErrorHandler from '@/errors/ErrorHandler'; import PlayerEntity from '@/worlds/entities/PlayerEntity'; import BlockType from '@/worlds/blocks/BlockType'; +import { resolveDeterministicMovementDirection } from '@/worlds/entities/controllers/shared/DeterministicMovementCore'; import type { PlayerInput } from '@/players/Player'; import type { PlayerCameraOrientation } from '@/players/PlayerCamera'; import type Vector3Like from '@/shared/types/math/Vector3Like'; @@ -661,29 +662,17 @@ export default class DefaultPlayerEntityController extends BaseEntityController const velocity = !this.isSwimming ? isFastMovement ? this.runVelocity : this.walkVelocity : isFastMovement ? this.swimFastVelocity : this.swimSlowVelocity; - - if (hasJoystickInput) { - // Joystick movement: exact direction relative to camera (jd: 0=forward) - const movementAngle = yaw + jd; - this._reusableTargetVelocities.x = -velocity * Math.sin(movementAngle); - this._reusableTargetVelocities.z = -velocity * Math.cos(movementAngle); - } else { - // WASD movement: discrete directions relative to camera - const sinYaw = Math.sin(yaw); - const cosYaw = Math.cos(yaw); - - if (w) { this._reusableTargetVelocities.x -= velocity * sinYaw; this._reusableTargetVelocities.z -= velocity * cosYaw; } - if (s) { this._reusableTargetVelocities.x += velocity * sinYaw; this._reusableTargetVelocities.z += velocity * cosYaw; } - if (a) { this._reusableTargetVelocities.x -= velocity * cosYaw; this._reusableTargetVelocities.z += velocity * sinYaw; } - if (d) { this._reusableTargetVelocities.x += velocity * cosYaw; this._reusableTargetVelocities.z -= velocity * sinYaw; } - - // Normalize diagonal movement to prevent speed boost - const horizontalSpeed = Math.sqrt(this._reusableTargetVelocities.x * this._reusableTargetVelocities.x + this._reusableTargetVelocities.z * this._reusableTargetVelocities.z); - if (horizontalSpeed > velocity) { - const factor = velocity / horizontalSpeed; - this._reusableTargetVelocities.x *= factor; - this._reusableTargetVelocities.z *= factor; - } + const movementDirection = resolveDeterministicMovementDirection({ + yaw, + joystickDirection: hasJoystickInput ? jd : null, + w: !!w, + a: !!a, + s: !!s, + d: !!d, + }); + if (movementDirection.lengthSq > 0) { + this._reusableTargetVelocities.x = movementDirection.x * velocity; + this._reusableTargetVelocities.z = movementDirection.z * velocity; } } diff --git a/server/src/worlds/entities/controllers/shared/DeterministicMovementCore.ts b/server/src/worlds/entities/controllers/shared/DeterministicMovementCore.ts new file mode 100644 index 00000000..2d68d0e7 --- /dev/null +++ b/server/src/worlds/entities/controllers/shared/DeterministicMovementCore.ts @@ -0,0 +1,45 @@ +export type DeterministicMovementInput = { + yaw: number; + joystickDirection: number | null; + w: boolean; + a: boolean; + s: boolean; + d: boolean; +}; + +export type DeterministicMovementDirection = { + x: number; + z: number; + lengthSq: number; +}; + +export const resolveDeterministicMovementDirection = ( + input: DeterministicMovementInput, +): DeterministicMovementDirection => { + let x = 0; + let z = 0; + + if (typeof input.joystickDirection === 'number') { + const movementAngle = input.yaw + input.joystickDirection; + x = -Math.sin(movementAngle); + z = -Math.cos(movementAngle); + } else { + const sinYaw = Math.sin(input.yaw); + const cosYaw = Math.cos(input.yaw); + + if (input.w) { x -= sinYaw; z -= cosYaw; } + if (input.s) { x += sinYaw; z += cosYaw; } + if (input.a) { x -= cosYaw; z += sinYaw; } + if (input.d) { x += cosYaw; z -= sinYaw; } + } + + const lengthSq = (x * x) + (z * z); + if (lengthSq > 1) { + const inverseLength = 1 / Math.sqrt(lengthSq); + x *= inverseLength; + z *= inverseLength; + return { x, z, lengthSq: 1 }; + } + + return { x, z, lengthSq }; +};