Quantcast
Channel: Envato Tuts+ Game Development
Viewing all articles
Browse latest Browse all 728

Basic 2D Platformer Physics, Part 7: Slopes Groundwork

$
0
0
Final product image
What You'll Be Creating

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.

Slopes Data

Let's also define another slope shape; this one will serve as more of a bump on the ground than anything else.

Another slope shape

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.

Transformations of the 45 degree slope

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.

Rotating slopes

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
Calculating Offsets

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.

Animation of moving down a slope

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.

First of all, let's handle the heightmaps. Let's define a few of them to process later on.

Let's add the test tile types for the defined slopes.

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.

Now let's create an array which will hold all the tiles' heightmaps. This array will be indexed by the TileCollisionType enumeration.

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.

Now let's create a function which will extend the heightmap into the bitmap.

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.

Let's create the container arrays here.

Now let's make every tile collision type point to the corresponding cached data.

Offset Structure

Now we can define our offset structure.

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.

And create the array in the Init function.

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.

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.

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.

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.

No overlaps

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.

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.

Finally, let's adjust the size of the object-tile overlapping area if the object moves outside the bounds of the tile.

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.

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.

Now let's do the same thing for the down and right offsets.

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.

Move the object out of collision

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.

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 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.

Let's do the same for the left and right offsets.

Caching the Data

Now that all the offsets are calculated, we can return the offset for this particular data set.

Let's create a container for all our cached data.

Initialize the array.

And finally, create the caching function.

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.

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.

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.

Now let's calculate the edges of the tile in the world space.

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.

Now we can retrieve the cached offsets for the overlap.

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.

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.

Once we're done, we can return the adjusted offset. The finished function should look like this.

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.

Checking 1-pixel collisions

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.

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.

Now let's declare an instance of the struct for the object.

Another variable that we'll need is slope sticking.

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.

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.

Now let's iterate through all the tiles along the object's right edge.

Now, depending on the collision tile, we react appropriately.

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:

We'll do the same for all the other three directions: left, up, and down.

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.

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.

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.

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.

If we didn't find an obstacle, we can move the object.

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.

The MoveY function works similarly.

Consolidate the Movement

Now that we have functions responsible for vertical and horizontal movement, we can create the main function responsible for movement. 

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.

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.

We also need to get the step vector. It will be used as a direction in which we'll be moving our object.

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.

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 only the y value is 0, we're going to move only horizontally.

If only the x value is 0, we're going to move only vertically.

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.

Let's also declare the vertical accumulator which will hold how many pixels we need to move vertically with each loop.

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.

Now let's calculate how many pixels vertically we should move the object.

For the movement, we first move one step horizontally.

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.

All in all, the function should look like this:

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.

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.

CollidesWithTiles simply calls all the collision functions we've written.

Then update the speed.

And update the position. First off, let's save the old one.

Calculate the new one.

Calculate the offset between the two.

Now, in case the offset is non-zero, we can call our Move function.

Finally, update the object's AABB and the position state.

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!


Viewing all articles
Browse latest Browse all 728

Trending Articles