In order to answer this, you need to know: Is my character on the ground?
The question "how can I tell if my player character is on the ground?" is one of the most common Box2D questions. This comes up frequently due to the callback-style method of getting contact information and because tracking object contacts is left to the programmer rather than built into the library.
In this tutorial we'll apply the tricks we learned in the collision callbacks and sensors topics to a character for a platform game.
Implementation Overview
We'll create a player body with:
- A main shape representing the player
- A smaller sensor shape underneath to detect ground contact
- A tracking system for shapes the foot sensor is touching
The foot sensor will determine jumpable situations based on contact with other shapes.
Code Implementation
Create bodies to jump around on:
// Add some bodies to jump around on
{
// Body definition
const bodyDef = b2DefaultBodyDef();
bodyDef.type = DYNAMIC;
bodyDef.position = new b2Vec2(-5, 5);
// Polygon
const boxShape = b2MakeBox(1, 1);
// Shape definition with density
const shapeDef = b2DefaultShapeDef();
shapeDef.density = 1;
// Create multiple boxes
for (let i = 0; i < 5; i++) {
const bodyId = b2CreateBody(worldId, bodyDef);
const shapeId = b2CreatePolygonShape(bodyId, shapeDef, boxShape);
b2Shape_SetUserData(shapeId, 1); // tag square boxes as 1
}
// Smaller boxes
boxShape = b2MakeBox(0.5, 1); // a 1x2 rectangle
for (let i = 0; i < 5; i++) {
const bodyId = b2CreateBody(worldId, bodyDef);
const shapeId = b2CreatePolygonShape(bodyId, shapeDef, boxShape);
b2Shape_SetUserData(shapeId, 2); // tag smaller rectangular boxes as 2
}
}
Create the player body with a 'foot' sensor:
// Player body
{
// Body definition
const bodyDef = new b2DefaultBodyDef();
bodyDef.type = DYNAMIC;
bodyDef.position = new b2Vec2(0, 10);
bodyDef.fixedRotation = true;
// Create dynamic body
const playerId = b2CreateBody(worldId, bodyDef);
// Main polygon definition
const mainShape = b2MakeBox(1, 2); // a 2x4 rectangle
// Shape definition with density
const shapeDef = b2DefaultShapeDef();
shapeDef.density = 1;
// Add main shape
b2CreatePolygonShape(playerId, shapeDef, mainShape);
// Add foot sensor
const sensorShape = b2MakeOffsetBox(0.3, 0.3, new b2Vec2(0, -2), 0);
shapeDef.isSensor = true;
const sensorId = b2CreatePolygonShape(playerId, shapeDef, sensorShape);
b2Shape_SetUserData(sensorId, 3);
}
NOTE: it's not necessary for this foot sensor to actually be a sensor, and it doesn't need to exist at all in order to get collision events, but it provides some advantages.
- Firstly, since we know it's under the player, we know a collision with it means the player is standing on something. If we just used the main body to get collision events, those events could be from collisions with the walls or ceiling too, so we would have to check the direction before we could say it was a ground collision.
- Secondly, as the player moves around, especially on slopes the main body tends to bounce slightly on the ground which causes a large number of begin/end events to occur in quick succession. If we were using the main body to determine jumpability, it's possible that the user could try to jump just when the main body is off the ground for a few milliseconds, even though it appears to be on the ground, and that would just be annoying huh? Using a sensor like this will cause a smoother and more reliable contact detection because the sensor will stay in contact with the ground during the tiny bounces.
- Thirdly, having the foot sensor and the main body as separate shapes means they can be given individual shapes as the situation requires. For example, in this case the foot sensor is much narrower than the main body meaning you wont be able to jump when you are teetering on the edge of something, but this could easily be adjusted by changing the size, shape or location of the sensor or the player body.
Contact handling implementation:
// Set up contact callbacks
const shapesUnderfoot = new Set();
const handleBeginContact = (contact) => {
const shapeIdA = contact.shapeIdA;
const shapeIdB = contact.shapeIdB;
// Check if shape A was the foot sensor
if (b2Shape_GetUserData(shapeIdA) === 3) {
shapesUnderfoot.add(shapeIdB);
}
// Check if shape B was the foot sensor
if (b2Shape_GetUserData(shapeIdB) === 3) {
shapesUnderfoot.add(shapeIdA);
}
};
const handleEndContact = (contact) => {
const shapeIdA = contact.shapeIdA;
const shapeIdB = contact.shapeIdB;
// Check if shape A was the foot sensor
if (b2Shape_GetUserData(shapeIdA) === 3) {
shapesUnderfoot.delete(shapeIdB);
}
// Check if shape B was the foot sensor
if (b2Shape_GetUserData(shapeIdB) === 3) {
shapesUnderfoot.delete(shapeIdA);
}
};
Jump capability check function:
We don't want to jump again immediately after jumping (before the sensor has cleared the ground), a timer is the easiest way to prevent this. We also want to check what the sensor is colliding with, and if it is suitable, we can jump!
const canJumpNow = () => {
if (jumpTimeout > 0) return false;
for (const shape of shapesUnderfoot) {
const userDataTag = b2Shape_GetUserData(shape);
if (userDataTag === 0 || userDataTag === 1) { // large box or static ground
return true;
}
}
return false;
};
Applying jump forces with environment reaction:
As one last little demonstration, let's give the currently stood on boxes a kick downwards when the player jumps. These interactive elements add interest to a game world, and can sometimes lead to emergent gameplay: Imagine a puzzle where you have to climb to a high platform, the only box is jammed into a corner where you cannot push it... maybe you can jump on it a few times to bounce it out of the corner?
// Kick player body upwards
const mass = b2Body_GetMass(playerId);
const jumpImpulse = new b2Vec2(0, mass * 10);
const worldCenter = b2Body_GetWorldCenterOfMass(playerId);
b2Body_ApplyLinearImpulseToCenter(playerId, jumpImpulse, true);
jumpTimeout = 15;
// Kick the underfoot boxes downwards
const localImpulsePoint = new b2Vec2(0, -2);
const worldImpulsePoint = b2Body_GetWorldPoint(playerId, localImpulsePoint);
const negativeJumpImpulse = new b2Vec2(-jumpImpulse.x, -jumpImpulse.y);
for (const shape of shapesUnderfoot) {
const shapeBody = b2Shape_GetBody(shape);
b2Body_ApplyLinearImpulse(shapeBody, negativeJumpImpulse, worldImpulsePoint, true);
}
Credits
This tutorial is adapted from an original piece of work created by Chris Campbell and is used under license.