Demo
The demo shows the end result of the slope implementation. Use WASD to move the character. Right mouse button creates a tile. You can use the scroll wheel or the arrow keys to select a tile you want to place. The sliders change the size of the player's character.
The demo has been published under Unity 5.5.2f1, and the source code is also compatible with this version of Unity.
Slopes
Slopes add a lot of versatility to a game, both in terms of possible interactions with the game's terrain and in visual variance, but their implementation can be very complex, especially if we'd like to support a vast number of slope types.
As was true for the previous parts in the series, we'll be continuing our work from the moment we left off the last part, even though we'll be reworking a big chunk of the code we've already written. What we'll need from the start is a working moving character and a tilemap.
You can download the project files from the previous part and write the code along with this tutorial.
Changes in Movement Integration
Since making the slopes work is pretty difficult, it'd be nice if we could actually make things easier in some aspects. Some time ago I stumbled upon a blog post on how Matt Thorson handles the physics in his games. Basically, in this method the movement is always made in 1px intervals. If a movement for a particular frame is larger than one pixel, then the movement vector is split into many 1px movements, and after each one the conditions for collision with the terrain are checked.
This saves us the headache of trying to find obstacles along the line of movement at once, and instead, we can do it iteratively. This makes the implementation simpler, but unfortunately it also increases the number of collision checks performed, so it might be inappropriate for games where there are many moving objects, especially high-resolution games where naturally the speed at which the objects are moving is higher. The plus side is that even though there will be more collision checks, each check will be much simpler since it knows that the character moves by a single pixel each time.
Slopes Data
Let's start defining the data that we'll need to represent the slopes. First of all, we'll need a height map of a slope, which will define its shape. Let's start with a classic 45-degree slope.
Let's also define another slope shape; this one will serve as more of a bump on the ground than anything else.
Of course we will want to use variants of these slopes, depending where we'd like to place them. For example, in the case of our defined 45-degree slope, it will fit nicely if there's a solid block to its right, but if the solid block is on its left then we'd like to use a flipped version of the tile we defined. We'll need to be able to flip the slopes on the X axis and Y axis as well as rotate them by 90 degrees to be able to access all the variants of a predefined slope.
Let's look at what the transformations of the 45-degree slope look like.
As you can see, in this case we can get all the variants using flips. We don't really need to rotate the slope by 90 degrees, but let's see how things look for the second slope we defined earlier.
In this case, the 90-degree rotation transform makes it possible to place the slope on the wall.
Calculate Offsets
Let's use our defined data to calculate the offsets which will need to be applied to the object that is overlapping with a tile. The offset will carry the information about:
- how much an object needs to move up/down/left/right in order not to collide with a tile
- how much an object needs to move to be right next to the top/bottom/left/right surface of a slope
The green parts of the above image are the parts where the object overlaps with the empty parts of the tile, and the yellow squares indicate the area in which the object overlaps with the slope.
Now, let's start to see how we'd calculate the offset for case number 1.
The object does not collide with any part of the slope. That means we don't really need to move it out of collision, so the first part of our offset will be set to 0.
For the second part of the offset, if we want the bottom of the object to be touching the slope, we would need to move it 3 pixels down. If we wanted the object's right side to touch the slope, we would need to move it 3 pixels to the right. For the object's left side to touch the right edge of the slope, we'd need to move it 16 pixels to the right. Similarly, if we wanted the top edge of the object to touch the slope, we'd need to move the object 16 pixels down.
Now, why would we need the information of how much distance there is between the object's edge and the slope? This data will be very useful to us when we want an object to stick to the slope.
So, for example, let's say an object moves left on our 45-degree slope. If it moves fast enough it will end up in the air, and then eventually it will fall on the slope again, and so on. If we want it to remain on the slope, each time it moves left, we'll want to push it down so it remains in touch with the slope. The below animation shows the difference between having slope sticking enabled or disabled for a character.
We'll be caching a lot of data here—basically, we want to calculate an offset for every possible overlap with a tile. This means that for every position and for every overlap size, we'll have a quick reference of how much to move an object. Note that we cannot cache the final offsets because we can't cache an offset for every possible AABB, but it's easy to adjust the offset knowing the AABB's overlap with the slope's tile.
Defining Tiles
We'll be defining all the slope data in a static Slopes class.
public static class Slopes { }
First of all, let's handle the heightmaps. Let's define a few of them to process later on.
public static readonly sbyte[] empty = new sbyte[16] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; public static readonly sbyte[] full = new sbyte[16] { 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16 }; public static readonly sbyte[] slope45 = new sbyte[16] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; public static readonly sbyte[] slopeMid1 = new sbyte[16] { 1, 2, 3, 4, 5, 6, 7, 8, 8, 7, 6, 5, 4, 3, 2, 1 };
Let's add the test tile types for the defined slopes.
public enum TileType { Empty, Block, OneWay, TestSlopeMid1, TestSlopeMid1FX, TestSlopeMid1FY, TestSlopeMid1FXY, TestSlopeMid1F90, TestSlopeMid1F90X, TestSlopeMid1F90Y, TestSlopeMid1F90XY, TestSlope45, TestSlope45FX, TestSlope45FY, TestSlope45FXY, TestSlope45F90, TestSlope45F90X, TestSlope45F90Y, TestSlope45F90XY, Count }
Let's also create another enumeration for tile collision type. This will be useful for assigning the same collision type to different tiles, for example a grassy 45-degree slope or stone 45-degree slope.
public enum TileCollisionType { Empty, Block, OneWay, SlopeMid1, SlopeMid1FX, SlopeMid1FY, SlopeMid1FXY, SlopeMid1F90, SlopeMid1F90X, SlopeMid1F90Y, SlopeMid1F90XY, Slope45, Slope45FX, Slope45FY, Slope45FXY, Slope45F90, Slope45F90X, Slope45F90Y, Slope45F90XY, Count }
Now let's create an array which will hold all the tiles' heightmaps. This array will be indexed by the TileCollisionType
enumeration.
public static readonly sbyte[] empty = new sbyte[16] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; public static readonly sbyte[] full = new sbyte[16] { 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16 }; public static readonly sbyte[] slope45 = new sbyte[16] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; public static readonly sbyte[] slopeMid1 = new sbyte[16] { 1, 2, 3, 4, 5, 6, 7, 8, 8, 7, 6, 5, 4, 3, 2, 1 }; public static sbyte[][] slopesHeights;
Processing the Slopes
Before we start calculating the offsets, we'll want to unfold our heightmaps into full collision bitmaps. This will make it easy to determine whether an AABB is colliding with a tile and also will enable more complex tile shapes if that's what we need. Let's create an array for those bitmaps.
public static readonly sbyte[] empty = new sbyte[16] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; public static readonly sbyte[] full = new sbyte[16] { 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16 }; public static readonly sbyte[] slope45 = new sbyte[16] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; public static readonly sbyte[] slopeMid1 = new sbyte[16] { 1, 2, 3, 4, 5, 6, 7, 8, 8, 7, 6, 5, 4, 3, 2, 1 }; public static sbyte[][] slopesHeights; public static sbyte[][][] slopesExtended;
Now let's create a function which will extend the heightmap into the bitmap.
public static sbyte[][] Extend(sbyte[] slope) { sbyte[][] extended = new sbyte[Map.cTileSize][]; for (int x = 0; x < Map.cTileSize; ++x) { extended[x] = new sbyte[Map.cTileSize]; for (int y = 0; y < Map.cTileSize; ++y) extended[x][y] = System.Convert.ToSByte(y < slope[x]); } return extended; }
Nothing complicated here—if a particular position on the tile is solid, we set it to 1; if it's not, it's set to 0.
Now let's create our Init
function, which will eventually do all the caching work we need to have done on the slopes.
public static void Init() { }
Let's create the container arrays here.
public static void Init() { slopesHeights = new sbyte[(int)TileCollisionType.Count][]; slopesExtended = new sbyte[(int)TileCollisionType.Count][][]; }
Now let's make every tile collision type point to the corresponding cached data.
for (int i = 0; i < (int)TileCollisionType.Count; ++i) { switch ((TileCollisionType)i) { case TileCollisionType.Empty: slopesHeights[i] = empty; slopesExtended[i] = Extend(slopesHeights[i]); break; case TileCollisionType.Full: slopesHeights[i] = full; slopesExtended[i] = Extend(slopesHeights[i]); break; case TileCollisionType.Slope45: slopesHeights[i] = slope45; slopesExtended[i] = Extend(slopesHeights[i]); break; case TileCollisionType.Slope45FX: case TileCollisionType.Slope45FY: case TileCollisionType.Slope45FXY: case TileCollisionType.Slope45F90: case TileCollisionType.Slope45F90X: case TileCollisionType.Slope45F90XY: case TileCollisionType.Slope45F90Y: slopesHeights[i] = slopesHeights[(int)TileCollisionType.Slope45]; slopesExtended[i] = slopesExtended[(int)TileCollisionType.Slope45]; break; case TileCollisionType.SlopeMid1: slopesHeights[i] = slopeMid1; slopesExtended[i] = Extend(slopesHeights[i]); break; case TileCollisionType.SlopeMid1FX: case TileCollisionType.SlopeMid1FY: case TileCollisionType.SlopeMid1FXY: case TileCollisionType.SlopeMid1F90: case TileCollisionType.SlopeMid1F90X: case TileCollisionType.SlopeMid1F90XY: case TileCollisionType.SlopeMid1F90Y: slopesHeights[i] = slopesHeights[(int)TileCollisionType.SlopeMid1]; slopesExtended[i] = slopesExtended[(int)TileCollisionType.SlopeMid1]; break; } }
Offset Structure
Now we can define our offset structure.
public struct SlopeOffsetSB { public sbyte freeLeft, freeRight, freeDown, freeUp, collidingLeft, collidingRight, collidingBottom, collidingTop; public SlopeOffsetSB(sbyte _freeLeft, sbyte _freeRight, sbyte _freeDown, sbyte _freeUp, sbyte _collidingLeft, sbyte _collidingRight, sbyte _collidingBottom, sbyte _collidingTop) { freeLeft = _freeLeft; freeRight = _freeRight; freeDown = _freeDown; freeUp = _freeUp; collidingLeft = _collidingLeft; collidingRight = _collidingRight; collidingBottom = _collidingBottom; collidingTop = _collidingTop; } }
As explained before, the freeLeft
, freeRight
, freeDown
, and freeUp
variables correspond to the offset that needs to be applied so the object is no longer colliding with the slope, while the collidingLeft
, collidingRight
, collidingTop
, and collidingBottom
are the distance that the object needs to be shifted to touch the slope while not overlapping it.
It's time to create our heavy-duty caching function, but just before we do it, let's create a container which will hold all that data.
public static sbyte[][] slopesHeights; public static sbyte[][][] slopesExtended; public static SlopeOffsetSB[][][][][] slopeOffsets;
And create the array in the Init
function.
slopesHeights = new sbyte[(int)TileCollisionType.Count][]; slopesExtended = new sbyte[(int)TileCollisionType.Count][][]; slopeOffsets = new SlopeOffsetSB[(int)TileCollisionType.Count][][][][];
Memory Issues
As you can see, this array has plenty of dimensions, and each new tile type will actually require quite a lot of memory. For every X position in the tile, for every Y position in the tile, for every possible Width in the tile and for every possible Height, there will be a separate offset value calculation.
Since the tiles we are using are 16x16, this means that the amount of data needed for each tile type will be 16*16*16*16*8 bytes, which equals 512 kB. This is a lot of data, but still manageable, and of course if caching this amount of information is unfeasible, we'll need to either switch to calculating the offsets in real time, probably using a more efficient method than the one we're using for caching, or optimize our data.
Right now, if the tile size in our game was bigger, say 32x32, each tile type would occupy 8 MB, and if we used 64x64, then it would be 128MB. These amounts seem way too big to be useful, especially if we want to have quite a few slope types in the game. A sensible solution to this seems to be splitting the big collision tiles into smaller ones. Note that it is just each newly defined slope that requires more space—the transformations use the same data.
Checking Collisions Within a Tile
Before we start calculating the offsets, we need to know if an object at a particular position will collide with the solid parts of the tile. Let's create this function first.
public static bool Collides(sbyte[][] slopeExtended, sbyte posX, sbyte posY, sbyte w, sbyte h) { for (int x = posX; x <= posX + w && x < Map.cTileSize; ++x) { for (int y = posY; y <= posY + h && y < Map.cTileSize; ++y) { if (slopeExtended[x][y] == 1) return true; } } return false; }
The function takes the collision bitmap, the position of the overlap, and the overlap size. The position is the bottom left pixel of the object, and the size is the 0-based width and height. By 0-based, I mean that width of 0 means that the object is actually 1 pixel wide, and width equal to 15 means that the object is 16 pixels wide. The function is very simple—if any of the object's pixels overlap with a slope, then we return true, otherwise we return false.
Calculate the Offsets
Now let's start calculating the offsets.
public static SlopeOffsetSB GetOffset(sbyte[][] slopeExtended, sbyte posX, sbyte posY, sbyte w, sbyte h) { }
Again, to calculate the offset, we'll need the collision bitmap, position and size of the overlap. Let's start by declaring the offset values.
public static SlopeOffsetSB GetOffset(sbyte[][] slopeExtended, sbyte posX, sbyte posY, sbyte w, sbyte h) { sbyte freeUp = 0, freeDown = 0, collidingTop = 0, collidingBottom = 0; sbyte freeLeft = 0, freeRight = 0, collidingLeft = 0, collidingRight = 0; }
Now let's calculate how much we need to move the object to make it not collide with the slope. To do that, while the object is colliding with the slope we need to keep moving it up and checking for collision until there's no overlap with the solid parts of the tile.
Above is the illustration of how we calculate the offset. In the first case, since the object is touching the top bound of the tile, instead of just moving it up we also need to decrease its height. That's because if any part of the AABB moves outside the tile bounds, we are no longer interested in it. Similarly, offsets are calculated for all other directions, so for the above example the offsets would be:
- 4 for the up offset
- -4 for the left offset
- -16 for the down offset—that's the maximum distance because basically if we move the object down, we need to move it all the way out of the bounds of the tile to stop colliding with the slope
- 16 for the right offset
Let's start by declaring the temporary variable for the height of the object. As mentioned above, this will change depending on how high we'll be moving the object.
sbyte movH = h;
Now it's time for the main condition. As long as the object hasn't moved out of the tile bounds and it collides with the solid parts of the tile, we need to increase the offsetUp
.
sbyte movH = h; while (movH >= 0 && posY + freeUp < Map.cTileSize && Collides(slopeExtended, posX, (sbyte)(posY + freeUp), w, movH)) { ++freeUp; }
Finally, let's adjust the size of the object-tile overlapping area if the object moves outside the bounds of the tile.
sbyte movH = h; while (movH >= 0 && posY + freeUp < Map.cTileSize && Collides(slopeExtended, posX, (sbyte)(posY + freeUp), w, movH)) { if (posY + freeUp == Map.cTileSize) --movH; ++freeUp; }
Now let's do the same thing for the left offset. Note that when we're moving the object left and the object is being moved out of the tile bounds, we don't really need to alter the position; instead, we just change the width of the overlap. This is illustrated on the right side of the animation illustrating the offset calculation.
movW = w; while (movW >= 0 && posX + freeLeft >= 0 && Collides(slopeExtended, (sbyte)(posX + freeLeft), posY, movW, h)) { if (posX + freeLeft == 0) --movW; else --freeLeft; }
But here, since we weren't moving the freeLeft
offset along the way as we were decreasing the width, we need to convert the reduced size into the offset.
movW = w; while (movW >= 0 && posX + freeLeft >= 0 && Collides(slopeExtended, (sbyte)(posX + freeLeft), posY, movW, h)) { if (posX + freeLeft == 0) --movW; else --freeLeft; } freeLeft -= (sbyte)(w - movW);
Now let's do the same thing for the down and right offsets.
movH = h; while (movH >= 0 && posY + freeDown >= 0 && Collides(slopeExtended, posX, (sbyte)(posY + freeDown), w, movH)) { if (posY + freeDown == 0) --movH; else --freeDown; } freeDown -= (sbyte)(h - movH); sbyte movW = w; while (movW >= 0 && posY + freeRight < Map.cTileSize && Collides(slopeExtended, (sbyte)(posX + freeRight), posY, movW, h)) { if (posX + freeRight == Map.cTileSize) --movW; ++freeRight; }
Alright, we've calculated the first part of the offset—that is how much we should move the object for it to stop colliding with the slope. Now it's time to figure out the offsets which are supposed to move the object right next to the solid parts of the tile.
Notice that if we need to move the object out of collision, we're already doing that, because we stop right after the collision is no more.
In the case on the right, the up offset is 4, but it is also the offset that we need to move the object for its bottom edge to sit on a solid pixel. The same goes for the other sides.
if (freeUp == 0) { } else collidingBottom = freeUp;
Now the case on the left is where we need to find the offsets ourselves. If we want to find the collidingBottom
offset there, we need to move the object 3 pixels down. The calculations needed here are similar to previous ones, but instead this time we'll be looking for when the object will collide with the slope, and then moving while reducing the offset by one, so it barely touches the solid pixels instead of overlapping them.
if (freeUp == 0) { while ( posY + collidingBottom >= 0 && !Collides(slopeExtended, posX, (sbyte)(posY + collidingBottom), w, h)) --collidingBottom; collidingBottom += 1; } else { collidingBottom = freeUp; }
If freeUp
is equal the 0, free down must be equal to 0 as well, so we can throw in the calculations for collidingTop
under the same brackets. Again, these calculations are analogous to what we've been doing so far.
if (freeUp == 0) { while (posY + h + collidingTop < Map.cTileSize && !Collides(slopeExtended, posX, (sbyte)(posY + collidingTop), w, h)) ++collidingTop; collidingTop -= 1; while ( posY + collidingBottom >= 0 && !Collides(slopeExtended, posX, (sbyte)(posY + collidingBottom), w, h)) --collidingBottom; collidingBottom += 1; } else { collidingBottom = freeUp; collidingTop = freeDown; }
Let's do the same for the left and right offsets.
if (freeRight == 0) { while (posX + w + collidingRight < Map.cTileSize && !Collides(slopeExtended, (sbyte)(posX + collidingRight), posY, w, h)) ++collidingRight; collidingRight -= 1; while (posX + collidingLeft >= 0 && !Collides(slopeExtended, (sbyte)(posX + collidingLeft), posY , w, h)) --collidingLeft; collidingLeft += 1; } else { collidingLeft = freeRight; collidingRight = freeLeft; }
Caching the Data
Now that all the offsets are calculated, we can return the offset for this particular data set.
return new SlopeOffsetSB(freeLeft, freeRight, freeDown, freeUp, collidingLeft, collidingRight, collidingBottom, collidingTop);
Let's create a container for all our cached data.
public static sbyte[][] slopesHeights; public static sbyte[][][] slopesExtended; public static SlopeOffsetSB[][][][][] slopeOffsets;
Initialize the array.
slopesHeights = new sbyte[(int)TileCollisionType.Count][]; slopesExtended = new sbyte[(int)TileCollisionType.Count][][]; slopeOffsets = new SlopeOffsetSB[(int)TileCollisionType.Count][][][][];
And finally, create the caching function.
public static SlopeOffsetSB[][][][] CacheSlopeOffsets(sbyte[][] slopeExtended) { var offsetCache = new SlopeOffsetSB[Map.cTileSize][][][]; for (int x = 0; x < Map.cTileSize; ++x) { offsetCache[x] = new SlopeOffsetSB[Map.cTileSize][][]; for (int y = 0; y < Map.cTileSize; ++y) { offsetCache[x][y] = new SlopeOffsetSB[Map.cTileSize][]; for (int w = 0; w < Map.cTileSize; ++w) { offsetCache[x][y][w] = new SlopeOffsetSB[Map.cTileSize]; for (int h = 0; h < Map.cTileSize; ++h) { offsetCache[x][y][w][h] = GetOffset(slopeExtended, (sbyte)x, (sbyte)y, (sbyte)w, (sbyte)h); } } } } return offsetCache; }
The function itself is very simple, so it's very easy to see how much data it caches to satisfy our requirements!
Now make sure to cache the offsets for each tile collision type.
case TileCollisionType.Slope45: slopesHeights[i] = slope45; slopesExtended[i] = Extend(slopesHeights[i]); slopeOffsets[i] = CacheSlopeOffsets(slopesExtended[i]); break; case TileCollisionType.Slope45FX: case TileCollisionType.Slope45FY: case TileCollisionType.Slope45FXY: case TileCollisionType.Slope45F90: case TileCollisionType.Slope45F90X: case TileCollisionType.Slope45F90XY: case TileCollisionType.Slope45F90Y: slopesHeights[i] = slopesHeights[(int)TileCollisionType.Slope45]; slopesExtended[i] = slopesExtended[(int)TileCollisionType.Slope45]; slopeOffsets[i] = slopeOffsets[(int)TileCollisionType.Slope45]; break;
And that's it, our main caching function is finished!
Calculating the World Space Offset
Now let's use the cached data to make a function which will return an offset for a character which exists in a world space.
public static SlopeOffsetI GetOffset(Vector2 tileCenter, float leftX, float rightX, float bottomY, float topY, TileCollisionType tileCollisionType) { }
The offset that we'll be returning is not the same struct we used for the cached data, since the world space offsets can end up being bigger than the limits of the single byte. The structure is basically the same thing, but using integers.
The parameters are as follows:
- the world space center of the tile
- the left, right, bottom and top edges of the AABB we want to receive the offset for
- the type of tile we want to receive the offset for
First, we need to figure out how the AABB overlaps with a slope tile. We need to know where the overlap starts (the bottom left corner), and also how much the overlap extends over the tile.
To calculate this, let's first declare the variables.
public static SlopeOffsetI GetOffset(Vector2 tileCenter, float leftX, float rightX, float bottomY, float topY, TileCollisionType tileCollisionType) { int posX, posY, sizeX, sizeY; SlopeOffsetI offset; }
Now let's calculate the edges of the tile in the world space.
float leftTileEdge = tileCenter.x - Map.cTileSize / 2; float rightTileEdge = leftTileEdge + Map.cTileSize; float bottomTileEdge = tileCenter.y - Map.cTileSize / 2; float topTileEdge = bottomTileEdge + Map.cTileSize;
Now this should be quite easy. There are two main categories of cases we can find here. First is that the overlap is within the tile bounds.
The dark blue pixel is the position of the overlap, and the height and width are marked with the blue tiles. Here things are pretty straightforward, so calculating the position and the size of the overlap doesn't require any additional actions.
The second category of cases looks as follows, and in the game we'll mostly be dealing with those cases:
Let's look at an example situation pictured above. As you can see, the AABB extends well beyond the tile, but what we need to figure out is the position and size of the overlap within the tile itself, so we can retrieve our cached offset value. Right now we don't really care about anything that lies beyond the tile bounds. This will require us to clamp the overlap position and size to the tile's bounds.
Position x is equal to the offset between the left edge of the AABB and the left edge of the tile. If AABB is to the left of the tile's left edge, the position needs to be clamped to 0. To get the overlap width, we need to subtract the AABB's right edge from the overlap's x position, which we already calculated.
The values for the Y axis are calculated in the same way.
posX = (int)Mathf.Clamp(leftX - leftTileEdge, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp(rightX - (leftTileEdge + posX), 0.0f, Map.cTileSize - 1); posY = (int)Mathf.Clamp(bottomY - bottomTileEdge, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp(topY - (bottomTileEdge + posY), 0.0f, Map.cTileSize - 1);
Now we can retrieve the cached offsets for the overlap.
offset = new SlopeOffsetI(slopeOffsets[(int)tileCollisionType][posX][posY][sizeX][sizeY]);
Adjust the Offset
Before we return the offset, we might need to adjust it. Consider the following situation.
Let's see how our cached offset for such an overlap would look. When caching, we were only concerned about the overlap within the tile bounds, so in this case, the up offset would be equal to 9. You can see that if we moved the overlap area within the tile bounds 9 pixels up, it would cease to collide with the slope, but if we move the whole AABB, then the area which is below the tile bounds will move into the collision.
Basically, what we need to do here is adjust the up offset by the number of pixels the AABB extends below the tile bounds.
if (bottomTileEdge > bottomY) { if (offset.freeUp > 0) offset.freeUp += (int)bottomTileEdge - (int)bottomY; offset.collidingBottom = offset.freeUp; }
The same thing needs to be done for all of the other offsets—left, right, and down—except that for now we'll skip handling the left and right offsets in this manner since it is not necessary to do so.
if (topTileEdge < topY) { if (offset.freeDown < 0) offset.freeDown -= (int)(topY - topTileEdge); offset.collidingTop = offset.freeDown; } if (bottomTileEdge > bottomY) { if (offset.freeUp > 0) offset.freeUp += (int)bottomTileEdge - (int)bottomY; offset.collidingBottom = offset.freeUp; }
Once we're done, we can return the adjusted offset. The finished function should look like this.
public static SlopeOffsetI GetOffset(Vector2 tileCenter, float leftX, float rightX, float bottomY, float topY, TileCollisionType tileCollisionType) { int posX, posY, sizeX, sizeY; float leftTileEdge = tileCenter.x - Map.cTileSize / 2; float rightTileEdge = leftTileEdge + Map.cTileSize; float bottomTileEdge = tileCenter.y - Map.cTileSize / 2; float topTileEdge = bottomTileEdge + Map.cTileSize; SlopeOffsetI offset; posX = (int)Mathf.Clamp(leftX - leftTileEdge, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp(rightX - (leftTileEdge + posX), 0.0f, Map.cTileSize - 1); posY = (int)Mathf.Clamp(bottomY - bottomTileEdge, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp(topY - (bottomTileEdge + posY), 0.0f, Map.cTileSize - 1); offset = new SlopeOffsetI(slopeOffsets[(int)tileCollisionType][posX][posY][sizeX][sizeY]); if (topTileEdge < topY) { if (offset.freeDown < 0) offset.freeDown -= (int)(topY - topTileEdge); offset.collidingTop = offset.freeDown; } if (bottomTileEdge > bottomY) { if (offset.freeUp > 0) offset.freeUp += Mathf.RoundToInt(bottomTileEdge - bottomY); offset.collidingBottom = offset.freeUp; } return offset; }
Of course, it's not completely done yet. Later on we'll also be handling the tile transformations here, so the offset is returned appropriately depending on whether the tile has been flipped on the XY axes or rotated 90 degrees. For now, though, we'll be playing only with the non-transformed tiles.
Implementing One-Pixel Step Physics
Overview
Moving the objects by one pixel will make it quite easy to handle a lot of things, especially collisions against slopes for fast objects, but even though we're going to check for collision each pixel we move, we should move in a specific pattern to ensure accuracy. This pattern will be dependent on the object's speed.
On the picture above, you can see that if we blindly move the object first all the pixels it needs to move horizontally, and after that vertically, the arrow would end up colliding with a solid block that's not really on its course. The order of movement should be based on the ratio of the vertical to horizontal speed; this way we'll know how many pixels we need to move vertically for each pixel moved horizontally.
Define the Data
Let's move to our moving object class and define a few new variables.
First of all, our main mPosition
variable will be holding only the integer numbers, and we'll be keeping another variable called mRemainder
to keep the value after the floating point.
public Vector2 mPosition; public Vector2 mRemainder;
Next, we'll add a few new position status variables to indicate whether the character is currently on the slope. At this point, it will be good if we pack all the position status into a single structure.
[Serializable] public struct PositionState { public bool pushesRight; public bool pushesLeft; public bool pushesBottom; public bool pushesTop; public bool pushedTop; public bool pushedBottom; public bool pushedRight; public bool pushedLeft; public bool pushedLeftObject; public bool pushedRightObject; public bool pushedBottomObject; public bool pushedTopObject; public bool pushesLeftObject; public bool pushesRightObject; public bool pushesBottomObject; public bool pushesTopObject; public bool pushedLeftTile; public bool pushedRightTile; public bool pushedBottomTile; public bool pushedTopTile; public bool pushesLeftTile; public bool pushesRightTile; public bool pushesBottomTile; public bool pushesTopTile; public bool onOneWayPlatform; public Vector2i leftTile; public Vector2i rightTile; public Vector2i topTile; public Vector2i bottomTile; public void Reset() { leftTile = rightTile = topTile = bottomTile = new Vector2i(-1, -1); pushesRight = false; pushesLeft = false; pushesBottom = false; pushesTop = false; pushedTop = false; pushedBottom = false; pushedRight = false; pushedLeft = false; pushedLeftObject = false; pushedRightObject = false; pushedBottomObject = false; pushedTopObject = false; pushesLeftObject = false; pushesRightObject = false; pushesBottomObject = false; pushesTopObject = false; pushedLeftTile = false; pushedRightTile = false; pushedBottomTile = false; pushedTopTile = false; pushesLeftTile = false; pushesRightTile = false; pushesBottomTile = false; pushesTopTile = false; onOneWayPlatform = false; } }
Now let's declare an instance of the struct for the object.
public PositionState mPS;
Another variable that we'll need is slope sticking.
public bool mSticksToSlope;
Basic Implementation
Let's start by creating the basic collision checking functions; these will not handle the slopes yet.
Collision Checks
Let's start with the right side.
public bool CollidesWithTileRight(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState stat) { }
The parameters used here are the current position of the object, its top right and bottom left corners, and the position state. First of all, let's calculate the top right and top left tile for our object.
Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x + 0.5f, topRight.y - 0.5f)); Vector2i bottomLeftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y + 0.5f));
Now let's iterate through all the tiles along the object's right edge.
for (int y = bottomLeftTile.y; y <= topRightTile.y; ++y) { var tileCollisionType = mMap.GetCollisionType(topRightTile.x, y); }
Now, depending on the collision tile, we react appropriately.
switch (tileCollisionType) { default://slope break; case TileCollisionType.Empty: break; case TileCollisionType.Full: state.pushesRightTile = true; state.rightTile = new Vector2i(topRightTile.x, y); return true; }
As you can see, for now we'll skip handling the slopes; we just want to get the basic setup done before we delve into that.
Overall, the function for now should look like this:
public bool CollidesWithTileRight(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState stat) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x + 0.5f, topRight.y - 0.5f)); Vector2i bottomLeftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y + 0.5f)); for (int y = bottomLeftTile.y; y <= topRightTile.y; ++y) { var tileCollisionType = mMap.GetCollisionType(topRightTile.x, y); switch (tileCollisionType) { default://slope break; case TileCollisionType.Empty: break; case TileCollisionType.Full: state.pushesRightTile = true; state.rightTile = new Vector2i(topRightTile.x, y); return true; } } return false; }
We'll do the same for all the other three directions: left, up, and down.
public bool CollidesWithTileLeft(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState stat) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x - 0.5f, topRight.y - 0.5f)); Vector2i bottomLeftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x - 0.5f, bottomLeft.y + 0.5f)); for (int y = bottomLeftTile.y; y <= topRightTile.y; ++y) { var tileCollisionType = mMap.GetCollisionType(bottomLeftTile.x, y); switch (tileCollisionType) { default://slope break; case TileCollisionType.Empty: break; case TileCollisionType.Full: state.pushesLeftTile = true; state.leftTile = new Vector2i(bottomLeftTile.x, y); return true; } } return false; } public bool CollidesWithTileTop(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState stat) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x - 0.5f, topRight.y + 0.5f)); Vector2i bottomleftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y + 0.5f)); for (int x = bottomleftTile.x; x <= topRightTile.x; ++x) { var tileCollisionType = mMap.GetCollisionType(x, topRightTile.y); switch (tileCollisionType) { default://slope break; case TileCollisionType.Empty: break; case TileCollisionType.Full: state.pushesTopTile = true; state.topTile = new Vector2i(x, topRightTile.y); return true; } } return false; } public bool CollidesWithTileBottom(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState stat) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x - 0.5f, topRight.y - 0.5f)); Vector2i bottomleftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y - 0.5f)); for (int x = bottomleftTile.x; x <= topRightTile.x; ++x) { var tileCollisionType = mMap.GetCollisionType(x, bottomleftTile.y); switch (tileCollisionType) { default://slope break; case TileCollisionType.Empty: break; case TileCollisionType.Full: state.onOneWayPlatform = false; state.pushesBottomTile = true; state.bottomTile = new Vector2i(x, bottomleftTile.y); return true; } } return false; }
Moving Functions
Now that we have this covered, we can start creating two functions responsible for movement. One will handle the movement horizontally, and another will handle the vertical movement.
public void MoveX(ref Vector2 position, ref bool foundObstacleX, float offset, float step, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state) { }
The arguments we'll use in this function are the current position, a boolean which indicates whether we find an obstacle along the way or not, an offset which defines how much we need to move, a step which is a value we move the object with each iteration, the AABB's lower left and upper right vertices, and finally the position state.
Basically, what we want to do here is move the object by a step so many times, so that the steps sum up to the offset. Of course, if we meet an obstacle, we need to stop moving as well.
while (!foundObstacleX && offset != 0.0f) { }
With each iteration, we subtract the step from the offset, so the offset eventually becomes zero, and we know we moved as many pixels as we needed to.
while (!foundObstacleX && offset != 0.0f) { offset -= step; }
With each step, we want to check whether we collide with a tile. If we're moving right, we want to check if we collide with a wall on the right; if we're moving left, we want to check for obstacles on the left.
while (!foundObstacleX && offset != 0.0f) { offset -= step; if (step > 0.0f) foundObstacleX = CollidesWithTileRight(ref position, ref topRight, ref bottomLeft, ref state, true); else foundObstacleX = CollidesWithTileLeft(ref position, ref topRight, ref bottomLeft, ref state, true); }
If we didn't find an obstacle, we can move the object.
while (!foundObstacleX && offset != 0.0f) { offset -= step; if (step > 0.0f) foundObstacleX = CollidesWithTileRight(ref position, ref topRight, ref bottomLeft, ref state, true); else foundObstacleX = CollidesWithTileLeft(ref position, ref topRight, ref bottomLeft, ref state, true); if (!foundObstacleX) { position.x += step; topRight.x += step; bottomLeft.x += step; } }
Finally, after we move, we check for collisions up and down, because we could slide right under or above a block. This is just to update the position state to be accurate.
public void MoveX(ref Vector2 position, ref bool foundObstacleX, float offset, float step, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state) { while (!foundObstacleX && offset != 0.0f) { offset -= step; if (step > 0.0f) foundObstacleX = CollidesWithTileRight(ref position, ref topRight, ref bottomLeft, ref state, true); else foundObstacleX = CollidesWithTileLeft(ref position, ref topRight, ref bottomLeft, ref state, true); if (!foundObstacleX) { position.x += step; topRight.x += step; bottomLeft.x += step; CollidesWithTileTop(ref position, ref topRight, ref bottomLeft, ref state); CollidesWithTileBottom(ref position, ref topRight, ref bottomLeft, ref state); } } }
The MoveY
function works similarly.
public void MoveY(ref Vector2 position, ref bool foundObstacleY, float offset, float step, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state) { while (!foundObstacleY && offset != 0.0f) { offset -= step; if (step > 0.0f) foundObstacleY = CollidesWithTileTop(ref position, ref topRight, ref bottomLeft, ref state); else foundObstacleY = CollidesWithTileBottom(ref position, ref topRight, ref bottomLeft, ref state); if (!foundObstacleY) { position.y += step; topRight.y += step; bottomLeft.y += step; CollidesWithTileLeft(ref position, ref topRight, ref bottomLeft, ref state); CollidesWithTileRight(ref position, ref topRight, ref bottomLeft, ref state); } } }
Consolidate the Movement
Now that we have functions responsible for vertical and horizontal movement, we can create the main function responsible for movement.
public void Move(Vector2 offset, Vector2 speed, ref Vector2 position, ref Vector2 remainder, AABB aabb, ref PositionState state) { }
The function takes the value of how much to move the object, the object's current speed, its current position together with the floating point remainder, the object's AABB, and the position state.
The first thing we'll do here is add the offset to the remainder, so that in the remainder we have the full value of how much our character should move.
remainder += offset;
Since we'll be calling the MoveX
and MoveY
functions from this one, we'll need to pass the top right and bottom left corners of the AABB, so let's calculate them now.
Vector2 topRight = aabb.Max(); Vector2 bottomLeft = aabb.Min();
We also need to get the step vector. It will be used as a direction in which we'll be moving our object.
var step = new Vector2(Mathf.Sign(offset.x), Mathf.Sign(offset.y));
Now let's see how many pixels we actually need to move. We need to round the remainder, because we are always going to move by an integer number, and then we need to subtract that value from the remainder.
var move = new Vector2(Mathf.Round(remainder.x), Mathf.Round(remainder.y)); remainder -= move;
Now let's split the movement into four cases, depending on our move vector values. If the move vector's x and y values are equal to 0, there is no movement to be made, so we can just return.
if (move.x == 0.0f && move.y == 0.0f) return;
If only the y value is 0, we're going to move only horizontally.
if (move.x == 0.0f && move.y == 0.0f) return; else if (move.x != 0.0f && move.y == 0.0f) MoveX(ref position, ref foundObstacleX, move.x, step.x, ref topRight, ref bottomLeft, ref state);
If only the x value is 0, we're going to move only vertically.
if (move.x == 0.0f && move.y == 0.0f) return; else if (move.x != 0.0f && move.y == 0.0f) MoveX(ref position, ref foundObstacleX, move.x, step.x, ref topRight, ref bottomLeft, ref state); else if (move.y != 0.0f && move.x == 0.0f) MoveY(ref position, ref foundObstacleY, move.y, step.y, ref topRight, ref bottomLeft, ref state); else { }
If we need to move both on the x and y axes, we need to move in a pattern that was described earlier. First off, let's calculate the speed ratio.
float speedRatio = Mathf.Abs(speed.y) / Mathf.Abs(speed.x);
Let's also declare the vertical accumulator which will hold how many pixels we need to move vertically with each loop.
float speedRatio = Mathf.Abs(speed.y) / Mathf.Abs(speed.x); float vertAccum = 0.0f;
The condition to stop moving will be that we either met an obstacle on any of the axes or the object was moved by the whole move vector.
float speedRatio = Mathf.Abs(speed.y) / Mathf.Abs(speed.x); float vertAccum = 0.0f; while (!foundObstacleX && !foundObstacleY && (move.x != 0.0f || move.y != 0.0f)) { }
Now let's calculate how many pixels vertically we should move the object.
while (!foundObstacleX && !foundObstacleY && (move.x != 0.0f || move.y != 0.0f)) { vertAccum += Mathf.Sign(step.y) * speedRatio; }
For the movement, we first move one step horizontally.
while (!foundObstacleX && !foundObstacleY && (move.x != 0.0f || move.y != 0.0f)) { vertAccum += Mathf.Sign(step.y) * speedRatio; MoveX(ref position, ref foundObstacleX, step.x, step.x, ref topRight, ref bottomLeft, ref state); move.x -= step.x; }
And after this we can move vertically. Here we know that we need to move the object by the value contained in the vertAccum
, but in case of any inaccuracies, if we moved all the way on the X axis, we also need to move all the way on the Y axis.
while (!foundObstacleX && !foundObstacleY && (move.x != 0.0f || move.y != 0.0f)) { vertAccum += Mathf.Sign(step.y) * speedRatio; MoveX(ref position, ref foundObstacleX, step.x, step.x, ref topRight, ref bottomLeft, ref state); move.x -= step.x; while (!foundObstacleY && move.y != 0.0f && (Mathf.Abs(vertAccum) >= 1.0f || move.x == 0.0f)) { move.y -= step.y; vertAccum -= step.y; MoveY(ref position, ref foundObstacleX, step.y, step.y, ref topRight, ref bottomLeft, ref state); } }
All in all, the function should look like this:
public void Move(Vector2 offset, Vector2 speed, ref Vector2 position, ref Vector2 remainder, AABB aabb, ref PositionState state) { remainder += offset; Vector2 topRight = aabb.Max(); Vector2 bottomLeft = aabb.Min(); bool foundObstacleX = false, foundObstacleY = false; var step = new Vector2(Mathf.Sign(offset.x), Mathf.Sign(offset.y)); var move = new Vector2(Mathf.Round(remainder.x), Mathf.Round(remainder.y)); remainder -= move; if (move.x == 0.0f && move.y == 0.0f) return; else if (move.x != 0.0f && move.y == 0.0f) MoveX(ref position, ref foundObstacleX, move.x, step.x, ref topRight, ref bottomLeft, ref state); else if (move.y != 0.0f && move.x == 0.0f) MoveY(ref position, ref foundObstacleY, move.y, step.y, ref topRight, ref bottomLeft, ref state); else { float speedRatio = Mathf.Abs(speed.y) / Mathf.Abs(speed.x); float vertAccum = 0.0f; while (!foundObstacleX && !foundObstacleY && (move.x != 0.0f || move.y != 0.0f)) { vertAccum += Mathf.Sign(step.y) * speedRatio; MoveX(ref position, ref foundObstacleX, step.x, step.x, ref topRight, ref bottomLeft, ref state); move.x -= step.x; while (!foundObstacleY && move.y != 0.0f && (Mathf.Abs(vertAccum) >= 1.0f || move.x == 0.0f)) { move.y -= step.y; vertAccum -= step.y; MoveY(ref position, ref foundObstacleX, step.y, step.y, ref topRight, ref bottomLeft, ref state); } } } }
Now we can use the functions we've built to compose our main UpdatePhysics
function.
Build the Physics Update Function
First of all, we want to update the position state, so all of the previous frame's data goes to the adequate variables, and the current frame's data is reset.
public void UpdatePhysics() { mPS.pushedBottom = mPS.pushesBottom; mPS.pushedRight = mPS.pushesRight; mPS.pushedLeft = mPS.pushesLeft; mPS.pushedTop = mPS.pushesTop; mPS.pushedBottomTile = mPS.pushesBottomTile; mPS.pushedLeftTile = mPS.pushesLeftTile; mPS.pushedRightTile = mPS.pushesRightTile; mPS.pushedTopTile = mPS.pushesTopTile; mPS.pushesBottom = mPS.pushesLeft = mPS.pushesRight = mPS.pushesTop = mPS.pushesBottomTile = mPS.pushesLeftTile = mPS.pushesRightTile = mPS.pushesTopTile = mPS.pushesBottomObject = mPS.pushesLeftObject = mPS.pushesRightObject = mPS.pushesTopObject = mPS.onOneWay = false; }
Now let's update the collision state of our object. We do this so that before we move our object, we have updated data on whether it's on the ground or is pushing any other tiles. Normally the previous frame's data would still be up to date if the terrain was unmodifiable and other objects wouldn't be able to move this one, but here we assume that any of this could happen.
Vector2 topRight = mAABB.Max(); Vector2 bottomLeft = mAABB.Min(); CollidesWithTiles(ref mPosition, ref topRight, ref bottomLeft, ref mPS);
CollidesWithTiles
simply calls all the collision functions we've written.
public void CollidesWithTiles(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state) { CollidesWithTileTop(ref position, ref topRight, ref bottomLeft, ref state); CollidesWithTileBottom(ref position, ref topRight, ref bottomLeft, ref state); CollidesWithTileLeft(ref position, ref topRight, ref bottomLeft, ref state); CollidesWithTileRight(ref position, ref topRight, ref bottomLeft, ref state); }
Then update the speed.
mOldSpeed = mSpeed; if (mPS.pushesBottomTile) mSpeed.y = Mathf.Max(0.0f, mSpeed.y); if (mPS.pushesTopTile) mSpeed.y = Mathf.Min(0.0f, mSpeed.y); if (mPS.pushesLeftTile) mSpeed.x = Mathf.Max(0.0f, mSpeed.x); if (mPS.pushesRightTile) mSpeed.x = Mathf.Min(0.0f, mSpeed.x);
And update the position. First off, let's save the old one.
mOldPosition = mPosition;
Calculate the new one.
Vector2 newPosition = mPosition + mSpeed * Time.deltaTime;
Calculate the offset between the two.
Vector2 offset = newPosition - mPosition;
Now, in case the offset is non-zero, we can call our Move function.
if (offset != Vector2.zero) Move(offset, mSpeed, ref mPosition, ref mRemainder, mAABB, ref mPS);
Finally, update the object's AABB and the position state.
mAABB.Center = mPosition; mPS.pushesBottom = mPS.pushesBottomTile; mPS.pushesRight = mPS.pushesRightTile; mPS.pushesLeft = mPS.pushesLeftTile; mPS.pushesTop = mPS.pushesTopTile;
That's it! This system now replaces the older one, the results should be the same, although the way we do it is quite a bit different.
Summary
That's it for laying down the groundwork for the slopes, so what's left is to fill up those gaps in our collision checks! We've done most of our caching work here and eliminated a lot of geometrical complexities by implementing the one-pixel movement integration.
This will make the slope implementation a breeze, compared to what we'd need to do otherwise. We'll be finishing the job in the next part of the series.
Thanks for reading!