import RAPIER from "@dimforge/rapier3d-compat"; import { NodeIO } from "@gltf-transform/core"; import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions"; import { MeshoptDecoder } from "meshoptimizer"; import { beforeAll, describe, expect, it } from "vitest"; import { PLAYER_GROUND_MIN_NORMAL_Y, PLAYER_GROUND_PROBE_DISTANCE, } from "./playerJump"; import { PLAYER_CHARACTER_CONTROLLER_OFFSET, PLAYER_GROUND_SNAP_DISTANCE, PLAYER_GROUND_STICK_VELOCITY, PLAYER_STEP_HEIGHT, PLAYER_STEP_MIN_WIDTH, airbornePlayerVerticalVelocity, configurePlayerCharacterController, playerFrameDeltaSeconds, } from "./playerMovement"; const PLAYER_RADIUS = 0.36; const PLAYER_CAPSULE_HALF_HEIGHT = 0.48; const PLAYER_CENTER_HEIGHT = PLAYER_RADIUS + PLAYER_CAPSULE_HALF_HEIGHT; const MOVE_SPEED = 4.8; const FRAME_SECONDS = 1 / 60; const STOCKADES_SPAWN = { x: -126.4629, y: -29.1512, z: 21.2946, forwardX: -0.2756962614591509, forwardZ: 0.9612448030639477, }; let stockadesCollision: { vertices: Float32Array; indices: Uint32Array }; beforeAll(async () => { await RAPIER.init(); const io = new NodeIO() .registerExtensions([EXTMeshoptCompression, KHRMeshQuantization]) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); const document = await io.read("public/assets/game/manastorm/34-stormwindjail/collision.glb"); const node = document.getRoot().listNodes().find((candidate) => candidate.getMesh()); const primitive = node?.getMesh()?.listPrimitives()[0]; const positions = primitive?.getAttribute("POSITION"); const sourceVertices = positions?.getArray(); const sourceIndices = primitive?.getIndices()?.getArray(); if (!node || !positions || !sourceVertices || !sourceIndices) { throw new Error("Stormwind Stockade collision GLB is missing its trimesh primitive."); } const translation = node.getWorldTranslation(); const scale = node.getWorldScale(); const normalization = positions.getNormalized() ? 32_767 : 1; const vertices = new Float32Array(sourceVertices.length); for (let offset = 0; offset < sourceVertices.length; offset += 3) { vertices[offset] = sourceVertices[offset] / normalization * scale[0] + translation[0]; vertices[offset + 1] = sourceVertices[offset + 1] / normalization * scale[1] + translation[1]; vertices[offset + 2] = sourceVertices[offset + 2] / normalization * scale[2] + translation[2]; } stockadesCollision = { vertices, indices: Uint32Array.from(sourceIndices), }; }); function walkIntoStep(stepHeight: number): { x: number; y: number; z: number } { const world = new RAPIER.World({ x: 0, y: -18, z: 0 }); const floor = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()); world.createCollider( RAPIER.ColliderDesc.cuboid(5, 0.1, 8).setTranslation(0, -0.1, 3), floor, ); const step = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()); world.createCollider( RAPIER.ColliderDesc.cuboid(2, stepHeight / 2, 5) .setTranslation(0, stepHeight / 2, 4.75), step, ); const body = world.createRigidBody( RAPIER.RigidBodyDesc.kinematicPositionBased() .setTranslation(0, PLAYER_CENTER_HEIGHT, -0.8), ); const collider = world.createCollider( RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS), body, ); world.step(); const controller = configurePlayerCharacterController( world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET), ); for (let frame = 0; frame < 50; frame += 1) { controller.computeColliderMovement( collider, { x: 0, y: PLAYER_GROUND_STICK_VELOCITY * FRAME_SECONDS, z: MOVE_SPEED * FRAME_SECONDS, }, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC, ); const translation = body.translation(); const movement = controller.computedMovement(); body.setNextKinematicTranslation({ x: translation.x + movement.x, y: translation.y + movement.y, z: translation.z + movement.z, }); world.step(); } const translation = body.translation(); const result = { x: translation.x, y: translation.y, z: translation.z }; world.removeCharacterController(controller); world.free(); return result; } describe("player movement", () => { it("configures bounded autostep and ground snapping", () => { const world = new RAPIER.World({ x: 0, y: -18, z: 0 }); const controller = configurePlayerCharacterController( world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET), ); expect(controller.autostepMaxHeight()).toBeCloseTo(PLAYER_STEP_HEIGHT); expect(controller.autostepMinWidth()).toBeCloseTo(PLAYER_STEP_MIN_WIDTH); expect(controller.autostepIncludesDynamicBodies()).toBe(false); expect(controller.snapToGroundDistance()).toBeCloseTo(PLAYER_GROUND_SNAP_DISTANCE); expect(controller.slideEnabled()).toBe(true); world.removeCharacterController(controller); world.free(); }); it("walks up an ordinary half-unit stair without jumping", () => { const translation = walkIntoStep(0.5); expect(translation.z).toBeGreaterThan(2.5); expect(translation.y).toBeGreaterThan(PLAYER_CENTER_HEIGHT + 0.45); }); it("does not autostep over a wall taller than the traversal limit", () => { const translation = walkIntoStep(0.8); expect(translation.z).toBeLessThan(-0.5); expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1); }); it("walks back up the real Stockades entrance stairs without a jump impulse", () => { const world = new RAPIER.World({ x: 0, y: -18, z: 0 }); const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()); world.createCollider( RAPIER.ColliderDesc.trimesh( stockadesCollision.vertices, stockadesCollision.indices, ), dungeonBody, ); const body = world.createRigidBody( RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation( STOCKADES_SPAWN.x, STOCKADES_SPAWN.y + PLAYER_CENTER_HEIGHT, STOCKADES_SPAWN.z, ), ); const collider = world.createCollider( RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS), body, ); world.step(); const controller = configurePlayerCharacterController( world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET), ); const groundRay = new RAPIER.Ray( { x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }, ); const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE; let verticalVelocity = 0; const walkFrame = (direction: 1 | -1) => { const translation = body.translation(); groundRay.origin.x = translation.x; groundRay.origin.y = translation.y; groundRay.origin.z = translation.z; const groundHit = world.castRayAndGetNormal( groundRay, groundProbeLength, true, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC, undefined, undefined, body, ); const groundedBeforeMove = verticalVelocity <= 0.5 && Boolean( groundHit && groundHit.timeOfImpact <= groundProbeLength && groundHit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y, ); verticalVelocity = groundedBeforeMove ? PLAYER_GROUND_STICK_VELOCITY : airbornePlayerVerticalVelocity(verticalVelocity, FRAME_SECONDS); controller.computeColliderMovement( collider, { x: direction * STOCKADES_SPAWN.forwardX * MOVE_SPEED * FRAME_SECONDS, y: verticalVelocity * FRAME_SECONDS, z: direction * STOCKADES_SPAWN.forwardZ * MOVE_SPEED * FRAME_SECONDS, }, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC, ); const movement = controller.computedMovement(); body.setNextKinematicTranslation({ x: translation.x + movement.x, y: translation.y + movement.y, z: translation.z + movement.z, }); if (controller.computedGrounded() && verticalVelocity <= 0) verticalVelocity = 0; world.step(); }; for (let frame = 0; frame < 190; frame += 1) walkFrame(1); const lowerFootHeight = body.translation().y - PLAYER_CENTER_HEIGHT; for (let frame = 0; frame < 240; frame += 1) walkFrame(-1); const returned = body.translation(); const returnedFootHeight = returned.y - PLAYER_CENTER_HEIGHT; expect(lowerFootHeight).toBeLessThan(-33); expect(returnedFootHeight).toBeGreaterThan(-30); expect(Math.hypot( returned.x - STOCKADES_SPAWN.x, returned.z - STOCKADES_SPAWN.z, )).toBeLessThan(3); world.removeCharacterController(controller); world.free(); }); it("clamps frame hitches and applies bounded airborne gravity", () => { expect(playerFrameDeltaSeconds(Number.NaN)).toBe(0); expect(playerFrameDeltaSeconds(1)).toBe(0.05); expect(airbornePlayerVerticalVelocity(0, 1)).toBeCloseTo(-0.9); expect(airbornePlayerVerticalVelocity(-100, FRAME_SECONDS)).toBe(-32); }); });