Simple platformer game in XNA tutorial – part five "Improved collision detection"
Previously on xnafan.net...
We looked at implementing simple collision detection, but still had a way to go before we're satisfied with the result.
Agenda
In this part of the tutorial we will
- add gravity
- add some debug info to the game
- add more realistic collision and movement to the Jumper
Adding gravity
Go ahead and add a new line AffectWithGravity() to Jumper's Update():
public void Update(GameTime gameTime) { CheckKeyboardAndUpdateMovement(); AffectWithGravity(); SimulateFriction(); MoveIfPossible(gameTime); }
Add the AffectWithGravity() method as well
private void AffectWithGravity() { Movement += Vector2.UnitY * .5f; }
This line adds half a pixel of vertical movement (downward) using the constant UnitY
(which is (0,1) you may recall :)). We may have to adjust this later, but for now we have something to work with ;).
I'm stuck!!
Now you can see that Jumper will fall whenever he is unsupported, but we also get some unwanted sideeffects, the most annoying being - we can't move once we've hit the floor :(, and sometimes we don't even hit the floor:
In situations like this one, it is a good idea to add some debug info to your game so you can see what is going on.
Adding debug info to the game
To write something onscreen you will need a SpriteFont in your content project. So go ahead and add a SpriteFont to your contentproject:
and name it "DebugFont"
Add a SpriteFont membervariable to the SimplePlatformerGame class
private SpriteFont _debugFont;
and load the font in the LoadContent method of SimplePlatformerGame
_debugFont = Content.Load<SpriteFont>("DebugFont");
Update Draw to show debug info
Then add some lines to the SimplePlatformerGame's Draw method to show where Jumper is, and where he is headed:
protected override void Draw(GameTime gameTime) { string positionInText = string.Format("Position of Jumper: ({0:0.0}, {1:0.0})", _jumper.Position.X, _jumper.Position.Y); string movementInText = string.Format("Current movement: ({0:0.0}, {1:0.0})", _jumper.Movement.X, _jumper.Movement.Y); GraphicsDevice.Clear(Color.WhiteSmoke); _spriteBatch.Begin(); base.Draw(gameTime); _board.Draw(); _spriteBatch.DrawString(_debugFont, positionInText, new Vector2(10, 0), Color.White); _spriteBatch.DrawString(_debugFont, movementInText, new Vector2(10, 20), Color.White); _jumper.Draw(); _spriteBatch.End(); }
If you are unfamiliar with String.Format(), have a look here. Basically you pass it a string with placeholders named "{0}", "{1}", etc. for all the variables you want inserted into it. This makes it easier to read the format of the string, since formatting and data are kept separate.
You can add conversion information inside the brackets, using a separating colon.
E.g. {0:c} for currency or {0:0.0} for one decimal.
When .NET renders the result on screen you will see that the decimal separator on my screendumps is a comma, not a period, as my PC uses european (da-DK) culture :).
Now we can see what is going on
Jumper still has a desired vertical downward movement of 4.5, which makes him want to bury himself.
Q: Why is Jumper sometimes floating?
A: Same reason why we couldn't get close to the wall when moving sideways until we let go of the left/right key: the movement we're attempting would make Jumper end up inside a blocked Tile, and the Board object's HasRoomForRectangle() won't allow that move.
Q: Why can't we move sideways?
A: We haven't stopped Jumper's downward movement when he landed on the ground, which means that even if we try to move sideways, Jumper would still try to move downward and sideways.
Let's fix the floating first by improving how we handle leftover movement for Jumper.
Improving Jumper's movement
Here you can see what we want to accomplish.
In the illustration above, Jumper is moving diagonally down and left. The current Movement for Jumper wants to move him to the end of the diagonal red arrow in this Update().
We want that movement to be stopped right when he hits the horizontal row of blocks, and have the leftover motion carry over into horizontal movement, which only terminates when he hits the vertical wall of blocks a bit later.
Algorithm
What we're going to implement is a function WhereCanIGetTo on the Board class which
- gets the origin and destination of a Rectangle
- breaks down that movement into a number of half pixel steps
- checks for every step whether it is blocked
- if it is blocked, tries to carry over any diagonal movement into vertical or horizontal movement
- returns where it was possible to move the Rectangle to
Creating the WhereCanIGetTo() method
Go ahead and add a method WhereCanIGetTo() to the Board class:
public Vector2 WhereCanIGetTo(Vector2 originalPosition, Vector2 destination, Rectangle boundingRectangle) { }
The reason we don't send the bounding rectangle of Jumper to the method, is that Rectangle uses ints for positioning, and we need more finegrained movement here.
We're going to do some vector math here, so if you're not used to that, have a look at this :).
Before we begin stepping along the path from origin to destination, we need some variables. These variables will be used to store
- the complete movement we want to try (the distance from origin to destination)
- the furthest available location we've found so far
- the direction only (without distance)
- the direction and length of one step
- the number of steps we want to break movement into
Add some code to calculate these values. First calculate the movement from origin to destination:
Vector2 movementToTry = destination - originalPosition;
This means that if Jumper was at wants to go to (80, 120) and starts out at (100,100), the movement he wants to carry out is (80 - 100, 120 - 100) = (-20, 20), or in other words, -20 on the x-axis (meaning left) and 20 on the y-axis meaning down.
we assume that the originalPosition is in a nonblocked area, so we use the original position as the furthest possible location we have found along the path we want to travel
Vector2 furthestAvailableLocationSoFar = originalPosition;
to figure out how many steps we want to break the movement into, we multiply the length of the movement by 2 (so we approximately try once per half pixel), and add one, to make sure we at least try one step for very small movements
int numberOfStepsToBreakMovementInto = (int)(movementToTry.Length() * 2) + 1;
In the example mentioned above ((-20,20).Length() being approximately 28.3) this would work out at
(28.3 * 2) ≈ 57 + 1 = 58 small steps
And finally figure out how far one step is by dividing the entire move by the number of steps
Vector2 oneStep = movementToTry / numberOfStepsToBreakMovementInto;
Each step would be (-20,20) / 58 ≈ (-0.34, 0.34)
One small step at a time
Now that we have these values we can make a loop where we:
- keep trying the next step along the movementToTry and see if we can go there. We do this by creating a Rectangle at that position and asking HasRoomForRectangle whether it is blocked
- if that move was unblocked, we store that position in furthestAvailableLocationSoFar and continue
- If that place is blocked, we exit the loop and return furthestAvailableLocation
Before we begin coding that, we need functionality to create a new Rectangle at a given position, so we have something to test each step with. So add a new function to the Board class which receives a Vector2 and a width and height, and creates a Rectangle at that position.
private Rectangle CreateRectangleAtPosition(Vector2 positionToTry, int width, int height) { return new Rectangle((int)positionToTry.X, (int)positionToTry.Y, width, height); }
We will lose some precision in converting the floats from positionToTry to ints, but we will finetune that later.
Now we have the basic skeleton of our improved collision detection up and running. So update the WhereCanIGetTo method. I suggest you don't copy and paste the code, but code it while making sure you understand every step of it, but suit yourself .
public Vector2 WhereCanIGetTo(Vector2 originalPosition, Vector2 destination, Rectangle boundingRectangle) { Vector2 movementToTry = destination - originalPosition; Vector2 furthestAvailableLocationSoFar = originalPosition; int numberOfStepsToBreakMovementInto = (int)(movementToTry.Length() * 2) + 1; Vector2 oneStep = movementToTry / numberOfStepsToBreakMovementInto; for (int i = 1; i <= numberOfStepsToBreakMovementInto; i++) { Vector2 positionToTry = originalPosition + oneStep * i; Rectangle newBoundary = CreateRectangleAtPosition(positionToTry, boundingRectangle.Width, boundingRectangle.Height); if (HasRoomForRectangle(newBoundary)) { furthestAvailableLocationSoFar = positionToTry; } else { break; } } return furthestAvailableLocationSoFar; }
Q: Why do you iterate from "1 to i <= numberOfSteps..." instead of "0 to i < numberOfSteps..." ?
A: Because it wouldn't make much sense to take a "zero" step which was right on top of the original position.
If we did, positionToTry would be originalPosition + (oneStep * 0) on the first step, and then we would end up one step from the finishing point instead of on it.
Figure it this way: We want to move ten meters, do we do this in ten steps: 0 meters, then 1 meters ... up to 9 meters, or would it make more sense to move 1 meter, then 2 meters ... up to 10 meters?
Finally, change the MoveIfPossible() method on Jumper. Delete the last line where we let any movement depend on whether the final destination was unblocked:
if (!Board.CurrentBoard.HasRoomForRectangle(Bounds)) { Position = oldPosition; }
and change it to:
Position = Board.CurrentBoard.WhereCanIGetTo(oldPosition, Position, Bounds);
...so we move as far as possible along the wanted route instead
Also update the MoveIfPossible name to MoveAsFarAsPossible, to better reflect that it is no longer a do/don't move decision, but a movement within the confines of the possible. Now your MoveIfPossible method should look like this:
private void MoveAsFarAsPossible(GameTime gameTime) { Vector2 oldPosition = Position; UpdatePositionBasedOnMovement(gameTime); Position = Board.CurrentBoard.WhereCanIGetTo(oldPosition, Position, Bounds); }
Press F5 to run the program and try moving around. It is better, but not perfect. Especially because we can "stick to walls" :).
Here's a little status quo explanation:
If you start out stuck...
Sometimes your little Jumper will start out stuck in a Tile like this:
To make sure that he is born a free little Sprite, you can set that tile's IsBlocked property to false when you construct a board object.
Create a well named method like this:
private void SetTopLeftTileUnblocked() { Tiles[1, 1].IsBlocked = false; }
And then call SetTopLeftTileUnblocked() in Board's constructor:
public Board(SpriteBatch spritebatch, Texture2D tileTexture, int columns, int rows) { Columns = columns; Rows = rows; TileTexture = tileTexture; SpriteBatch = spritebatch; Tiles = new Tile[Columns, Rows]; InitializeAllTilesAndBlockSomeRandomly(); SetAllBorderTilesBlocked(); SetTopLeftTileUnblocked(); Board.CurrentBoard = this; }
Recycle leftover diagonal movement as horizontal or vertical movement
Right now our movement ends as soon as we take a step which ends in a blocked position.
What we want to do is:
If we get blocked before finishing a move:
- find out if we we're moving diagonally when we got blocked, and if we were:
- try to move as far horizontally and/or vertically as possible and return the farthest possible location
We can illustrate it like this: Here we hit a blocked Tile about halfway into the move the Jumper is trying to make. When that happens we check whether it is a diagonal move (neither of the X and Y part of the movement vector is zero), and then we test movement using the remaining movement along the X and Y axis.
Remember you can turn a movement vector into horizontal movement by multiplying it by Vector2.UnitX (thereby setting its movement on the y-axis to zero) and into vertical movement by multiplying it by Vector2.UnitY (setting its x-axis movement to zero).
"If we get blocked before finishing a move"
The place to add code for this case is inside the else part of the loop in the WhereCanIGetTo() method
if (HasRoomForRectangle(newBoundary)) { furthestAvailableLocationSoFar = positionToTry; } else { //here! break; }
"If it is a diagonal move"
Create a boolean variable to store whether it is a diagonal move, and add it to the beginning of the else in WhereCanIGetTo(). As you can see, we store a true if neither the x- nor the y-movement is zero (think about it! :)).
Add an if where we can add code to handle remaining diagonal movement
bool isDiagonalMove = movementToTry.X != 0 && movementToTry.Y != 0; if (isDiagonalMove) { }
To calculate the steps left, we have to subtract the step we just tried, as that moved us into a blocked area (if HasRoomForRectangle() returned true we wouldn't be down here handling all the messy details :)), and subtract the result from however many steps we were supposed to take on the entire path.
int stepsLeft = numberOfStepsToBreakMovementInto - (i - 1);
Example: We want to move 10 steps in this complete Update(). When testing step 7, we find it to be blocked, so we subtract 1 from 7 to find the last valid position (7-1 = 6), and then subtract that step from the entire trip (10 - 6 = 4) to find out how many steps we still need to try.
"try to move as far horizontally and/or vertically as possible"
We're almost there now - we can see the finishing line ... so let's perform the final sprint!
As mentioned earlier, to get only the horizontal/vertical movement part of a vector, we multiply by Vector2.UnitX or Vector2.UnitY respectively. So for each type of movement, we calculate the remaining movement in that direction, find out where we want to end up of we completed that movement by adding the remaining movement to furthestAvailableLocationSoFar.
Now we have the position to start from, and where we want to end up ... if only there were some way of finding out how far little Jumper could get to along that path...? 😉
"But there IS!" (I hear you cry!)
"Just feed those two positions right back into WhereCanIGetTo(), and it'll tell you!".
Right you are - so here is the final part of our if (isDiagonalMove) {... }
Vector2 remainingHorizontalMovement = oneStep.X * Vector2.UnitX * stepsLeft; Vector2 finalPositionIfMovingHorizontally = furthestAvailableLocationSoFar + remainingHorizontalMovement; furthestAvailableLocationSoFar = WhereCanIGetTo(furthestAvailableLocationSoFar, finalPositionIfMovingHorizontally, boundingRectangle); Vector2 remainingVerticalMovement = oneStep.Y * Vector2.UnitY * stepsLeft; Vector2 finalPositionIfMovingVertically = furthestAvailableLocationSoFar + remainingVerticalMovement; furthestAvailableLocationSoFar = WhereCanIGetTo(furthestAvailableLocationSoFar, finalPositionIfMovingVertically, boundingRectangle);
The calling of a function from itself is called recursion. In a lot of cases the calling can be nested many times deep, but we only ever call two layers deep. When calling functions recursively it is very important to have a criteria for when to stop, otherwise the program enters an infinite loop. The reason our calling stops is that the recursion is only performed when movement is diagonal, and the parameters to the second call to WhereCanIGetTo is never diagonal.
Go ahead and try it out, you will see that little Jumper no longer sticks to walls or floors, and slides right along - YAY! Go celebrate with a cup of coffee/cola/juice/water... 😀
The final version of WhereCanIGetTo
Read it through and see if there is something you still don't understand. If there is, now is the time to scroll back up and read the explanation again
public Vector2 WhereCanIGetTo(Vector2 originalPosition, Vector2 destination, Rectangle boundingRectangle) { Vector2 movementToTry = destination - originalPosition; Vector2 furthestAvailableLocationSoFar = originalPosition; int numberOfStepsToBreakMovementInto = (int)(movementToTry.Length() * 2) + 1; Vector2 oneStep = movementToTry / numberOfStepsToBreakMovementInto; for (int i = 1; i <= numberOfStepsToBreakMovementInto; i++) { Vector2 positionToTry = originalPosition + oneStep * i; Rectangle newBoundary = CreateRectangleAtPosition(positionToTry, boundingRectangle.Width, boundingRectangle.Height); if (HasRoomForRectangle(newBoundary)) { furthestAvailableLocationSoFar = positionToTry; } else { bool isDiagonalMove = movementToTry.X != 0 && movementToTry.Y != 0; if (isDiagonalMove) { int stepsLeft = numberOfStepsToBreakMovementInto - (i - 1); Vector2 remainingHorizontalMovement = oneStep.X * Vector2.UnitX * stepsLeft; Vector2 finalPositionIfMovingHorizontally = furthestAvailableLocationSoFar + remainingHorizontalMovement; furthestAvailableLocationSoFar = WhereCanIGetTo(furthestAvailableLocationSoFar, finalPositionIfMovingHorizontally, boundingRectangle); Vector2 remainingVerticalMovement = oneStep.Y * Vector2.UnitY * stepsLeft; Vector2 finalPositionIfMovingVertically = furthestAvailableLocationSoFar + remainingVerticalMovement; furthestAvailableLocationSoFar = WhereCanIGetTo(furthestAvailableLocationSoFar, finalPositionIfMovingVertically, boundingRectangle); } break; } } return furthestAvailableLocationSoFar; }
This method is too long for my tastes. But simplifying it would mean having to put some of the code into other methods and passing along a lot of parameters (which is also not optimal for readability), or encapsulating the functionality in a small class. At the end of the next and final part of this tutorial, I will show you how that can be achieved.
What we've covered
After this part of the tutorial you should have learnt that
- Gravity can be implemented simply by adding a downward momentum in every Update
- You can benefit from adding debug information in your games to let you know why something is happening
- You can reuse a method inside itself using recursion, and you should always ensure you have a stopping condition, so the program doesn't enter an infinite loop
The code so far
Is here
Next up...
In the final part of this tutorial we will have a look at how to make Jumper jump, and how to stop his Movement when he hits something.