import { describe, expect, it } from "vitest"; import { BoxGeometry, PlaneGeometry } from "three"; import { createModelInstance } from "../../src/assets/model-instances"; import { createBoxBrush } from "../../src/document/brushes"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createTerrain } from "../../src/document/terrains"; import { createNpcEntity, createPlayerStartEntity } from "../../src/entities/entity-instances"; import { RapierCollisionWorld } from "../../src/runtime-three/rapier-collision-world"; import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; describe("RapierCollisionWorld", () => { it("resolves first-person motion against brush and imported model colliders in one query path", async () => { const floorBrush = createBoxBrush({ id: "brush-floor", center: { x: 0, y: -0.5, z: 0 }, size: { x: 10, y: 1, z: 10 } }); const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-crate", new BoxGeometry(1, 1, 1)); const crateInstance = createModelInstance({ id: "model-instance-crate", assetId: asset.id, position: { x: 2, y: 0.5, z: 0 }, collision: { mode: "static", visible: true } }); const runtimeScene = buildRuntimeSceneFromDocument( { ...createEmptySceneDocument({ name: "Brush And Model Collision Scene" }), assets: { [asset.id]: asset }, brushes: { [floorBrush.id]: floorBrush }, modelInstances: { [crateInstance.id]: crateInstance } }, { loadedModelAssets: { [asset.id]: loadedAsset } } ); const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); try { const landing = collisionWorld.resolveFirstPersonMotion( { x: 0, y: 2, z: 0 }, { x: 0, y: -3, z: 0 }, runtimeScene.playerCollider ); expect(landing.grounded).toBe(true); expect(landing.feetPosition.y).toBeLessThan(0.02); const blocked = collisionWorld.resolveFirstPersonMotion( { x: 0, y: 0, z: 0 }, { x: 3, y: 0, z: 0 }, runtimeScene.playerCollider ); expect(blocked.feetPosition.x).toBeLessThan(1.21); expect(blocked.feetPosition.y).toBeLessThan(0.02); expect(blocked.collidedAxes.x).toBe(true); } finally { collisionWorld.dispose(); } }); it("blocks first-person motion against authored NPC colliders", async () => { const floorBrush = createBoxBrush({ id: "brush-floor-npc-collision", center: { x: 0, y: -0.5, z: 0 }, size: { x: 10, y: 1, z: 10 } }); const npc = createNpcEntity({ id: "entity-npc-guard", actorId: "actor-gate-guard", position: { x: 2, y: 0, z: 0 }, collider: { mode: "box", eyeHeight: 1.6, boxSize: { x: 1, y: 1.8, z: 1 } } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "NPC Collision Scene" }), brushes: { [floorBrush.id]: floorBrush }, entities: { [npc.id]: npc } }); const collisionWorld = await RapierCollisionWorld.create( runtimeScene.colliders, runtimeScene.playerCollider ); try { const blocked = collisionWorld.resolveFirstPersonMotion( { x: 0, y: 0, z: 0 }, { x: 3, y: 0, z: 0 }, runtimeScene.playerCollider ); expect(blocked.feetPosition.x).toBeLessThan(1.21); expect(blocked.feetPosition.y).toBeLessThan(0.02); expect(blocked.collidedAxes.x).toBe(true); } finally { collisionWorld.dispose(); } }); it("initializes and resolves first-person motion against terrain heightfield colliders", async () => { const terrainGeometry = new PlaneGeometry(8, 8, 4, 4); terrainGeometry.rotateX(-Math.PI / 2); const positionAttribute = terrainGeometry.getAttribute("position"); for (let index = 0; index < positionAttribute.count; index += 1) { const x = positionAttribute.getX(index); const z = positionAttribute.getZ(index); positionAttribute.setY(index, 2 + x * 0.25 + z * 0.75); } positionAttribute.needsUpdate = true; terrainGeometry.computeVertexNormals(); const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-terrain", terrainGeometry); const terrainInstance = createModelInstance({ id: "model-instance-terrain", assetId: asset.id, position: { x: 0, y: 0, z: 0 }, collision: { mode: "terrain", visible: true } }); const runtimeScene = buildRuntimeSceneFromDocument( { ...createEmptySceneDocument({ name: "Terrain Collision Scene" }), assets: { [asset.id]: asset }, modelInstances: { [terrainInstance.id]: terrainInstance } }, { loadedModelAssets: { [asset.id]: loadedAsset } } ); const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); try { const highLanding = collisionWorld.resolveFirstPersonMotion( { x: -2, y: 6, z: 2 }, { x: 0, y: -8, z: 0 }, runtimeScene.playerCollider ); const lowLanding = collisionWorld.resolveFirstPersonMotion( { x: 2, y: 6, z: -2 }, { x: 0, y: -8, z: 0 }, runtimeScene.playerCollider ); expect(highLanding.grounded).toBe(true); expect(highLanding.feetPosition.y).toBeGreaterThan(2.9); expect(highLanding.feetPosition.y).toBeLessThan(3.1); expect(lowLanding.grounded).toBe(true); expect(lowLanding.feetPosition.y).toBeGreaterThan(0.9); expect(lowLanding.feetPosition.y).toBeLessThan(1.1); const traversed = collisionWorld.resolveFirstPersonMotion( highLanding.feetPosition, { x: -1, y: 0, z: 0 }, runtimeScene.playerCollider ); expect(traversed.feetPosition.x).toBeLessThan(-2.5); expect(traversed.collidedAxes.x).toBe(false); } finally { collisionWorld.dispose(); } }); it("blocks motion against static-simple boxified wall colliders derived from open meshes", async () => { const floorBrush = createBoxBrush({ id: "brush-floor-static-simple-wall", center: { x: 0, y: -0.5, z: 0 }, size: { x: 10, y: 1, z: 10 } }); const wallGeometry = new PlaneGeometry(4, 4, 4, 4); wallGeometry.rotateY(Math.PI * 0.5); const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-model-static-simple-wall", wallGeometry); const wallInstance = createModelInstance({ id: "model-instance-static-simple-wall", assetId: asset.id, position: { x: 2, y: 2, z: 0 }, collision: { mode: "static-simple", visible: true } }); const runtimeScene = buildRuntimeSceneFromDocument( { ...createEmptySceneDocument({ name: "Static Simple Wall Collision Scene" }), assets: { [asset.id]: asset }, brushes: { [floorBrush.id]: floorBrush }, modelInstances: { [wallInstance.id]: wallInstance } }, { loadedModelAssets: { [asset.id]: loadedAsset } } ); const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); try { const blocked = collisionWorld.resolveFirstPersonMotion( { x: 0, y: 0, z: 0 }, { x: 3, y: 0, z: 0 }, runtimeScene.playerCollider ); expect(blocked.collidedAxes.x).toBe(true); expect(blocked.feetPosition.x).toBeLessThan(1.8); expect(blocked.feetPosition.y).toBeLessThan(0.02); } finally { collisionWorld.dispose(); } }); it("resolves motion against freely rotated whitebox box colliders", async () => { const floorBrush = createBoxBrush({ id: "brush-floor-rotated-wall", center: { x: 0, y: -0.5, z: 0 }, size: { x: 10, y: 1, z: 10 } }); const wallBrush = createBoxBrush({ id: "brush-wall-rotated", center: { x: 1.2, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 45, z: 0 }, size: { x: 0.4, y: 2, z: 4 } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Rotated Brush Collision Scene" }), brushes: { [floorBrush.id]: floorBrush, [wallBrush.id]: wallBrush } }); const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); try { const blocked = collisionWorld.resolveFirstPersonMotion( { x: 0, y: 0, z: 0 }, { x: 2, y: 0, z: 0 }, runtimeScene.playerCollider ); expect(blocked.collidedAxes.x).toBe(true); expect(blocked.feetPosition.x).toBeLessThan(1.3); expect(blocked.feetPosition.z).toBeGreaterThan(0.25); expect(blocked.feetPosition.z).toBeLessThan(1.25); } finally { collisionWorld.dispose(); } }); it("uses the authored Player Start box collider in the Rapier motion path", async () => { const floorBrush = createBoxBrush({ id: "brush-floor-box-player", center: { x: 0, y: -0.5, z: 0 }, size: { x: 10, y: 1, z: 10 } }); const wallBrush = createBoxBrush({ id: "brush-wall-box-player", center: { x: 1.2, y: 1, z: 0 }, size: { x: 0.4, y: 2, z: 4 } }); const playerStart = createPlayerStartEntity({ id: "entity-player-start-box", collider: { mode: "box", eyeHeight: 1.4, capsuleRadius: 0.3, capsuleHeight: 1.8, boxSize: { x: 0.8, y: 1.6, z: 0.8 } } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Box Player Collider Scene" }), brushes: { [floorBrush.id]: floorBrush, [wallBrush.id]: wallBrush }, entities: { [playerStart.id]: playerStart } }); const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); try { const landing = collisionWorld.resolveFirstPersonMotion( { x: 0, y: 2, z: 0 }, { x: 0, y: -3, z: 0 }, runtimeScene.playerCollider ); expect(landing.grounded).toBe(true); expect(landing.feetPosition.y).toBeLessThan(0.02); const blocked = collisionWorld.resolveFirstPersonMotion( landing.feetPosition, { x: 2, y: 0, z: 0 }, runtimeScene.playerCollider ); expect(blocked.collidedAxes.x).toBe(true); expect(blocked.feetPosition.x).toBeLessThan(0.61); } finally { collisionWorld.dispose(); } }); it("does not climb a wall when repeatedly walking into it from the ground", async () => { const floorBrush = createBoxBrush({ id: "brush-floor-repeat-wall-walk", center: { x: 0, y: -0.5, z: 0 }, size: { x: 10, y: 1, z: 10 } }); const wallBrush = createBoxBrush({ id: "brush-repeat-wall-walk", center: { x: 1.2, y: 1, z: 0 }, size: { x: 0.4, y: 2, z: 4 } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Repeat Wall Walk Scene" }), brushes: { [floorBrush.id]: floorBrush, [wallBrush.id]: wallBrush } }); const collisionWorld = await RapierCollisionWorld.create( runtimeScene.colliders, runtimeScene.playerCollider ); try { let feetPosition = { x: 0, y: 0, z: 0 }; for (let index = 0; index < 12; index += 1) { const moved = collisionWorld.resolveFirstPersonMotion( feetPosition, { x: 0.3, y: 0, z: 0 }, runtimeScene.playerCollider ); feetPosition = moved.feetPosition; } expect(feetPosition.y).toBeLessThan(0.05); expect(feetPosition.x).toBeLessThan(0.75); } finally { collisionWorld.dispose(); } }); it("does not autostep onto a ledge while airborne without floor support", async () => { const floorBrush = createBoxBrush({ id: "brush-floor-airborne-ledge", center: { x: 0, y: -0.5, z: 0 }, size: { x: 10, y: 1, z: 10 } }); const ledgeBrush = createBoxBrush({ id: "brush-airborne-ledge", center: { x: 1.2, y: 0.35, z: 0 }, size: { x: 0.4, y: 0.7, z: 4 } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Airborne Ledge Collision Scene" }), brushes: { [floorBrush.id]: floorBrush, [ledgeBrush.id]: ledgeBrush } }); const collisionWorld = await RapierCollisionWorld.create( runtimeScene.colliders, runtimeScene.playerCollider ); try { const airborne = collisionWorld.resolveFirstPersonMotion( { x: 0, y: 0.45, z: 0 }, { x: 2, y: -0.05, z: 0 }, runtimeScene.playerCollider ); expect(airborne.grounded).toBe(false); expect(airborne.feetPosition.y).toBeLessThan(0.55); expect(airborne.feetPosition.x).toBeLessThan(0.95); expect(airborne.collidedAxes.x).toBe(true); } finally { collisionWorld.dispose(); } }); it("keeps falling smoothly when airborne input pushes into a wall", async () => { const floorBrush = createBoxBrush({ id: "brush-floor-airborne-wall-fall", center: { x: 0, y: -0.5, z: 0 }, size: { x: 10, y: 1, z: 10 } }); const wallBrush = createBoxBrush({ id: "brush-airborne-fall-wall", center: { x: 1.2, y: 1, z: 0 }, size: { x: 0.4, y: 2, z: 4 } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Airborne Wall Fall Scene" }), brushes: { [floorBrush.id]: floorBrush, [wallBrush.id]: wallBrush } }); const collisionWorld = await RapierCollisionWorld.create( runtimeScene.colliders, runtimeScene.playerCollider ); try { const start = { x: 0.2, y: 1.2, z: 0 }; const fallingOnly = collisionWorld.resolveFirstPersonMotion( start, { x: 0, y: -0.2, z: 0 }, runtimeScene.playerCollider ); const againstWall = collisionWorld.resolveFirstPersonMotion( start, { x: 0.6, y: -0.2, z: 0 }, runtimeScene.playerCollider ); expect(againstWall.collidedAxes.x).toBe(true); expect(againstWall.feetPosition.y).toBeCloseTo(fallingOnly.feetPosition.y, 5); expect(againstWall.feetPosition.y).toBeLessThan(start.y - 0.15); } finally { collisionWorld.dispose(); } }); it("autosteps up authored stairs when the top surface is within the max step height", async () => { const floorBrush = createBoxBrush({ id: "brush-floor-stair-climb", center: { x: 0, y: -0.5, z: 0 }, size: { x: 8, y: 1, z: 10 } }); const stepOne = createBoxBrush({ id: "brush-stair-step-one", center: { x: 0, y: 0.1, z: 0.9 }, size: { x: 2, y: 0.2, z: 0.8 } }); const stepTwo = createBoxBrush({ id: "brush-stair-step-two", center: { x: 0, y: 0.2, z: 1.7 }, size: { x: 2, y: 0.4, z: 0.8 } }); const stepThree = createBoxBrush({ id: "brush-stair-step-three", center: { x: 0, y: 0.3, z: 2.5 }, size: { x: 2, y: 0.6, z: 0.8 } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Stair Climb Scene" }), brushes: { [floorBrush.id]: floorBrush, [stepOne.id]: stepOne, [stepTwo.id]: stepTwo, [stepThree.id]: stepThree } }); const collisionWorld = await RapierCollisionWorld.create( runtimeScene.colliders, runtimeScene.playerCollider, { maxStepHeight: 0.25 } ); try { let feetPosition = { x: 0, y: 0, z: -0.4 }; for (let index = 0; index < 32; index += 1) { feetPosition = collisionWorld.resolveFirstPersonMotion( feetPosition, { x: 0, y: 0, z: 0.1 }, runtimeScene.playerCollider ).feetPosition; } expect(feetPosition.z).toBeGreaterThan(2.2); expect(feetPosition.y).toBeGreaterThan(0.45); expect(feetPosition.y).toBeLessThan(0.62); } finally { collisionWorld.dispose(); } }); it("supports authored Player Start collision mode none without world clipping", async () => { const wallBrush = createBoxBrush({ id: "brush-wall-no-collision", center: { x: 0.5, y: 1, z: 0 }, size: { x: 0.4, y: 2, z: 4 } }); const playerStart = createPlayerStartEntity({ id: "entity-player-start-none", collider: { mode: "none", eyeHeight: 1.6, capsuleRadius: 0.3, capsuleHeight: 1.8, boxSize: { x: 0.6, y: 1.8, z: 0.6 } } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "No Collision Player Scene" }), brushes: { [wallBrush.id]: wallBrush }, entities: { [playerStart.id]: playerStart } }); const collisionWorld = await RapierCollisionWorld.create(runtimeScene.colliders, runtimeScene.playerCollider); try { const moved = collisionWorld.resolveFirstPersonMotion( { x: 0, y: 0, z: 0 }, { x: 2, y: 0, z: 0 }, runtimeScene.playerCollider ); expect(moved.collidedAxes.x).toBe(false); expect(moved.feetPosition.x).toBe(2); expect(moved.feetPosition.y).toBe(0); } finally { collisionWorld.dispose(); } }); it("treats only gentle ramps as walkable ground", async () => { const walkableSlopeAngle = Math.PI / 6; const steepSlopeAngle = Math.PI / 3; const walkableGeometry = new PlaneGeometry(8, 8, 1, 1); const steepGeometry = new PlaneGeometry(8, 8, 1, 1); walkableGeometry.rotateX(-Math.PI / 2 - walkableSlopeAngle); steepGeometry.rotateX(-Math.PI / 2 - steepSlopeAngle); const walkableAssetFixture = createFixtureLoadedModelAssetFromGeometry( "asset-model-walkable-slope", walkableGeometry ); const steepAssetFixture = createFixtureLoadedModelAssetFromGeometry( "asset-model-steep-slope", steepGeometry ); const walkableSlope = createModelInstance({ id: "model-instance-walkable-slope", assetId: walkableAssetFixture.asset.id, position: { x: -6, y: 4 * Math.sin(walkableSlopeAngle), z: 4 * Math.cos(walkableSlopeAngle) }, collision: { mode: "static", visible: true } }); const steepSlope = createModelInstance({ id: "model-instance-steep-slope", assetId: steepAssetFixture.asset.id, position: { x: 6, y: 4 * Math.sin(steepSlopeAngle), z: 4 * Math.cos(steepSlopeAngle) }, collision: { mode: "static", visible: true } }); const runtimeScene = buildRuntimeSceneFromDocument( { ...createEmptySceneDocument({ name: "Slope Walkability Scene" }), assets: { [walkableAssetFixture.asset.id]: walkableAssetFixture.asset, [steepAssetFixture.asset.id]: steepAssetFixture.asset }, modelInstances: { [walkableSlope.id]: walkableSlope, [steepSlope.id]: steepSlope } }, { loadedModelAssets: { [walkableAssetFixture.asset.id]: walkableAssetFixture.loadedAsset, [steepAssetFixture.asset.id]: steepAssetFixture.loadedAsset } } ); const collisionWorld = await RapierCollisionWorld.create( runtimeScene.colliders, runtimeScene.playerCollider ); try { const walkableProbe = collisionWorld.probePlayerGround( { x: -6, y: Math.tan(walkableSlopeAngle) * 2 + 0.05, z: 2 }, runtimeScene.playerCollider, 0.2 ); const steepProbe = collisionWorld.probePlayerGround( { x: 6, y: Math.tan(steepSlopeAngle) * 2 + 0.05, z: 2 }, runtimeScene.playerCollider, 0.2 ); expect(walkableProbe.grounded).toBe(true); expect(walkableProbe.slopeDegrees ?? 0).toBeGreaterThan(25); expect(walkableProbe.slopeDegrees ?? 0).toBeLessThan(35); expect(steepProbe.grounded).toBe(false); expect(steepProbe.slopeDegrees ?? 0).toBeGreaterThan(55); expect(steepProbe.slopeDegrees ?? 0).toBeLessThan(65); } finally { collisionWorld.dispose(); } }); it("initializes and resolves first-person motion against authored terrain colliders", async () => { const terrain = createTerrain({ id: "terrain-authored-collision", position: { x: -4, y: 0, z: -4 }, sampleCountX: 5, sampleCountZ: 5, cellSize: 2, heights: [ 0, 0.25, 0.5, 0.75, 1, 0.75, 1, 1.25, 1.5, 1.75, 1.5, 1.75, 2, 2.25, 2.5, 2.25, 2.5, 2.75, 3, 3.25, 3, 3.25, 3.5, 3.75, 4 ] }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Authored Terrain Collision Scene" }), terrains: { [terrain.id]: terrain } }); const collisionWorld = await RapierCollisionWorld.create( runtimeScene.colliders, runtimeScene.playerCollider ); try { const highLanding = collisionWorld.resolveFirstPersonMotion( { x: 4, y: 8, z: 4 }, { x: 0, y: -10, z: 0 }, runtimeScene.playerCollider ); const lowLanding = collisionWorld.resolveFirstPersonMotion( { x: -2, y: 8, z: -2 }, { x: 0, y: -10, z: 0 }, runtimeScene.playerCollider ); expect(highLanding.grounded).toBe(true); expect(highLanding.feetPosition.y).toBeGreaterThan(3.9); expect(highLanding.feetPosition.y).toBeLessThan(4.1); expect(lowLanding.grounded).toBe(true); expect(lowLanding.feetPosition.y).toBeGreaterThan(1.0); expect(lowLanding.feetPosition.y).toBeLessThan(1.2); } finally { collisionWorld.dispose(); } }); });