Basic 2D Platformer Physics, Part 4

Ledge Grabbing

Now that we can jump, drop down from one-way platforms and run around, we can also implement ledge grabbing. Ledge-grabbing mechanics are definitely not a must-have in every game, but it’s a very popular method of extending a player’s possible movement range while still not doing something extreme like a double jump.

Let’s look at how we determine whether a ledge can be grabbed. To determine whether the character can grab a ledge, we’ll constantly check the side the character is moving towards. If we find an empty tile at the top of the AABB, and then a solid tile below it, then the top of that solid tile is the ledge our character can grab onto.

Setting Up Variables

Let’s go to our Character class, where we’ll implement ledge grabbing. There’s no point doing this in the MovingObject class, since most of the objects won’t have an option to grab a ledge, so it would be a waste to do any processing in that direction there.

First, we need to add a couple of constants. Let’s start by creating the sensor offset constants.

public const float cGrabLedgeStartY = 0.0f;
public const float cGrabLedgeEndY = 2.0f;

The cGrabLedgeStartY and cGrabLedgeEndY are offsets from the top of the AABB; the first one is the first sensor point, and the second one is the ending sensor point. As you can see, the character will need to find a ledge within 2 pixels.

We also need an additional constant to align the character to the tile it just grabbed. For our character, this will be set to -4.

public const float cGrabLedgeTileOffsetY = -4.0f;

Aside from that, we’ll want to remember the coordinates of the tile that we grabbed. Let’s save those as a character’s member variable.

public Vector2i mLedgeTile;

Implementation

We’ll need to see if we can grab the ledge from the jump state, so let’s head over there. Right after we check whether the character has landed on the ground, let’s see if the conditions to grab a ledge are fulfilled. The primary conditions are as follows:

  • The vertical speed is less than or equal to zero (the character is falling).
  • The character is not at the ceiling—no use grabbing a ledge if you can’t jump off it.
  • The character collides with the wall and moves towards it.
if (mOnGround)
{
    //if there's no movement change state to standing
    if (KeyState(KeyInput.GoRight) == KeyState(KeyInput.GoLeft))
    {
        mCurrentState = CharacterState.Stand;
        mSpeed = Vector2.zero;
        mAudioSource.PlayOneShot(mHitWallSfx, 0.5f);
    }
    else    //either go right or go left are pressed so we change the state to walk
    {
        mCurrentState = CharacterState.Walk;
        mSpeed.y = 0.0f;
        mAudioSource.PlayOneShot(mHitWallSfx, 0.5f);
    }
}
else if (mSpeed.y <= 0.0f 
    && !mAtCeiling
    && ((mPushesRightWall && KeyState(KeyInput.GoRight)) || (mPushesLeftWall && KeyState(KeyInput.GoLeft))))
{
}

If those three conditions are met, then we need to look for the ledge to grab. Let’s start by calculating the top position of the sensor, which is going to be either the top left or top right corner of the AABB. 

Vector2 aabbCornerOffset;

if (mPushesRightWall && mInputs[(int)KeyInput.GoRight])
    aabbCornerOffset = mAABB.halfSize;
else
    aabbCornerOffset = new Vector2(-mAABB.halfSize.x - 1.0f, mAABB.halfSize.y);

Now, as you may imagine, here we’ll encounter a similar problem to the one we found when implementing the collision checks—if the character is falling very fast, it is actually very likely to miss the hotspot at which it can grab the ledge. That’s why we’ll need to check for the tile we need to grab not starting from the current frame’s corner, but the previous one’s—as illustrated here:

The top image of a character is its position in the previous frame. In this situation, we need to start looking for opportunities to grab a ledge from the top-right corner of the previous frame’s AABB and stop at the current frame’s position.

Let’s get the coordinates of the tiles we need to check, starting by declaring the variables. We’ll be checking tiles in a single column, so all we need is the X coordinate of the column as well as its top and bottom Y coordinates.

int tileX, topY, bottomY;

Let’s get the X coordinate of the AABB’s corner.

int tileX, topY, bottomY;
tileX = mMap.GetMapTileXAtPoint(mAABB.center.x + aabbCornerOffset.x);

We want to start looking for a ledge from the previous frame’s position only if we actually were already moving towards the pushed wall in that time—so our character’s X position didn’t change.

if ((mPushedLeftWall && mPushesLeftWall) || (mPushedRightWall && mPushesRightWall))
{
    topY = mMap.GetMapTileYAtPoint(mOldPosition.y + mAABBOffset.y + aabbCornerOffset.y - Constants.cGrabLedgeStartY);
    bottomY = mMap.GetMapTileYAtPoint(mAABB.center.y + aabbCornerOffset.y - Constants.cGrabLedgeEndY);
}

As you can see, in that case we’re calculating the topY using the previous frame’s position, and the bottom one using that of the current frame. If we weren’t next to any wall, then we’re simply going to see if we can grab a ledge using only the object’s position in the current frame.

if ((mPushedLeftWall && mPushesLeftWall) || (mPushedRightWall && mPushesRightWall))
{
    topY = mMap.GetMapTileYAtPoint(mOldPosition.y + mAABBOffset.y + aabbCornerOffset.y - Constants.cGrabLedgeStartY);
    bottomY = mMap.GetMapTileYAtPoint(mAABB.center.y + aabbCornerOffset.y - Constants.cGrabLedgeEndY);
}
else
{
    topY = mMap.GetMapTileYAtPoint(mAABB.center.y + aabbCornerOffset.y - Constants.cGrabLedgeStartY);
    bottomY = mMap.GetMapTileYAtPoint(mAABB.center.y + aabbCornerOffset.y - Constants.cGrabLedgeEndY);
}

Alright, now that we know which tiles to check, we can start iterating through them. We’ll be going from the top to the bottom, because this order makes the most sense as we allow for ledge grabbing only when the character is falling.

for (int y = topY; y >= bottomY; --y)
{
}

Now let’s check whether the tile we are iterating fulfills the conditions which allow the character to grab a ledge. The conditions, as explained before, are as follows:

  • The tile is empty.
  • The tile below it is a solid tile (this is the tile we want to grab).
for (int y = topY; y >= bottomY; --y)
{
    if (!mMap.IsObstacle(tileX, y)
        && mMap.IsObstacle(tileX, y - 1))
    {
    }
}

The next step is to calculate the position of the corner of the tile we want to grab. This is pretty simple—we just need to get the tile’s position and then offset it by the tile’s size.

if (!mMap.IsObstacle(tileX, y)
        && mMap.IsObstacle(tileX, y - 1))
{
    var tileCorner = mMap.GetMapTilePosition(tileX, y - 1);
    tileCorner.x -= Mathf.Sign(aabbCornerOffset.x) * Map.cTileSize / 2;
    tileCorner.y += Map.cTileSize / 2;
}

Now that we know this, we should check whether the corner is between our sensor points. Of course we want to do that only if we’re checking the tile concerning the current frame’s position, which is the tile with Y coordinate equal to the bottomY. If that’s not the case, then we can safely assume that we passed the ledge between the previous and the current frame—so we want to grab the ledge anyway.

if (!mMap.IsObstacle(tileX, y)
        && mMap.IsObstacle(tileX, y - 1))
{
    var tileCorner = mMap.GetMapTilePosition(tileX, y - 1);
    tileCorner.x -= Mathf.Sign(aabbCornerOffset.x) * Map.cTileSize / 2;
    tileCorner.y += Map.cTileSize / 2;
    
    if (y > bottomY ||
        ((mAABB.center.y + aabbCornerOffset.y) - tileCorner.y <= Constants.cGrabLedgeEndY
        && tileCorner.y - (mAABB.center.y + aabbCornerOffset.y) >= Constants.cGrabLedgeStartY))
    {
    }
}

Now we’re home, we have found the ledge that we want to grab. First, let’s save the grabbed ledge’s tile position.

if (y > bottomY ||
    ((mAABB.center.y + aabbCornerOffset.y) - tileCorner.y <= Constants.cGrabLedgeEndY
    && tileCorner.y - (mAABB.center.y + aabbCornerOffset.y) >= Constants.cGrabLedgeStartY))
{
    mLedgeTile = new Vector2i(tileX, y - 1);
}

We also need to align the character with the ledge. What we want to do is align the top of the character’s ledge sensor with the top of the tile, and then offset that position by cGrabLedgeTileOffsetY.

mPosition.y = tileCorner.y - aabbCornerOffset.y - mAABBOffset.y - Constants.cGrabLedgeStartY + Constants.cGrabLedgeTileOffsetY;

Aside from this, we need to do things like set the speed to zero and change the state to CharacterState.GrabLedge. After this, we can break from the loop because there’s no point iterating through the rest of the tiles.

mPosition.y = tileCorner.y - aabbCornerOffset.y - mAABBOffset.y - Constants.cGrabLedgeStartY + Constants.cGrabLedgeTileOffsetY;

mSpeed = Vector2.zero;
mCurrentState = CharacterState.GrabLedge;
break;

That’s going to be it! The ledges can now be detected and grabbed, so now we just need to implement the GrabLedge state, which we skipped earlier.

Ledge Grab Controls

Once the character is grabbing a ledge, the player has two options: they can either jump up or drop down. Jumping works as normal; the player presses the jump key and the jump’s force is identical to the force applied when jumping from the ground. Dropping down is done by pressing the down button, or the directional key that points away from the ledge.

Controls Implementation

The first thing here to do is to detect whether the ledge is to the left or to the right of the character. We can do this because we saved the coordinates of the ledge the character is grabbing.

bool ledgeOnLeft = mLedgeTile.x * Map.cTileSize < mPosition.x;
bool ledgeOnRight = !ledgeOnLeft;

We can use that information to determine whether the character is supposed to drop off the ledge. To drop down, the player needs to either:

  • press the down button
  • press the left button when we’re grabbing a ledge on the right, or
  • press the right button when we’re grabbing a ledge on the left
bool ledgeOnLeft = mLedgeTile.x * Map.cTileSize < mPosition.x;
bool ledgeOnRight = !ledgeOnLeft;

if (mInputs[(int)KeyInput.GoDown]
    || (mInputs[(int)KeyInput.GoLeft] && ledgeOnRight)
    || (mInputs[(int)KeyInput.GoRight] && ledgeOnLeft))
{
}

There’s a small caveat here. Consider a situation when we’re holding the down button and the right button, when the character is holding onto a ledge to the right. It’ll result in the following situation:

The problem here is that the character grabs the ledge immediately after it lets go of it. 

A simple solution to this is to lock movement towards the ledge for a couple frames after we dropped off the ledge. For that we need to add two new variables; let’s call them mCannotGoLeftFrames and mCannotGoRightFrames.

public int mCannotGoLeftFrames = 0;
public int mCannotGoRightFrames = 0;

When the character drops off the ledge, we need to set those variables and change the state to jump.

bool ledgeOnLeft = mLedgeTile.x * Map.cTileSize < mPosition.x;
bool ledgeOnRight = !ledgeOnLeft;

if (mInputs[(int)KeyInput.GoDown]
    || (mInputs[(int)KeyInput.GoLeft] && ledgeOnRight)
    || (mInputs[(int)KeyInput.GoRight] && ledgeOnLeft))
{
    if (ledgeOnLeft)
        mCannotGoLeftFrames = 3;
    else
        mCannotGoRightFrames = 3;

    mCurrentState = CharacterState.Jump;
}

Now let’s go back for a bit to the Jump state, and let’s make sure that it respects our ban on moving either left or right after dropping off the ledge. Let’s reset the inputs right before we check if we should look for a ledge to grab.

if (mCannotGoLeftFrames > 0)
{
    --mCannotGoLeftFrames;
    mInputs[(int)KeyInput.GoLeft] = false;
}
if (mCannotGoRightFrames > 0)
{
    --mCannotGoRightFrames;
    mInputs[(int)KeyInput.GoRight] = false;
}

if (mSpeed.y <= 0.0f && !mAtCeiling
    && ((mPushesRightWall && mInputs[(int)KeyInput.GoRight]) || (mPushesLeftWall && mInputs[(int)KeyInput.GoLeft])))
{

As you can see, this way we won’t fulfill the conditions needed to grab a ledge as long as the blocked direction is the same as the direction of the ledge the character may try to grab. Each time we deny a particular input, we decrement from the remaining blocking frames, so eventually we’ll be able to move again—in our case, after 3 frames.

Now let’s continue working on the GrabLedge state. Since we handled dropping off the ledge, we now need to make it possible to jump from the grabbing position.

If the character didn’t drop from the ledge, we need to check whether the jump key has been pressed; if so, we need to set the jump’s vertical speed and change the state:

if (mInputs[(int)KeyInput.GoDown]
    || (mInputs[(int)KeyInput.GoLeft] && ledgeOnRight)
    || (mInputs[(int)KeyInput.GoRight] && ledgeOnLeft))
{
    if (ledgeOnLeft)
        mCannotGoLeftFrames = 3;
    else
        mCannotGoRightFrames = 3;

    mCurrentState = CharacterState.Jump;
}
else if (mInputs[(int)KeyInput.Jump])
{
    mSpeed.y = mJumpSpeed;
    mCurrentState = CharacterState.Jump;
}

That’s pretty much it! Now the ledge grabbing should work properly in all kinds of situations.

Allow the Character to Jump Shortly After Leaving a Platform

Often, to make jumps easier in platformer games, the character is allowed to jump if it just stepped off the edge of a platform and is no longer on the ground. This is a popular method to mitigate an illusion that the player has pressed the jump button but the character didn’t jump, which might have appeared due to input lag or the player pressing the jump button right after the character has moved off the platform.

Let’s implement such a mechanic now. First of all, we need to add a constant of how many frames after the character steps off the platform it can still perform a jump.

public const int cJumpFramesThreshold = 4;

We’ll also need a frame counter in the Character class, so we know how many frames the character is in the air already.

protected int mFramesFromJumpStart = 0;

Now let’s set the mFramesFromJumpStart to 0 every time we just left the ground. Let’s do that right after we call  UpdatePhysics.

UpdatePhysics();

if (mWasOnGround && !mOnGround)
    mFramesFromJumpStart = 0;

And let’s increment it every frame we’re in the jump state.

case CharacterState.Jump:

    ++mFramesFromJumpStart;

If we’re in the jump state, we cannot allow an in-air jump if we’re either at the ceiling or have a positive vertical speed. Positive vertical speed would mean that the character hasn’t missed a jump.

++mFramesFromJumpStart;

if (mFramesFromJumpStart <= Constants.cJumpFramesThreshold)
{
    if (mAtCeiling || mSpeed.y > 0.0f)
        mFramesFromJumpStart = Constants.cJumpFramesThreshold + 1;
}

If that’s not the case and the jump key is pressed, all we need to do is set the vertical speed to the jump value, as if we jumped normally, even though the character is in the jump state already.

if (mFramesFromJumpStart <= Constants.cJumpFramesThreshold)
{
    if (mAtCeiling || mSpeed.y > 0.0f)
        mFramesFromJumpStart = Constants.cJumpFramesThreshold + 1;
    else if (KeyState(KeyInput.Jump))
        mSpeed.y = mJumpSpeed;
}

And that’s it! We can set the cJumpFramesThreshold to a big value like 10 frames to make sure that it works.

The effect here is quite exaggerated. It’s not very noticeable if we’re allowing the character to jump just 1-4 frames after it is in fact no longer on the ground, but overall this allows us to modify how lenient we want our jumps to be.

Scaling the Objects

Let’s make it possible to scale the objects. We already have the mScale in the MovingObject class, so all we actually need to do is make sure it affects the AABB and the AABB offset properly.

First of all, let’s edit our AABB class so it has a scale component.

public struct AABB
{
    public Vector2 scale;
    public Vector2 center;
	public Vector2 halfSize;
    
    public AABB(Vector2 center, Vector2 halfSize)
    {
        scale = Vector2.one;
        this.center = center;
        this.halfSize = halfSize;
    }

Now let’s edit the halfSize, so that when we access it, we actually get a scaled size instead of the unscaled one.

public Vector2 scale;
public Vector2 center;

private Vector2 halfSize;
public Vector2 HalfSize
{
    set { halfSize = value; }
    get { return new Vector2(halfSize.x * scale.x, halfSize.y * scale.y); }
}

We’ll also want to be able to get or set only an X or Y value of the half size, so we need to make separate getters and setters for those as well.

public float HalfSizeX
{
    set { halfSize.x = value; }
    get { return halfSize.x * scale.x; }
}

public float HalfSizeY
{
    set { halfSize.y = value; }
    get { return halfSize.y * scale.y; }
}

Besides scaling the AABB itself, we’ll also need to scale the mAABBOffset, so that after we scale the object, its sprite will still match the AABB the same way it did when the object was unscaled. Let’s head back over to the MovingObject class to edit it.

private Vector2 mAABBOffset;
public Vector2 AABBOffset
{
    set { mAABBOffset = value; }
    get { return new Vector2(mAABBOffset.x * mScale.x, mAABBOffset.y * mScale.y); }
}

The same as previously, we’ll want to have access to X and Y components separately too.

public float AABBOffsetX
{
    set { mAABBOffset.x = value; }
    get { return mAABBOffset.x * mScale.x; }
}

public float AABBOffsetY
{
    set { mAABBOffset.y = value; }
    get { return mAABBOffset.y * mScale.y; }
}

Finally, we also need to make sure that when the scale is modified in the MovingObject, it also is modified in the AABB. The object’s scale can be negative, but the AABB itself shouldn’t have a negative scale because we rely on half size to be always positive. That’s why instead of simply passing the scale to the AABB, we’re going to pass a scale that has all components positive.

private Vector2 mScale;
public Vector2 Scale
{
    set {
        mScale = value;
        mAABB.scale = new Vector2(Mathf.Abs(value.x), Mathf.Abs(value.y));
    }
    get { return mScale; }
}
public float ScaleX
{
    set
    {
        mScale.x = value;
        mAABB.scale.x = Mathf.Abs(value);
    }
    get { return mScale.x; }
}
public float ScaleY
{
    set
    {
        mScale.y = value;
        mAABB.scale.y = Mathf.Abs(value);
    }
    get { return mScale.y; }
}

All that’s left to do now is to make sure that wherever we used the variables directly, we use them through the getters and setters now. Wherever we used halfSize.x, we’ll want to use HalfSizeX, wherever we used halfSize.y, we’ll want to use HalfSizeY, and so on. A few uses of a find and replace function should deal with this well.

Check the Results

The scaling should work well now, and because of the way we built our collision detection functions, it doesn’t matter if the character is giant or tiny—it should interact with the map well.

Summary

This part concludes our work with the tilemap. In the next parts, we’ll be setting things up to detect collisions between objects. 

It took some time and effort, but the system in general should be very robust. One thing that may be lacking right now is the support for slopes. Many games don’t rely on them, but a lot of them do, so that’s the biggest improvement goal to this system. Thanks for reading so far, see you in the next part!

Download Basic 2D Platformer Physics, Part 4

Leave a Reply

Your email address will not be published. Required fields are marked *