Making Characters Climb Sloped Tiles
After a long and tortuous journey I finally managed to get CozyPhysics engine to work with partial tiles. I felt I would not look at tile collisions for some time(!) but curiosity got the better of me and started to work on collisions with "true" sloping tiles.
...and despite the seemingly small step from partial (AABB) tiles, implementing sloping tiles and making characters move smoothly up and down said slopes, turned out to be considerably more complex than anticipated.
As with my other projects, this is a basic implementation, developed to test out my theory and there are known limitations, which are commented at the end of this post (note in particular that the demo code has tiles "floating" in mid-screen but the algorithm is not designed to deal with ceilings, hence you will be able to "jump through" sloping tiles from below).
The key challenges are:
how to detect where you are in inside slope
how to make character climb up, while the front "foot" is overlapping (see above picture) with the sloping tile
how do you know when the character should climb down, as opposed to fall down (look at the picture above, if character moves to the right, you want it to climb "down", but there is no overlap to the right to trigger a climb down process)
I will go through how I tackled each of the above.
Creation of slope tiles
The first thing we need to do is figure out how to encapsulate the slope information into individual tiles. For the partial AABB tiles, I stored the upper and lower gaps in the tile properties, when creating the tilemap in TILED. For a sloping tile, I need to be able to tell, at a specific x coordinate, how high the tile is. I can think of a variety of ways to do this.
The simplest to understand (and probably the most flexible) way would be to simply store the actual height value for each x-coordinate.
Another method is to store the height of the tile at the left most edge and the right most edge, in each tile. The height of a tile at a particular x-position can then be arrived at by calculating the slope based on that information, then applying it to the standard line formula - this method is described under "Slopes" in this fantastic article here (this article is well worth a read as it contains a lot of great information about 2D platform tilemaps).
My implementation, I have stored the 2 parameters of a straight line formula, namely:
b: y-intercept.
m: slope of the line
It is easier to understand by looking at one of the tiles I have created (they are 16x16 in size) for my example, below. For the tile highlighted, b=15, and m = -0.5 (this is all based on the y-axis pointing downwards). Then it is easy to calculate the height of the "n-th column from the left" within this tile by application of the line formular: y= b+mx.
Note also that my implementation assumes the "full" floor tile is the first one in the tileset, ie tile.index = 1.
Detecting where you are *inside* the sloping tile
In the partial tiles detection, I essentially followed a two phased approach. First, to check for overlapping tiles with the physics body by using Phaser's getTileAt type of method. If a tile is found to be overlapping, check for intersection with the "filled" part of the tile in the second phase.
With sloped tiles, I have used adopted the same approach, but the second phase detection is a bit more complicated involving the calculation of the height of the floor at a particular column within the tile identified in the first phase.
The method to detect how "embedded" your character is within the sloping tile is as follows. The method returns:
a positive value if the (x, y) is "embedded" into the slope,
a negative value if the (x,y) is "above" the slope (ie overlapping a sloping tile, but the y position is not intersecting with the slope),
zero if the x,y position is at the top of the top of the slope
-1 is the x, y position is one pixel above the top of the slope - this is where we want the character to end up, when "standing" on the slope
There are 3 situations that the method deals with (tileheight in the example code is 16 pixels):
RED: there is no tile (ie the tile.index = -1), the y-coordinate mod 16 is gives how deep into the tile the y-coordinate is, hence the returned value is minus of (16 - (y mod 16)).
BLUE: overlapping with a full tile, so return (y-coordinate mod 16)
GREEN: overlapping with sloping tile. assign y-coordinate of the top of the slope (absolute y-coordinate, not relative to the individual tile) to floor. Returned value is (y - floor).
overlap( x,y) {
const tile = this.colliderLayer.getTileAtWorldXY(x,y, true);
if (tile.index !== -1)
{
if (tile.index === 1)
{
// if collided with a full tile, it means y%tileheight is the amount of penetration
return (y % this.tileHeight);
}
const floor = tile.pixelY + tile.properties.b + Math.trunc(tile.properties.m * (x % this.tileWidth));
return (y - floor)
}
else
{
return -(this.tileHeight - (y % this.tileHeight));
}
}
Just a rant - I could not for a very long time figure out why the above was not working as expected until I swapped Math.floor with Math.trunc
Math.trunc is not the same as Math.floor !!!!!!!!!!
Collision Detection
There are 3 main methods involved in the collision detection:
processX: checks for collision in the horizontal direction
processY: checks for collision in the vertical direction
handleSlope: this is called from processY at the end, to deal with sloping tiles
ProcessX
The logic for much of the method is the same as the one developed for the partial tiles (the very early version which checks for points for collision as opposed to the edge).
The critical difference is the code bold highlighted line, which effectively skips the horizontal collision detection, if the body is overlapping a sloping tile. This is because if the body is overlapping a sloping tile, you do not want the collision to be resolved horizontally - rather you want the body's y-position to be adjusted up (if climbing up) or down (if climbing down).
processX(body) {
// check world boundary collision against the vertical walls
if (body.newPosition.x < this.bounds.x)
{
body.delta.x = this.bounds.x - body.position.x;
body.responseX();
}
else if (body.newPosition.x + body.width > this.bounds.right )
{
body.delta.x = (this.bounds.right - body.width) - body.position.x;
body.responseX();
}
// horizontal collision
const hitBoxSide= (body.delta.x > 0) ? body.newPosition.x + body.width-1 : body.newPosition.x;
const feet = this.colliderLayer.getTileAtWorldXY(body.center.x, body.bottom, true)
const tileToCheck = [
this.colliderLayer.getTileAtWorldXY(hitBoxSide, body.bottom),
this.colliderLayer.getTileAtWorldXY(hitBoxSide, body.top)
].filter(n=>n);
// are we going to be in a slope tile? ie tile with index>1 skip normal horizontal collision detection
// only do normal collision check if tile.index = 1 (full tile), or tile.index = -1 (empty)
if (feet.index <= 1) {
if (tileToCheck.some(e=>e.index === 1))
{
if (body.delta.x > 0)
{
body.delta.x = (Math.min(...tileToCheck.map(i=>i.pixelX)) - body.width) - body.position.x;
body.responseX();
}
else if (body.delta.x < 0)
{
body.delta.x = (Math.max(...tileToCheck.map(i=>i.right))) - body.position.x;
body.responseX();
}
}
}
}
ProcessY
Again, much of the code for ProcessY remains the same as the version developed for the partial tiles.
The critical differences are:
The basic horizontal collision detection is based on the bottom left and bottom right corners of the physics body. However, we check the centre of the bottom of the physics body and assign to feet. This is where the physics body will "stand" on top of the slope. By implication, either the left or right corner will be embedded with the slope tile.
If the tile overlapping with feet is not a full tile, the "normal" vertical collision detection is skipped.
if we have gotten to the black highlighted code at the end of the method, it means the physics body is overlapping with a sloped tile - call handleSlope() method.
processY(body) {
// check world boundary collision against the horizontal walls
if (body.newPosition.y < this.bounds.y )
{
body.delta.y = this.bounds.y - body.position.y;
body.responseY();
return
}
else if ((body.newPosition.y + body.height) > this.bounds.bottom)
{
body.delta.y = (this.bounds.bottom - body.height) - body.position.y;
body.reponseY();
return
}
//vertical collision
const hitBoxSide = (body.delta.y > 0) ? body.newPosition.y + body.height - 1: body.newPosition.y;
const feet = this.getTileAtWorldXY(body.center.x, body.newPosition.y + body.height-1, true)
const tileToCheck = [
this.colliderLayer.getTileAtWorldXY(body.left, hitBoxSide),
this.colliderLayer.getTileAtWorldXY(body.right, hitBoxSide)
].filter(n=>n);
// if solid tile do normal collision
if (feet.index <= 1)
{
if (tileToCheck.some(t=>t.index ===1))
{
if (body.delta.y > 0)
{
body.delta.y = (Math.min(...tileToCheck.map(i=>i.pixelY)) - body.height) - body.position.y;
body.responseY();
return
}
else
{
body.delta.y = Math.max(...tileToCheck.map(i=>i.bottom)) - body.position.y;
body.responseY();
return
}
}
}
this.handleSlope(body);
}
handleSlope
This method will only be called if the physics body is overlapping with a sloping tile. It starts with finding out how far the physics body is "embedded" in or floating above the sloping tile.
There are two main parts to this method:
BLUE: the body is embedded in the sloping tile, so need to push up the body by the depth of penetration
RED: the body is "floating" above the sloping tile, so need to adjust the y-coordinate of the body so it is flush against the top of the tile. The non-bold part of the RED code is simple enough. It is the same as the climb up part of the code (don't forget the overlap method returns a negative value if floating above the tile). The tricky part is the bold part. This handles the situation where the body is at the bottom of a sloping tile. Imagine the following situation where the body is moving right. In this situation, if the body is at the bottom of one sloping tile, which joins another sloping tile, you want the body to continue climbing down. However, at the very bottom of a sloping tile, that the centre of the bottom of the body is now not overlapping hence the next time the collision detection code is run, the body will be treated as if there is no tile, creating a little "hop". In order to avoid this, we check the next tile immediately below the body at the "adjusted" position, to see if there is another sloping tile. Then adjust the body as necessary.
handleSlope(body) {
let floordist = this.overlap(body.newPosition.x + body.halfWidth, body.newPosition.y + body.height-1);
// floor dist = 0 means bottom row is aligned with the top row of the tile, ie embeddded. anything positive means embedded further
if (floordist >= 0)
{ // CLIMB UP!
// ie we are inside a slope tile, so need to "raise" body up by the embedded amount
body.newPosition.y -= (floordist + 1);
body.delta.y -= (floordist +1)
floordist = -1;
body.responseY();
}
else
{
// CLIMB DOWN! If on floor in previous frame and not jumping
if (body.onFloor && body.delta.y >= 0)
{
body.newPosition.y -= (floordist+1);
body.delta.y -= (floordist + 1)
body.responseY();
// Check if the "next" tile is also a sloping tile. This is needed in order eliminate "hop" at tile joints
if (this.colliderLayer.getTileAtWorldXY(body.newPosition.x+body.halfWidth, body.newPosition.y + body.height, true).index>1)
{
const descent = Math.abs(this.overlap(body.newPosition.x+body.halfWidth, body.newPosition.y + body.height));
body.newPosition.y += descent
body.delta.y += descent
}
}
}
And that's pretty much all the meaty bits of the code.
Limitations
As mentioned at the beginning there are several limitations.
Motion of the physics body is pixel constrained (ie it cannot move by sub-pixel amounts). Therefore, even though we are using the line formula to calculate the height of the slope, gentler slopes will be treated more like "steps" and the physics body movement will not be smooth. This is not a bug.
The physics body is expected to be equal or smaller than individual tile, as much of the collision detection relies on checking the corners of the physics body (except for the slope check which checks the centre of the bottom)
The sample code is designed for 2D platforms in mind, not top-down
The code only looks a "floor" and does not look to the "ceiling".
The code assumes that a sloped down "joins" with a full floor tile, and does not end "half-way up". If you have slopes that finish half-way, you will find the physics body "pops" through to the top of the tile.
The code assumes "straight" line slopes, not curved slopes (although it would be relatively easy to amend the way the tile slope information is stored to deal with curved slopes)
You could of course create code to handle all of the above, but it would make it considerably more complex, and more often that not, it is simpler to design your platforms to suit the code rather than create code that will cope with difficult situations.
Sample Code
Useful references
http://higherorderfun.com/blog/2012/05/20/the-guide-to-implementing-2d-platformers/ (article about various methods of collision detection in 2D platformers in general)
https://games.greggman.com/game/programming_m_c__kids/ (interesting article about tilemap collisions in general)
https://www.youtube.com/watch?v=Yre-XAE8DBA&t=2393s (Tile based - GameMaker Studio based)
https://www.youtube.com/watch?v=Isvs9OzX6Lk&t=1018s (Tile based - Scratch)
https://www.youtube.com/watch?v=cwcC2tIKObU&t=8s (using polygons - Unity)
https://nikles.it/2019/gamemaker-tutorial/how-to-code-a-platformer-engine/ (GameMaker Studio)
https://www.emanueleferonato.com/2016/05/04/my-take-on-handling-slopes-with-phaser-and-arcade-physics/ (sprites and arcade physics - Phaser 3)
https://2dgames.jp/2d-platformer-programers-guide/ (in Japanese)
https://slideshowjp.com/doc/136418/2d%E5%BD%93%E3%81%9F%E3%82%8A%E5%88%A4%E5%AE%9A%E8%B6%85%E5%85%A5%E9%96%80 (in Japanese, mostly about vector based collisions)
http://marupeke296.com/COL_main.html (in Japanese)
Comments