From baa3c794d0a41fd3629338832ac58083edc289eb Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sat, 11 Apr 2026 19:34:22 +0200 Subject: [PATCH] Update locomotion logic to handle airborne state and previous locomotion state --- .../first-person-navigation-controller.ts | 1 + src/runtime-three/player-locomotion.ts | 14 ++- .../third-person-navigation-controller.ts | 1 + ...first-person-navigation-controller.test.ts | 93 ++++++++++++++++++- 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/runtime-three/first-person-navigation-controller.ts b/src/runtime-three/first-person-navigation-controller.ts index ac28bce1..f43c2605 100644 --- a/src/runtime-three/first-person-navigation-controller.ts +++ b/src/runtime-three/first-person-navigation-controller.ts @@ -272,6 +272,7 @@ export class FirstPersonNavigationController implements NavigationController { movementYawRadians: this.yawRadians, standingShape: this.standingPlayerShape, verticalVelocity: this.verticalVelocity, + previousLocomotionState: this.locomotionState, crouched: this.locomotionState.crouched, wasJumpPressed: this.jumpPressed, input: inputState, diff --git a/src/runtime-three/player-locomotion.ts b/src/runtime-three/player-locomotion.ts index c5ad8cd0..8365dc0e 100644 --- a/src/runtime-three/player-locomotion.ts +++ b/src/runtime-three/player-locomotion.ts @@ -69,6 +69,7 @@ export interface StepPlayerLocomotionOptions { movementYawRadians: number; standingShape: FirstPersonPlayerShape; verticalVelocity: number; + previousLocomotionState?: RuntimeLocomotionState; crouched: boolean; wasJumpPressed: boolean; input: PlayerStartActionInputState; @@ -286,14 +287,25 @@ export function stepPlayerLocomotion( options.movement.capabilities.sprint && sprintPressed && !crouched && + currentlyGrounded && !currentVolumeState.inWater; - const requestedPlanarSpeed = + const groundedRequestedPlanarSpeed = options.movement.moveSpeed * (crouched ? CROUCH_SPEED_MULTIPLIER : sprinting ? SPRINT_SPEED_MULTIPLIER : 1); + const airborneRequestedPlanarSpeed = Math.max( + options.movement.moveSpeed, + options.previousLocomotionState?.requestedPlanarSpeed ?? 0 + ); + const requestedPlanarSpeed = + activeShape.mode !== "none" && + !currentVolumeState.inWater && + !currentlyGrounded + ? airborneRequestedPlanarSpeed + : groundedRequestedPlanarSpeed; const planarMotion = computePlanarMotion( options.movementYawRadians, options.input, diff --git a/src/runtime-three/third-person-navigation-controller.ts b/src/runtime-three/third-person-navigation-controller.ts index 297a9c60..ef83c615 100644 --- a/src/runtime-three/third-person-navigation-controller.ts +++ b/src/runtime-three/third-person-navigation-controller.ts @@ -244,6 +244,7 @@ export class ThirdPersonNavigationController implements NavigationController { movementYawRadians: this.cameraYawRadians, standingShape: this.standingPlayerShape, verticalVelocity: this.verticalVelocity, + previousLocomotionState: this.locomotionState, crouched: this.locomotionState.crouched, wasJumpPressed: this.jumpPressed, input: inputState, diff --git a/tests/unit/first-person-navigation-controller.test.ts b/tests/unit/first-person-navigation-controller.test.ts index 17184300..d5727b46 100644 --- a/tests/unit/first-person-navigation-controller.test.ts +++ b/tests/unit/first-person-navigation-controller.test.ts @@ -385,7 +385,7 @@ describe("FirstPersonNavigationController", () => { }); }); - it("preserves sprint planar speed while jumping", () => { + it("preserves takeoff sprint speed while airborne", () => { const probePlayerGround = vi.fn( ( feetPosition: Vec3, @@ -473,7 +473,7 @@ describe("FirstPersonNavigationController", () => { airborneTelemetry?.locomotionState.requestedPlanarSpeed ).toBeGreaterThan(7); expect(airborneTelemetry?.locomotionState.planarSpeed).toBeGreaterThan(7); - expect(airborneTelemetry?.locomotionState.sprinting).toBe(true); + expect(airborneTelemetry?.locomotionState.sprinting).toBe(false); window.dispatchEvent(new KeyboardEvent("keyup", { code: "Space" })); window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyW" })); @@ -485,6 +485,95 @@ describe("FirstPersonNavigationController", () => { }); }); + it("does not keep crouch speed penalty while airborne", () => { + const probePlayerGround = vi.fn( + ( + feetPosition: Vec3, + _shape: FirstPersonPlayerShape, + _maxDistance: number + ): PlayerGroundProbeResult => { + if (feetPosition.y <= 0.13) { + return { + grounded: true, + distance: feetPosition.y, + normal: { x: 0, y: 1, z: 0 }, + slopeDegrees: 0 + }; + } + + return { + grounded: false, + distance: null, + normal: null, + slopeDegrees: null + }; + } + ); + + const { context } = createRuntimeControllerContext( + createPlayerStartEntity({ + id: "entity-player-start-crouch-jump" + }), + (feetPosition, motion) => ({ + feetPosition: { + x: feetPosition.x + motion.x, + y: feetPosition.y + motion.y, + z: feetPosition.z + motion.z + }, + grounded: false, + collisionCount: 0, + groundCollisionNormal: null, + collidedAxes: { + x: false, + y: false, + z: false + } + }), + { + probePlayerGround, + canOccupyPlayerShape: () => true + } + ); + const controller = new FirstPersonNavigationController(); + + controller.activate(context); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ControlLeft" }) + ); + window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" })); + controller.update(1 / 60); + + const crouchedGroundedTelemetry = + context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0]; + + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Space" })); + controller.update(1 / 60); + controller.update(1 / 60); + + const airborneTelemetry = + context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0]; + + expect(crouchedGroundedTelemetry?.locomotionState.crouched).toBe(true); + expect(crouchedGroundedTelemetry?.locomotionState.requestedPlanarSpeed).toBe( + lessThanBaseMoveSpeedMatcher() + ); + + expect(airborneTelemetry?.locomotionState.locomotionMode).toBe("airborne"); + expect(airborneTelemetry?.locomotionState.requestedPlanarSpeed).toBeCloseTo( + 4.5 + ); + expect(airborneTelemetry?.locomotionState.planarSpeed).toBeCloseTo(4.5); + + window.dispatchEvent(new KeyboardEvent("keyup", { code: "Space" })); + window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyW" })); + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "ControlLeft" }) + ); + controller.deactivate(context, { + releasePointerLock: false + }); + }); + it("lowers the eye height and locomotion gait when crouch is held", () => { const { context } = createRuntimeControllerContext( createPlayerStartEntity({