import RAPIER from "@dimforge/rapier3d-compat"; import { beforeAll, describe, expect, it } from "vitest"; import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "../game/playerMovement"; import { isWalkableGroundHit } from "../game/playerJump"; import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision"; import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight"; import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "./partyTraversal"; const BODY_TYPES = ["fixed", "kinematic", "dynamic"] as const; type BodyType = typeof BODY_TYPES[number]; beforeAll(async () => { await RAPIER.init(); }); function actor(world: RAPIER.World, bodyType: BodyType, z = 0, groups = ACTOR_COLLISION_GROUPS) { const description = bodyType === "fixed" ? RAPIER.RigidBodyDesc.fixed() : bodyType === "kinematic" ? RAPIER.RigidBodyDesc.kinematicPositionBased() : RAPIER.RigidBodyDesc.dynamic(); const body = world.createRigidBody(description.setTranslation(0, 0.84, z)); const collider = world.createCollider( RAPIER.ColliderDesc.capsule(0.48, 0.36) .setCollisionGroups(groups) .setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL), body, ); return { body, collider }; } function terrain(world: RAPIER.World, wall = false) { const floor = world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 8) .setTranslation(0, -0.1, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS)); if (wall) world.createCollider(RAPIER.ColliderDesc.cuboid(5, 2, 0.1) .setTranslation(0, 2, 2.5).setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS)); return floor; } describe("non-blocking combat actors", () => { it.each(BODY_TYPES)("has no physical contacts with overlapping %s actors", (bodyType) => { const world = new RAPIER.World({ x: 0, y: 0, z: 0 }); const player = actor(world, "dynamic"); // Party members, mobs, bosses, and remote players use the same actor policy. const others = ["party", "mob", "boss", "remote-player"].map(() => actor(world, bodyType)); world.step(); for (const other of others) { world.contactPair(player.collider, other.collider, () => { throw new Error("Actors must not produce physical contacts"); }); expect(other.body.translation().z).toBeCloseTo(0); } expect(player.body.translation().z).toBeCloseTo(0); world.free(); }); it("detects contacts when an actor mistakenly uses the terrain layer", () => { const world = new RAPIER.World({ x: 0, y: 0, z: 0 }); const player = actor(world, "dynamic"); const obstacle = actor(world, "fixed", 0.4, DUNGEON_PHYSICAL_COLLISION_GROUPS); world.step(); let contacts = 0; world.contactPair(player.collider, obstacle.collider, (manifold) => { contacts += manifold.numContacts(); }); expect(contacts).toBeGreaterThan(0); world.free(); }); it.each(BODY_TYPES)("walks through %s actors but still stops at a dungeon wall", (bodyType) => { for (const hasWall of [false, true]) { const world = new RAPIER.World({ x: 0, y: 0, z: 0 }); terrain(world, hasWall); actor(world, bodyType); actor(world, bodyType, -2); // Start overlapped: no depenetration or detour. const player = actor(world, "kinematic", -2); const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET)); world.step(); for (let frame = 0; frame < 90; frame += 1) { controller.computeColliderMovement(player.collider, { x: 0, y: -0.5 / 60, z: 4.8 / 60 }, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS); const position = player.body.translation(); const movement = controller.computedMovement(); player.body.setNextKinematicTranslation({ x: position.x + movement.x, y: position.y + movement.y, z: position.z + movement.z }); world.step(); } const position = player.body.translation(); if (hasWall) { expect(position.z).toBeGreaterThan(1.8); expect(position.z).toBeLessThan(2.1); } else expect(position.z).toBeGreaterThan(5); expect(position.x).toBeCloseTo(0); expect(position.y).toBeCloseTo(0.84, 1); world.removeCharacterController(controller); world.free(); } }); it.each(BODY_TYPES)("never treats a %s actor as ground, a route obstacle, or a camera wall", (bodyType) => { const world = new RAPIER.World({ x: 0, y: 0, z: 0 }); const floor = terrain(world); actor(world, bodyType); world.step(); const ground = world.castRayAndGetNormal(new RAPIER.Ray({ x: 0, y: 4, z: 0 }, { x: 0, y: -1, z: 0 }), 5, true, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS); expect(ground?.collider.handle).toBe(floor.handle); expect(ground?.timeOfImpact).toBeCloseTo(4); expect(isWalkableGroundHit(ground, 5)).toBe(true); expect(createPartyDirectSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true); expect(createPartyRouteSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true); expect(world.castShape({ x: 0, y: 0.9, z: -1.5 }, { x: 0, y: 0, z: 0, w: 1 }, { x: 0, y: 0, z: 1 }, new RAPIER.Ball(0.24), 0.08, 3, false, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS)).toBeNull(); world.free(); }); });