There are two basic approaches to building platformer levels. One of them is to use a grid and place the appropriate tiles in cells, and the other is a more free-form one, in which you can loosely place level geometry however and wherever you want.
There are pros to cons to both approaches. We'll be using the grid, so let's see what kind of pros it has over the other method:
- Better performance—collision detection against the grid is cheaper than against loosely placed objects in most cases.
- Makes it much easier to handle pathfinding.
- Tiles are more accurate and predictable than loosely placed objects, especially when considering things like destructible terrain.
Building a Map Class
Let's start by creating a Map class. It will hold all of the map specific data.
public class Map { }
Now we need to define all the tiles that the map contains, but before we do that, we need to know what tile types exist in our game. For now, we're planning on only three: an empty tile, a solid tile, and a one-way platform.
public enum TileType { Empty, Block, OneWay }
In the demo, tile types correspond directly to the type of collision we'd like to have with a tile, but in a real game that's not necessarily so. As you have more visually different tiles, it would be better to add new types such as GrassBlock, GrassOneWay and so on, to let the TileType enum define not only the collision type but also the appearance of the tile.
Now in the map class we can add an array of tiles.
public class Map { private TileType[,] mTiles; }
Of course, a tilemap that we can't see is not of much use to us, so we also need sprites to back up the tile data. Normally in Unity it is extremely inefficient to have each tile be a separate object, but since we're just using this to test our physics, it's OK to make it this way in the demo.
private SpriteRenderer[,] mTilesSprites;
The map also needs a position in the world space, so that if we need to have more than just a single one, we can move them apart.
public Vector3 mPosition;
Width and height, in tiles.
public int mWidth = 80; public int mHeight = 60;
And the tile size: in the demo we'll be working with a fairly small tile size, which is 16 by 16 pixels.
public const int cTileSize = 16;
That would be it. Now we need a couple of helper functions to let us access the map's data easily. Let's start by making a function which will convert world coordinates to the map's tile coordinates.
public Vector2i GetMapTileAtPoint(Vector2 point) { }
As you can see, this function takes a Vector2
as a parameter and returns a Vector2i
, which is basically a 2D vector operating on integers instead of floats.
Converting the world position to the map position is very straightforward—we simply need to shift the point
by mPosition
so we return the tile relative to the map's position and then divide the result by the tile size.
public Vector2i GetMapTileAtPoint(Vector2 point) { return new Vector2i((int)((point.x - mPosition.x + cTileSize / 2.0f) / (float)(cTileSize)), (int)((point.y - mPosition.y + cTileSize / 2.0f) / (float)(cTileSize))); }
Note that we had to shift the point
additionally by cTileSize / 2.0f
, because the tile's pivot is in its center. Let's also make two additional functions which will return only the X and Y component of the position in the map space. It'll be useful later.
public int GetMapTileYAtPoint(float y) { return (int)((y - mPosition.y + cTileSize / 2.0f) / (float)(cTileSize)); } public int GetMapTileXAtPoint(float x) { return (int)((x - mPosition.x + cTileSize / 2.0f) / (float)(cTileSize)); }
We also should create a complementary function which, given a tile, will return its position in the world space.
public Vector2 GetMapTilePosition(int tileIndexX, int tileIndexY) { return new Vector2( (float)(tileIndexX * cTileSize) + mPosition.x, (float)(tileIndexY * cTileSize) + mPosition.y ); } public Vector2 GetMapTilePosition(Vector2i tileCoords) { return new Vector2( (float)(tileCoords.x * cTileSize) + mPosition.x, (float)(tileCoords.y * cTileSize) + mPosition.y ); }
Aside from translating positions, we also need to have a couple of functions to see whether a tile at a certain position is empty, is a solid tile or is a one-way platform. Let's start with a very generic GetTile function, which will return a type of a specific tile.
public TileType GetTile(int x, int y) { if (x < 0 || x >= mWidth || y < 0 || y >= mHeight) return TileType.Block; return mTiles[x, y]; }
As you can see, before we return the tile type, we check if the given position is out of bounds. If it is, then we want to treat it as a solid block, otherwise we return a true type.
The next in queue is a function to check whether a tile is an obstacle.
public bool IsObstacle(int x, int y) { if (x < 0 || x >= mWidth || y < 0 || y >= mHeight) return true; return (mTiles[x, y] == TileType.Block); }
In the same way as before, we check if the tile is out of bounds, and if it is then we return true, so any tile out of bounds is treated as an obstacle.
Now let's check whether the tile is a ground tile. We can stand on both a block and a one-way platform, so we need to return true if the tile is any of these two.
public bool IsGround(int x, int y) { if (x < 0 || x >= mWidth || y < 0 || y >= mHeight) return false; return (mTiles[x, y] == TileType.OneWay || mTiles[x, y] == TileType.Block); }
Finally, let's add IsOneWayPlatform
and IsEmpty
functions in the same manner.
public bool IsOneWayPlatform(int x, int y) { if (x < 0 || x >= mWidth || y < 0 || y >= mHeight) return false; return (mTiles[x, y] == TileType.OneWay); } public bool IsEmpty(int x, int y) { if (x < 0 || x >= mWidth || y < 0 || y >= mHeight) return false; return (mTiles[x, y] == TileType.Empty); }
That's all that we need our map class to do. Now we can move on and implement the character collision against it.
Character-Map Collision
Let's go back to the MovingObject
class. We need to create a couple of functions which will detect whether the character is colliding with the tilemap.
The method by which we'll know whether the character collides with a tile or not is very simple. We'll be checking all the tiles that exist right outside the moving object's AABB.
The yellow box represents the character's AABB, and we'll be checking the tiles along the red lines. If any of those overlap with a tile, we set a corresponding collision variable to true (such as mOnGround
, mPushesLeftWall
, mAtCeiling
or mPushesRightWall
).
Let's start by creating a function HasGround, which will check if the character collides with a ground tile.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) { }
This function returns true if Character overlaps with any of the bottom tiles. It takes the old position, the current position and the current speed as parameters, and also returns the Y position of the top of the tile we are colliding with and whether the collided tile is a one-way platform or not.
The first thing we want to do is to calculate the center of AABB.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) { var center = position + mAABBOffset; }
Now that we've got that, for the bottom collision check we'll need to calculate the beginning and end of the bottom sensor line. The sensor line is just one pixel below the AABB's bottom contour.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) { var center = position + mAABBOffset; var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y); }
The bottomLeft
and bottomRight
represent the two ends of the sensor. Now that we've got these, we can calculate which tiles we need to check. Let's start by creating a loop in which we'll be going through the tiles from the left to the right.
for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize) { }
Note that there's no condition to exit the loop here—we'll do that at the end of the loop.
The first thing we should do in the loop is to make sure that the checkedTile.x
is not greater than the right end of the sensor. This might be the case because we move the checked point by multiples of the tile size, so for example, if the character is 1.5 tiles wide, we need to check the tile on the left edge of the sensor, then one tile to the right, and then 1.5 tiles to the right instead of 2.
for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize) { checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x); }
Now we need to get the tile coordinate in the map space to be able to check the tile's type.
int tileIndexX, tileIndexY; for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize) { checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x); tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y); }
First, let's calculate the tile's top position.
int tileIndexX, tileIndexY; for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize) { checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x); tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y); groundY = (float)tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y; }
Now, if the currently checked tile is an obstacle, we can easily return true.
int tileIndexX, tileIndexY; for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize) { checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x); tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y); groundY = (float)tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y; if (mMap.IsObstacle(tileIndexX, tileIndexY)) return true; }
Finally, let's check whether we already looked through all the tiles that intersect with the sensor. If that's the case, then we can safely exit the loop. After we exit the loop not finding a tile we collided with, we need to return false
to let the caller know that there is no ground below the object.
int tileIndexX, tileIndexY; for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize) { checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x); tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y); groundY = (float)tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y; if (mMap.IsObstacle(tileIndexX, tileIndexY)) return true; if (checkedTile.x >= bottomRight.x) break; } return false;
That's the most basic version of the check. Let's try to get it to work now. Back in the UpdatePhysics
function, our old ground check looks like this.
if (mPosition.y <= 0.0f) { mPosition.y = 0.0f; mOnGround = true; } else mOnGround = false;
Let's replace it using the newly created method. If the character is falling down and we have found an obstacle on our way, then we need to move it out of the collision and also set the mOnGround
to true. Let's start with the condition.
float groundY = 0; if (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY)) { }
If the condition is fulfilled then we need to move the character on the top of the tile we collided with.
float groundY = 0; if (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY)) { mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y; }
As you can see, it's very simple because the function returns the ground level to which we should align the object. After this, we only need to set the vertical speed to zero and set mOnGround
to true.
float groundY = 0; if (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY)) { mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y; mSpeed.y = 0.0f; mOnGround = true; }
If our vertical speed is greater than zero or we're not touching any ground, we need to set the mOnGround
to false
.
float groundY = 0; if (mSpeed.y <= 0.0f && HasGround(mOldPosition, mPosition, mSpeed, out groundY)) { mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y; mSpeed.y = 0.0f; mOnGround = true; } else mOnGround = false;
Now let's see how this works.
As you can see, it works well! The collision detection for the walls on both sides and at the top of the character are still not there, but the character stops each time it meets the ground. We still need to put a little more work into the collision-checking function to make it robust.
One of the issues we need to solve is visible if the character's offset from one frame to the other is too big to detect the collision properly. This is illustrated in the following picture.
This situation does not happen now because we locked the maximum falling speed to a reasonable value and update the physics with 60 FPS frequency, so the differences in positions between the frames are rather small. Let's see what happens if we update the physics only 30 times per second.
As you can see, in this scenario our ground collision check fails us. To fix this, we can't simply check if the character has ground beneath him at the current position, but we rather need to see whether there were any obstacles along the way from the previous frame's position.
Let's go back to our HasGround
function. Here, besides calculating the center, we'll also want to calculate the previous frame's center.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) { var oldCenter = oldPosition + mAABBOffset; var center = position + mAABBOffset;
We'll also need to get the previous frame's sensor position.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) { var oldCenter = oldPosition + mAABBOffset; var center = position + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
Now we need to calculate at which tile vertically we are going to start checking whether there is a collision or not, and at which we will stop.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) { var oldCenter = oldPosition + mAABBOffset; var center = position + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y); int endY = mMap.GetMapTileYAtPoint(bottomLeft.y); int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY);
We start the search from the tile at the previous frame's sensor position, and end it at the current frame's sensor position. That's of course because when we check for a ground collision we assume we are falling down, and that means we're moving from the higher position to the lower one.
Finally, we need to have another iteration loop. Now, before we fill the code for this outer loop, let's consider the following scenario.
Here you can see an arrow moving fast. This example shows that we need not only to iterate through all the tiles we would need to pass vertically, but also to interpolate the object's position for each tile we go through to approximate the path from the previous frame's position to the current one. If we simply kept using the current object's position, then in the above case a collision would be detected, even though it shouldn't be.
Let's rename the bottomLeft
and bottomRight
as newBottomLeft
and newBottomRight
, so we know that these are the new frame's sensor positions.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) { var oldCenter = oldPosition + mAABBOffset; var center = position + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomRight = new Vector2(newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y); int endY = mMap.GetMapTileYAtPoint(newBottomLeft.y); int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY); int tileIndexX; for (int tileIndexY = begY; tileIndexY >= endY; --tileIndexY) { } return false; }
Now, within this new loop, let's interpolate the sensor positions, so that at the beginning of the loop we're assuming the sensor to be at the previous frame's position, and at its end it's going to be in the current frame's position.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) { var oldCenter = oldPosition + mAABBOffset; var center = position + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomRight = new Vector2(newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y); int endY = mMap.GetMapTileYAtPoint(newBottomLeft.y); int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY); int dist = Mathf.Max(Mathf.Abs(endY - begY), 1); int tileIndexX; for (int tileIndexY = begY; tileIndexY >= endY; --tileIndexY) { var bottomLeft = Vector2.Lerp(newBottomLeft, oldBottomLeft, (float)Mathf.Abs(endY - tileIndexY) / dist); var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y); } return false; }
Note that we interpolate the vectors based on the difference in tiles on the Y axis. When old and new positions are within the same tile, the vertical distance will be zero, so in that case we wouldn't be able to divide by the distance. So to solve this issue, we want the distance to have a minimum value of 1, so that if such a scenario were to happen (and it's going to happen very often), we'll simply be using the new position for collision detection.
Finally, for each iteration, we need to execute the same code we did already for checking the ground collision along the width of the object.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY) { var oldCenter = oldPosition + mAABBOffset; var center = position + mAABBOffset; var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right; var newBottomRight = new Vector2(newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y); int endY = mMap.GetMapTileYAtPoint(newBottomLeft.y); int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY); int dist = Mathf.Max(Mathf.Abs(endY - begY), 1); int tileIndexX; for (int tileIndexY = begY; tileIndexY >= endY; --tileIndexY) { var bottomLeft = Vector2.Lerp(newBottomLeft, oldBottomLeft, (float)Mathf.Abs(endY - tileIndexY) / dist); var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y); for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize) { checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x); tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x); groundY = (float)tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y; if (mMap.IsObstacle(tileIndexX, tileIndexY)) return true; if (checkedTile.x >= bottomRight.x) break; } } return false; }
That's pretty much it. As you may imagine, if the game's objects move really fast, this way of checking collision can be quite a bit more expensive, but it also reassures us that there will be no weird glitches with objects moving through solid walls.
Summary
Phew, that was more code than we thought we'd need, wasn't it? If you spot any errors or possible shortcuts to take, let me and everyone know in the comments! The collision check should be robust enough so that we don't have to worry about any unfortunate events of objects slipping through the tilemap's blocks.
A lot of the code was written to make sure that there are no objects passing through the tiles at big speeds, but if that's not a problem for a particular game, we could safely remove the additional code to increase the performance. It might even be a good idea to have a flag for specific fast-moving objects, so that only those use the more expensive versions of the checks.
We still have a lot of things to cover, but we managed to make a reliable collision check for the ground, which can be mirrored pretty straightforwardly to the other three directions. We'll do that in the next part.