In this tutorial, we'll extend our grid-based platformer pathfinder so that it can cope with characters that take up more than one cell of the grid.
If you haven't added one-way platform support to your code yet, I recommend you do so, but it's not necessary in order to follow this tutorial.
Demo
You can play the Unity demo, or the WebGL version (100MB+), to see the final result in action. Use WASD to move the character, left-click on a spot to find a path you can follow to get there, right-click a cell to toggle the ground at that point, and click-and-drag the sliders to change their values.
Character Position
The pathfinder accepts the position, width, and height of the character as an input. While width and height are easy to interpret, we need to to clarify which block the position coordinates refer to.
The position that we pass needs to be in terms of map coordinates, which means that, yet again, we need to embrace some inaccuracy. I decided that it would be sensible to make the position refer to the bottom-left character tile, since this matches the map coordinate system.
With that cleared up, we can update the pathfinder.
Checking That the Goal is Realistic
First, we must make sure that our custom-sized character can fit in the destination location. Until this point, we've only checked one block to do this, as that was the maximum (and only) size of the character:
if (mGrid[end.x, end.y] == 0) return null;
Now, however, we need to iterate through every cell that the character would occupy if it were standing in the end position, and check whether any of them are a solid block. If they are, then of course the character cannot stand there, so the goal cannot be reached.
To do this, let's first declare a Boolean which we will set to false
if the character is in a solid tile and true
otherwise:
var inSolidTile = false;
Next, we'll iterate through every block of the character:
for (var w = 0; w < characterWidth; ++w) { for (int h = 0; h < characterHeight; ++h) { } }
Inside this loop, we need to check whether a particular block is solid; if so, we set inSolidTile
to true
, and exit the loop:
for (var w = 0; w < characterWidth; ++w) { for (int h = 0; h < characterHeight; ++h) { if (mGrid[end.x + w, end.y + h] == 0 || mGrid[end.x + w, end.y + h] == 0) { inSolidTile = true; break; } } if (inSolidTile) break; }
But this is not enough. Consider the following situation:
If we were to move the character so that its bottom-left block occupied the goal, then the bottom-right block would be stuck in a solid block—so the algorithm would think that, since the character doesn't fit the goal position, it is impossible to reach the end point. Of course, that's not true; we don't care which part of the character reaches the goal.
To solve this problem, we will move the end point to the left, step by step, up to the point where the original goal location would match the bottom-right character block:
for (var i = 0; i < characterWidth; ++i) { inSolidTile = false; for (var w = 0; w < characterWidth; ++w) { for (var h = 0; h < characterHeight; ++h) { if (mGrid[end.x + w, end.y + h] == 0 || mGrid[end.x + w, end.y + h] == 0) { inSolidTile = true; break; } } if (inSolidTile) break; } if (inSolidTile) end.x -= 1; else break; }
Note that we shouldn't simply check the bottom left and right corners, because the following case may occur:
Here, you can see that if either of the bottom corners occupy the goal location, then the character would still be in solid ground on the other side. In this case, we need to match the bottom-center block with the goal.
Finally, if we can't find any place where the character would fit, we might as well exit the algorithm early:
if (inSolidTile == true) return null;
Determining the Starting Position
To see whether our character is on the ground, we need to check whether any of the character's bottom-most cells are directly above a solid tile.
Let's look at the code we used for a 1x1 character:
if (mMap.IsGround(start.x, start.y - 1)) firstNode.JumpLength = 0; else firstNode.JumpLength = (short)(maxCharacterJumpHeight * 2);
We determine whether the starting point is on the ground by checking whether the tile immediately below the starting point is a ground tile. To update the code, we'll simply make it check below all of the bottom-most blocks of the character.
First, let's declare a Boolean that will tell us whether the character starts on the ground. Initially, we assume that it doesn't:
bool startsOnGround = false;
Next, we'll iterate through all the bottom-most character blocks and check whether any of them are directly above a ground tile. If so, then we set startsOnGround
to true
and exit the loop:
for (int x = start.x; x < start.x + characterWidth; ++x) { if (mMap.IsGround(x, start.y - 1)) { startsOnGround = true; break; } }
Finally, we set the jump value depending on whether the character started on the ground:
if (startsOnGround) firstNode.JumpLength = 0; else firstNode.JumpLength = (short)(maxCharacterJumpHeight * 2);
Checking the Successor's Bounds
We need to change our successor's bounds check as well, but here we don't need to check every tile. It's good enough to check the contour of the character—the blocks around the edge—because we know that the parent's position was fine.
Let's look how we checked the successor's bounds previously:
if (mGrid[mNewLocationX, mNewLocationY] == 0) continue; if (mMap.IsGround(mNewLocationX, mNewLocationY - 1)) onGround = true; else if (mGrid[mNewLocationX, mNewLocationY + characterHeight] == 0) atCeiling = true;
We'll update this by checking whether any of the contour blocks are within a solid block. If any of them does, then the character cannot fit in the position and the successor should be skipped.
Checking the Top and Bottom Blocks
First, let's iterate over all the top-most and bottom-most blocks of the character, and check whether they overlap a solid tile on our grid:
for (var w = 0; w < characterWidth; ++w) { if (mGrid[mNewLocationX + w, mNewLocationY] == 0 || mGrid[mNewLocationX + w, mNewLocationY + characterHeight - 1] == 0) goto CHILDREN_LOOP_END; }
The CHILDREN_LOOP_END
label leads to the end of the successor loop; by using it, we skip the need to first break
out of the loop and then continue
to the next successor in the successor loop.
When a Tile in the Air Can Be Considered "OnGround"
If any of the bottom blocks are right above a solid tile, then the successor must be on the ground. This means that, even if there's no solid tile directly under the successor cell itself, the successor will still be considered an OnGround
node, if the character is wide enough.
for (var w = 0; w < characterWidth; ++w) { if (mGrid[mNewLocationX + w, mNewLocationY] == 0 || mGrid[mNewLocationX + w, mNewLocationY + characterHeight - 1] == 0) goto CHILDREN_LOOP_END; if (mMap.IsGround(mNewLocationX + w, mNewLocationY - 1)) onGround = true; }
Checking Whether the Character is at the Ceiling
If any of the tiles above the character are solid, then the character is at the ceiling.
for (var w = 0; w < characterWidth; ++w) { if (mGrid[mNewLocationX + w, mNewLocationY] == 0 || mGrid[mNewLocationX + w, mNewLocationY + characterHeight - 1] == 0) goto CHILDREN_LOOP_END; if (mMap.IsGround(mNewLocationX + w, mNewLocationY - 1)) onGround = true; if (mGrid[mNewLocationX + w, mNewLocationY + characterHeight] == 0) atCeiling = true; }
Checking the Blocks at the Sides of the Character
Now we just need to check that there aren't any solid blocks in the left and right cells of the character. If there are, then we can safely skip the successor, because our character won't fit that particular position:
for (var h = 1; h < characterHeight - 1; ++h) { if (mGrid[mNewLocationX, mNewLocationY + h] == 0 || mGrid[mNewLocationX + characterWidth - 1, mNewLocationY + h] == 0) goto CHILDREN_LOOP_END; }
Conclusion
We've removed a fairly significant restriction from the algorithm; now, you have much more freedom in terms of the size of your game's characters.
In the next tutorial in the series, we'll use our pathfinding algorithm to power a bot that can follow the path itself; just click on a location and it'll run and jump to get there. This is very useful for NPCs!