diff --git a/tests/domain/scene-document-validation.test.ts b/tests/domain/scene-document-validation.test.ts index 62a6daa9..344473e8 100644 --- a/tests/domain/scene-document-validation.test.ts +++ b/tests/domain/scene-document-validation.test.ts @@ -130,7 +130,9 @@ describe("validateSceneDocument", () => { bufferMs: -1, coyoteTimeMs: -1, variableHeight: "yes", - maxHoldMs: 0 + maxHoldMs: 0, + moveWhileJumping: "yes", + moveWhileFalling: 1 }, sprint: { speedMultiplier: 0 @@ -209,6 +211,12 @@ describe("validateSceneDocument", () => { expect.objectContaining({ code: "invalid-player-start-variable-jump-max-hold-ms" }), + expect.objectContaining({ + code: "invalid-player-start-move-while-jumping" + }), + expect.objectContaining({ + code: "invalid-player-start-move-while-falling" + }), expect.objectContaining({ code: "invalid-player-start-sprint-speed-multiplier" }), diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts index a89866dd..6cf04b03 100644 --- a/tests/serialization/scene-document-json.test.ts +++ b/tests/serialization/scene-document-json.test.ts @@ -465,7 +465,9 @@ describe("scene document JSON", () => { bufferMs: 120, coyoteTimeMs: 90, variableHeight: true, - maxHoldMs: 220 + maxHoldMs: 220, + moveWhileJumping: false, + moveWhileFalling: false }, sprint: { speedMultiplier: 1.8 @@ -939,6 +941,40 @@ describe("scene document JSON", () => { ); }); + it("migrates version 33 Player Start jump settings to include default air movement flags", () => { + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-legacy-air-move-flags", + movementTemplate: { + kind: "responsive" + } + }); + const { + moveWhileJumping: _moveWhileJumping, + moveWhileFalling: _moveWhileFalling, + ...legacyJump + } = playerStart.movementTemplate.jump; + const legacyDocument = { + ...createEmptySceneDocument({ + name: "Legacy Player Air Movement Scene" + }), + version: 33, + entities: { + [playerStart.id]: { + ...playerStart, + movementTemplate: { + ...playerStart.movementTemplate, + jump: legacyJump + } + } + } + }; + + const migratedDocument = migrateSceneDocument(legacyDocument); + + expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); + expect(migratedDocument.entities[playerStart.id]).toEqual(playerStart); + }); + it("round-trips authored third-person Player Start navigation", () => { const playerStart = createPlayerStartEntity({ id: "entity-player-start-third-person", diff --git a/tests/unit/player-locomotion.test.ts b/tests/unit/player-locomotion.test.ts index b0dc36e7..523bfc7d 100644 --- a/tests/unit/player-locomotion.test.ts +++ b/tests/unit/player-locomotion.test.ts @@ -382,6 +382,67 @@ describe("player-locomotion", () => { expect(step?.verticalVelocity).toBeLessThan(0); }); + it("disables jump-phase air movement when move while jumping is off", () => { + const step = stepPlayerLocomotion({ + dt: 0.1, + feetPosition: { + x: 0, + y: 1, + z: 0 + }, + movementYawRadians: 0, + standingShape: FIRST_PERSON_PLAYER_SHAPE, + verticalVelocity: 2, + previousLocomotionState: createIdleRuntimeLocomotionState("airborne"), + previousPlanarDisplacement: { + x: 0, + y: 0, + z: 0 + }, + jumpBufferRemainingMs: 0, + coyoteTimeRemainingMs: 0, + jumpHoldRemainingMs: 0, + crouched: false, + wasJumpPressed: false, + input: FORWARD_INPUT, + movement: { + ...DEFAULT_MOVEMENT, + jump: { + ...DEFAULT_MOVEMENT.jump, + moveWhileJumping: false + } + }, + resolveMotion: (feetPosition, motion): ResolvedPlayerMotion => ({ + 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 + } + }), + resolveVolumeState: () => createVolumeState(), + probeGround: () => ({ + grounded: false, + distance: null, + normal: null, + slopeDegrees: null + }), + canOccupyShape: () => true + }); + + expect(step).not.toBeNull(); + expect(step?.locomotionState.locomotionMode).toBe("airborne"); + expect(step?.planarDisplacement.z).toBeCloseTo(0); + expect(step?.locomotionState.planarSpeed).toBeCloseTo(0); + }); + it("keeps falling when airborne input pushes into a wall and the pre-move probe flickers grounded", () => { let probeCount = 0; @@ -452,6 +513,67 @@ describe("player-locomotion", () => { expect(step?.verticalVelocity).toBeLessThan(-1.5); }); + it("disables falling air movement when move while falling is off", () => { + const step = stepPlayerLocomotion({ + dt: 0.1, + feetPosition: { + x: 0, + y: 1, + z: 0 + }, + movementYawRadians: 0, + standingShape: FIRST_PERSON_PLAYER_SHAPE, + verticalVelocity: -2, + previousLocomotionState: createIdleRuntimeLocomotionState("airborne"), + previousPlanarDisplacement: { + x: 0, + y: 0, + z: 0 + }, + jumpBufferRemainingMs: 0, + coyoteTimeRemainingMs: 0, + jumpHoldRemainingMs: 0, + crouched: false, + wasJumpPressed: false, + input: FORWARD_INPUT, + movement: { + ...DEFAULT_MOVEMENT, + jump: { + ...DEFAULT_MOVEMENT.jump, + moveWhileFalling: false + } + }, + resolveMotion: (feetPosition, motion): ResolvedPlayerMotion => ({ + 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 + } + }), + resolveVolumeState: () => createVolumeState(), + probeGround: () => ({ + grounded: false, + distance: null, + normal: null, + slopeDegrees: null + }), + canOccupyShape: () => true + }); + + expect(step).not.toBeNull(); + expect(step?.locomotionState.locomotionMode).toBe("airborne"); + expect(step?.planarDisplacement.z).toBeCloseTo(0); + expect(step?.locomotionState.planarSpeed).toBeCloseTo(0); + }); + it("sinks toward the water surface while keeping the head above water", () => { const step = stepPlayerLocomotion({ dt: 0.1,