Following up on my recent 'discovery' of the Rapier.rs physics engine I make the first attempt at a character controller.
Links:
Rigid-body choices for a character controller in Rapier.rs
Except for Static
all other body types seem viable to make a controller, namely:
KinematicPositionBased
KinematicVelocityBased
Dynamic
Kinematic bodies allow us to set their Position and Velocity, so at a first glance, it sounds like they'd make a good controller. Unfortunately, they come with a few caveats, making them harder to use than you'd think. The biggest drawback for a quick and easy character controller is the fact that they don't interact with static bodies out of the gate and will clip through them. Not great if we want our characters to stick to walls and platforms. Rapier provides us with a lot of options to handle this drawback. Scene queries and hooks are quite robust, allowing the user to implement custom collision logic, but it's not something I want to get into before learning a bit more about the engine.
The last remaining choice, Dynamic
is a fully-fledged body that interacts with the entire world.
Setup
To not make this article unnecessarily long, I will skip the world and renderer setup and instead link the Github repo for the project. It should be easy enough to follow and you're always welcome to hit me up with any questions you might have.
Before proceeding with character controller I setup:
- rapier.rs physics world with gravity
{x: 0, y: 0}
- for the topdown experience - add walls to browser window bounds
- spawn Dynamic objects for our character to interact with later, in this case, 100 randomly sized balls
- render walls and balls with simple pixi.js graphics
Step by step
Steps to implement a simple keyboard and point to click controller:
Player body setup
- Create a player physics body and place it in the middle of the screen with
setTranslation
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.newDynamic().setTranslation(
window.innerWidth / 2,
window.innerHeight / 2
)
);
- Make a collider description so the body has shape and size. It needs it to interact with the world. For this example, we're going with a simple circle. Translation in this step describes the collider's relative position to the body.
const colliderDesc = new RAPIER.ColliderDesc(
new RAPIER.Ball(12)
).setTranslation(0, 0);
- Create a collider, attach it to the body and add the whole thing to the world.
const collider = world.createCollider(colliderDesc, body.handle);
Keyboard WASD control bindings
In later steps, we will move the player's body based on the provided direction. To get that we're going to set up a basic WASD control scheme with listeners listening to keydown
and keyup
. They will manipulate a direction vector:
const direction = {
x: 0,
y: 0,
};
When the key is pressed down, player begins to move:
window.addEventListener("keydown", (e) => {
switch (e.key) {
case "w": {
direction.y = -1;
break;
}
case "s": {
direction.y = 1;
break;
}
case "a": {
direction.x = -1;
break;
}
case "d": {
direction.x = 1;
break;
}
}
});
Then, when the key is released, the movement on that particular axis (x or y) is set to 0.
window.addEventListener("keyup", (e) => {
switch (e.key) {
case "w": {
direction.y = 0;
break;
}
case "s": {
direction.y = 0;
break;
}
case "a": {
direction.x = 0;
break;
}
case "d": {
direction.x = 0;
break;
}
}
});
Moving the body
Now that we've made a way for us to input where the player has to go, it's time to make it happen. We will create an updatePlayer
function that will have to be called every frame.
The most basic approach is as simple as the snippet below, we simply set the body's velocity to the direction
.
const updatePlayer = () => {
body.setLinvel(direction, true);
};
You might notice though, that the body isn't moving much. That's because we only set the direction vector to go from -1 to 1, and that isn't very fast. To combat that and make the code more reusable we add a MOVE_SPEED
variable and multiply the x and y of the direction.
const MOVE_SPEED = 80;
const updatePlayer = () => {
body.setLinvel(
{ x: direction.x * MOVE_SPEED, y: direction.y * MOVE_SPEED },
true
);
};
That's more like it!
Bonus method: Applying force to move the body
When I was playing around and writing this article I found another cool way to make our player's body move. Instead of setting the velocity directly, we "push" the body to make it go in the desired direction at the desired speed. It gives a smoother, more natural feeling movement right out of the gate.
The whole thing is just these few lines of code but it's a little more complicated than the previous example.
The concept is simple. We apply impulse in order to make the body move, but what if it starts going too fast or we want to stop?
We check the body's current velocity with const velocity = body.linvel();
.Then, to determine what impulse should be applied next, we take the difference of the desired and current velocity for both axis direction.x * MOVE_SPEED - velocity.x
. If the body is moving too fast or in the wrong direction, a counteracting impulse is applied. We multiply it by ACCELERATION
constant to.. drumroll - make the body accelerate faster or slower.
const MOVE_SPEED = 80;
const ACCELERATION = 40;
const velocity = body.linvel();
const impulse = {
x: (direction.x * MOVE_SPEED - velocity.x) * ACCELERATION,
y: (direction.y * MOVE_SPEED - velocity.y) * ACCELERATION,
};
body.applyImpulse(impulse, true);
You can achieve a similar effect by using the velocity method and applying some form of easing.
Note: For simplicity, I use VELOCITY
and ACCELERATION
in relation to one value of the vector. So velocity with the value of 2
would look like this: {x: 2, y: 2}
, where in reality velocity is almost always the length of such vector - const velocity = Math.sqrt(2**2 + 2**2)
resulting in velocity of ~2.83!. This means that if we used my implementation in a game, moving diagonally would be 40% faster than going up and down!
TLDR; Use correct velocity, calculated for example with Pythagorem's theorem.
Top comments (0)