Simple platformer game in XNA tutorial – part six "jumping and stopping"
Sunday, April 28th, 2013Previously on xnafan.net...
We looked at how to improve collision detection, and use recursion to have a method call itself.
In this final installment we will
- enable Jumper to Jump
- stop movement when blocked
- refactor our collision code to increase readability
- make a few minor improvements
So let's get to it
Enabling Jumper to jump
First of all, we only want Jumper to be able to jump when he has a solid surface under his feet. We can test this by checking if a Rectangle one pixel lower than Jumper's bounding rectangle would be blocked. If that is the case, we let him jump.
So go ahead and introduce this method in the Jumper class. Notice the "!" in front of the last line. That means not. Which means that if HasRoomForRectangle is true (Jumper is floating) then we return NOT true (we're not on firm ground) and vice versa.
public bool IsOnFirmGround() { Rectangle onePixelLower = Bounds; onePixelLower.Offset(0, 1); return !Board.CurrentBoard.HasRoomForRectangle(onePixelLower); }
Rectangle.Offset() moves a Rectangle in the direction given in the x and y parameters
Change the Up key's code
...so we only allow jumps if Jumper is on firm ground. And then boost the upward movement with a value you like for his movement.
if (keyboardState.IsKeyDown(Keys.Up) && IsOnFirmGround()) { Movement = -Vector2.UnitY * 25; }
Important!
Notice that I've changed the Movement -= Vector2.UnitY * 25;
to Movement = -Vector2.UnitY * 25;
because no matter whether we were falling fast when we hit the floor, we don't want our upward momentum to have any memory of our downward speed (which would be the result of using the -= operator), so we just set it to a firm negative 25 upward.
Go ahead and fiddle around with the values in AffectWithGravity and SimulateFriction until you are satisfied with how Jumper moves.
Updating debug information
Go ahead and add some additional debug information to the game, so you can see whether Jumper is on firm ground.
And since our Draw method is getting pretty long, refactor the it, so you extract the writing of debug information in a separate method.
protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.WhiteSmoke); _spriteBatch.Begin(); base.Draw(gameTime); _board.Draw(); WriteDebugInformation(); _jumper.Draw(); _spriteBatch.End(); } private void WriteDebugInformation() { 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); string isOnFirmGroundText = string.Format("On firm ground? : {0}", _jumper.IsOnFirmGround()); _spriteBatch.DrawString(_debugFont, positionInText, new Vector2(10, 0), Color.White); _spriteBatch.DrawString(_debugFont, movementInText, new Vector2(10, 20), Color.White); _spriteBatch.DrawString(_debugFont, isOnFirmGroundText, new Vector2(10, 40), Color.White); }
Stop movement when blocked
This is as simple as looking at what WhereCanIGetTo returns, and ensure that movement on either axis is set to zero if that direction was blocked.
To be able to find out whether we were able to move in this update, we need access to the previous position of Jumper, so add a private member variable to Jumper
private Vector2 oldPosition;
and a method:
private void StopMovingIfBlocked() { Vector2 lastMovement = Position - oldPosition; if (lastMovement.X == 0) { Movement *= Vector2.UnitY; } if (lastMovement.Y == 0) { Movement *= Vector2.UnitX; } }
What happens in StopMovingIfBlocked is that we get the last movement by subtracting where Jumper was before he moved from where he ended up. If there was no movement on an axis, we set that part of the current movement to zero.
We will then call StopMovingIfBlocked as the last line of Jumper's Update():
public void Update(GameTime gameTime) { CheckKeyboardAndUpdateMovement(); AffectWithGravity(); SimulateFriction(); MoveAsFarAsPossible(gameTime); StopMovingIfBlocked(); }
This is my final Jumper code
public class Jumper : Sprite { public Vector2 Movement { get; set; } private Vector2 oldPosition; public Jumper(Texture2D texture, Vector2 position, SpriteBatch spritebatch) : base(texture, position, spritebatch) { } public void Update(GameTime gameTime) { CheckKeyboardAndUpdateMovement(); AffectWithGravity(); SimulateFriction(); MoveAsFarAsPossible(gameTime); StopMovingIfBlocked(); } private void CheckKeyboardAndUpdateMovement() { KeyboardState keyboardState = Keyboard.GetState(); if (keyboardState.IsKeyDown(Keys.Left)) { Movement -= Vector2.UnitX; } if (keyboardState.IsKeyDown(Keys.Right)) { Movement += Vector2.UnitX; } if (keyboardState.IsKeyDown(Keys.Up) && IsOnFirmGround()) { Movement = -Vector2.UnitY * 20; } } private void AffectWithGravity() { Movement += Vector2.UnitY * .65f; } private void SimulateFriction() { Movement -= Movement * Vector2.One * .03f; } private void MoveAsFarAsPossible(GameTime gameTime) { oldPosition = Position; UpdatePositionBasedOnMovement(gameTime); Position = Board.CurrentBoard.WhereCanIGetTo(oldPosition, Position, Bounds); } private void UpdatePositionBasedOnMovement(GameTime gameTime) { Position += Movement * (float)gameTime.ElapsedGameTime.TotalMilliseconds / 15; } public bool IsOnFirmGround() { Rectangle onePixelLower = Bounds; onePixelLower.Offset(0, 1); return !Board.CurrentBoard.HasRoomForRectangle(onePixelLower); } private void StopMovingIfBlocked() { Vector2 lastMovement = Position - oldPosition; if (lastMovement.X == 0) { Movement *= Vector2.UnitY; } if (lastMovement.Y == 0) { Movement *= Vector2.UnitX; } } }
Refactor collision code to improve readability
If you go back and look at Board's WhereCanIGetTo method, you'll see that it is over thirty lines long:
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; }
If we could get that down to about ten lines we might increase readability. So see what I do here, and if you like it, copy it. If you don't - then don't .
Wrapping multiple variables and related functionality into a wrapper
First of all we have four lines which are just initialization. We could wrap all those variables and the math up into a structure, and initialize it with the same data that WhereCanIGetTo receives. Add a new class to your SimplePlatformerGame project, name the file MovementWrapper.cs and move all this code
public struct MovementWrapper { public Vector2 MovementToTry { get; private set; } public Vector2 FurthestAvailableLocationSoFar { get; set; } public int NumberOfStepsToBreakMovementInto { get; private set; } public bool IsDiagonalMove { get; private set; } public Vector2 OneStep { get; private set; } public Rectangle BoundingRectangle { get; set; } public MovementWrapper(Vector2 originalPosition, Vector2 destination, Rectangle boundingRectangle) : this() { MovementToTry = destination - originalPosition; FurthestAvailableLocationSoFar = originalPosition; NumberOfStepsToBreakMovementInto = (int)(MovementToTry.Length() * 2) + 1; IsDiagonalMove = MovementToTry.X != 0 && MovementToTry.Y != 0; OneStep = MovementToTry / NumberOfStepsToBreakMovementInto; BoundingRectangle = boundingRectangle; } }
We use a struct instead of a class, since objects (instantiated from classes) are only cleaned out (garbage collected) at irregular intervals, which might make our game stutter. Structs (like int, float, Vector2, etc.) are garbage collected the moment you leave the "{...}" in which they were created.
The most important gotcha to remember when working with structs is that you always get a copy of it when you pass it to a method, so you can't just pass it to a method, change some values on the struct in the method, and then expect to see the changes reflected in the reference to the struct that you had outside the method (see example 2 for more info on this).
That not only made four lines into one, it also collected all the information about the move we're about to perform into one neat little package, which makes it easier to refactor further, and extract methods from whatever else we're performing, just wait and see . Now see what our WhereCanIGetTo method looks like
public Vector2 WhereCanIGetTo(Vector2 originalPosition, Vector2 destination, Rectangle boundingRectangle) { MovementWrapper move = new MovementWrapper(originalPosition, destination); for (int i = 1; i <= move.NumberOfStepsToBreakMovementInto; i++) { Vector2 positionToTry = originalPosition + move.OneStep * i; Rectangle newBoundary = CreateRectangleAtPosition(positionToTry, boundingRectangle.Width, boundingRectangle.Height); if (HasRoomForRectangle(newBoundary)) {move.FurthestAvailableLocationSoFar = positionToTry; } else { if (move.IsDiagonalMove) { int stepsLeft = move.NumberOfStepsToBreakMovementInto - (i - 1); Vector2 remainingHorizontalMovement = move.OneStep.X * Vector2.UnitX * stepsLeft; Vector2 finalPositionIfMovingHorizontally = move.FurthestAvailableLocationSoFar + remainingHorizontalMovement; move.FurthestAvailableLocationSoFar = WhereCanIGetTo(move.FurthestAvailableLocationSoFar, finalPositionIfMovingHorizontally, boundingRectangle); Vector2 remainingVerticalMovement = move.OneStep.Y * Vector2.UnitY * stepsLeft; Vector2 finalPositionIfMovingVertically = move.FurthestAvailableLocationSoFar + remainingVerticalMovement; move.FurthestAvailableLocationSoFar = WhereCanIGetTo(move.FurthestAvailableLocationSoFar, finalPositionIfMovingVertically, boundingRectangle); } break; } } return move.FurthestAvailableLocationSoFar; }
Basically, we just updated the references to the four variables to use the variables contained in MovementWrapper instead.
Refactor nondiagonal movement out into a separate method
Since we have all the variables for moving in the MovementWrapper struct, we can easily refactor the nondiagonal part of the movement out in a separate method. First create the method CheckPossibleNonDiagonalMovement, and move the code there.
private Vector2 CheckPossibleNonDiagonalMovement(MovementWrapper wrapper, int i) { if (wrapper.IsDiagonalMove) { int stepsLeft = wrapper.NumberOfStepsToBreakMovementInto - (i - 1); Vector2 remainingHorizontalMovement = wrapper.OneStep.X * Vector2.UnitX * stepsLeft; wrapper.FurthestAvailableLocationSoFar = WhereCanIGetTo(wrapper.FurthestAvailableLocationSoFar, wrapper.FurthestAvailableLocationSoFar + remainingHorizontalMovement, wrapper.BoundingRectangle); Vector2 remainingVerticalMovement = wrapper.OneStep.Y * Vector2.UnitY * stepsLeft; wrapper.FurthestAvailableLocationSoFar = WhereCanIGetTo(wrapper.FurthestAvailableLocationSoFar, wrapper.FurthestAvailableLocationSoFar + remainingVerticalMovement, wrapper.BoundingRectangle); } return wrapper.FurthestAvailableLocationSoFar; }
...then go back to WhereCanIGetTo and use our new method
public Vector2 WhereCanIGetTo(Vector2 originalPosition, Vector2 destination, Rectangle boundingRectangle) { MovementWrapper move = new MovementWrapper(originalPosition, destination, boundingRectangle); for (int i = 1; i <= move.NumberOfStepsToBreakMovementInto; i++) { Vector2 positionToTry = originalPosition + move.OneStep * i; Rectangle newBoundary = CreateRectangleAtPosition(positionToTry, boundingRectangle.Width, boundingRectangle.Height); if (HasRoomForRectangle(newBoundary)) {move.FurthestAvailableLocationSoFar = positionToTry; } else { if (move.IsDiagonalMove) { move.FurthestAvailableLocationSoFar = CheckPossibleNonDiagonalMovement(move, i); } break; } } return move.FurthestAvailableLocationSoFar; }
That's much better!
Now I'm satisfied with our codebase - I hope you are too .
Make a few minor improvements
First of all, let's implement some code to restart with a random level whenever we want to.
So go ahead and move the initialization code of the Board constructor into a separate method like this
public Board(SpriteBatch spritebatch, Texture2D tileTexture, int columns, int rows) { Columns = columns; Rows = rows; TileTexture = tileTexture; SpriteBatch = spritebatch; Tiles = new Tile[Columns, Rows]; CreateNewBoard(); Board.CurrentBoard = this; } public void CreateNewBoard() { InitializeAllTilesAndBlockSomeRandomly(); SetAllBorderTilesBlocked(); SetTopLeftTileUnblocked(); }
And add a new method call CheckKeyboardAndReact to listen for keyboard input in SimplePlatformerGame.Update. In This method will react to someone pressing F5, and call a new method RestartGame() which creates a new Board and puts Jumper back in the top left corner:
protected override void Update(GameTime gameTime) { base.Update(gameTime); _jumper.Update(gameTime); CheckKeyboardAndReact(); } private void CheckKeyboardAndReact() { KeyboardState state = Keyboard.GetState(); if (state.IsKeyDown(Keys.F5)) { RestartGame(); } } private void RestartGame() { Board.CurrentBoard.CreateNewBoard(); PutJumperInTopLeftCorner(); } private void PutJumperInTopLeftCorner() { _jumper.Position = Vector2.One * 80; _jumper.Movement = Vector2.Zero; }
Add some code to let the player exit game when ESC is pressed:
private void CheckKeyboardAndReact() { KeyboardState state = Keyboard.GetState(); if (state.IsKeyDown(Keys.F5)) { RestartGame(); } if (state.IsKeyDown(Keys.Escape)) { Exit(); } }
More friction on ground
If you want to you could also add extra friction when Jumper is gliding on a surface
private void SimulateFriction() { if (IsOnFirmGround()) { Movement -= Movement * Vector2.One * .08f; } else { Movement -= Movement * Vector2.One * .02f; } }
There are many small changes you can make. I added some functionality which adds a shadow under our debug text, and changed the jump key to SPACE instead of UP. You go ahead and do whatever you wish - it's your code now
Here's the final result
Play around with it, expand it and have fun!
If you need help with anything in particular, or you want to give feedback on improving my tutorial feel free to leave a comment
The final code
Is here.
/Jake "xnafan"