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.
Before We Start...
As was true for the previous parts in the series, we'll be continuing our work where we left off in the last part. Last time we calculated and cached data needed to move the objects out of the slopes collision and changed how the collisions are checked against the tilemap. In this part we'll need the same setup from the end of last part.
You can download the project files from the previous part and write the code along with this tutorial.
In this part we'll be implementing the collision with slopes or other custom tiles, adding one-way slopes, and making it possible for the game object to travel along the slopes smoothly.
Slopes Implementation
Vertical Slope Check
We can finally get to slopes! First off, we'll try to handle when the bottom edge of the object is within a slope tile.
Let's go and take a look at our CollidesWithTileBottom
function, particularly the part where we are handling the tiles.
switch (tileCollisionType) { default://slope break; case TileCollisionType.Empty: break; case TileCollisionType.Full: state.onOneWay = false; state.pushesBottomTile = true; state.bottomTile = new Vector2i(x, bottomleftTile.y); return true; }
To be able to see whether our object collides with the slope, we first need to get the offsets from the function we created earlier, which does most of our work.
Vector2 tileCenter = mMap.GetMapTilePosition(x, bottomleftTile.y); SlopeOffsetI sf = Slopes.GetOffset(tileCenter, bottomLeft.x + 0.5f, topRight.x - 0.5f, bottomLeft.y - 0.5f, topRight.y - 0.5f, tileCollisionType);
Since we're checking one pixel below our character, we need to adjust the offset.
Vector2 tileCenter = mMap.GetMapTilePosition(x, bottomleftTile.y); SlopeOffsetI sf = Slopes.GetOffset(tileCenter, bottomLeft.x + 0.5f, topRight.x - 0.5f, bottomLeft.y - 0.5f, topRight.y - 0.5f, tileCollisionType); sf.freeUp -= 1; sf.collidingBottom -= 1;
The condition for the collision is that the freeUp
offset is greater or equal to 0, which means that either we move the character up or the character is standing on the slope.
Vector2 tileCenter = mMap.GetMapTilePosition(x, bottomleftTile.y); SlopeOffsetI sf = Slopes.GetOffset(tileCenter, bottomLeft.x + 0.5f, topRight.x - 0.5f, bottomLeft.y - 0.5f, topRight.y - 0.5f, tileCollisionType); sf.freeUp -= 1; sf.collidingBottom -= 1; if (sf.freeUp >= 0) { }
We shouldn't forget about the case when we want the character to stick to the slope, though. This means that even though the character walks off the slope, we want it to behave as if it were on the slope anyway. For this, we need to add a new constant which will contain the value of how steep a slope needs to be in order to be considered a vertical wall instead of a slope.
public const int cSlopeWallHeight = 4;
If the offset is below this constant, it should be possible for the object to smoothly travel along the slope's curve. If it's equal or greater, it should be treated as a wall, and jumping would be needed to climb up.
Now we need to add another condition to our statement. This condition will check whether the character is supposed to be sticking to slopes, whether it was on a slope's last frame, and whether it needs to be pushed down or up by fewer pixels than our cSlopeWallHeight
constant.
if (sf.freeUp >= 0 || (mSticksToSlope && state.pushedBottom && sf.freeUp - sf.collidingBottom < Constants.cSlopeWallHeight)) { }
If the condition is true, we need to save this tile as a potential collidee with the object. We'll still need to iterate through all the other tiles along the X axis. First off, create the variables which will hold the X coordinate and the offset value for the colliding tile.
Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x - 0.5f, topRight.y - 0.5f)); Vector2i bottomleftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y - 0.5f)); int collidingBottom = int.MinValue; int slopeX = -1;
Now save the values, if the defined condition holds true. If we already found a colliding tile, we need to compare the offsets, and the final colliding tile will be the one for which the character needs to be offset the most.
if ((sf.freeUp >= 0 || (mSticksToSlope && state.pushedBottom && sf.freeUp - sf.collidingBottom < Constants.cSlopeWallHeight))&& sf.collidingBottom >= collidingBottom) { collidingBottom = sf.collidingBottom; slopeX = x; }
Finally, after we've iterated through all the tiles and found a tile the object is colliding with, we need to offset the object.
if (slopeX != -1) { state.pushesBottomTile = true; state.bottomTile = new Vector2i(slopeX, bottomleftTile.y); position.y += collidingBottom; topRight.y += collidingBottom; bottomLeft.y += collidingBottom; return true; } return false;
That's pretty much it for the bottom check, so now let's do the top one. This one will be a bit simpler, as we don't even need to handle sticking.
public bool CollidesWithTileTop(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x - 0.5f, topRight.y + 0.5f)); Vector2i bottomleftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y + 0.5f)); int freeDown = int.MaxValue; int slopeX = -1; for (int x = bottomleftTile.x; x <= topRightTile.x; ++x) { var tileCollisionType = mMap.GetCollisionType(x, topRightTile.y); if (Slopes.IsOneWay(tileCollisionType)) continue; switch (tileCollisionType) { default://slope Vector2 tileCenter = mMap.GetMapTilePosition(x, topRightTile.y); SlopeOffsetI sf = Slopes.GetOffset(tileCenter, bottomLeft.x + 0.5f, topRight.x - 0.5f, bottomLeft.y + 0.5f, topRight.y + 0.5f, tileCollisionType); sf.freeDown += 1; sf.collidingTop += 1; if (sf.freeDown < freeDown && sf.freeDown <= 0 && sf.freeDown == sf.collidingTop) { freeDown = sf.freeDown; slopeX = x; } break; case TileCollisionType.Empty: break; case TileCollisionType.Full: state.pushesTopTile = true; state.topTile = new Vector2i(x, topRightTile.y); return true; } } if (slopeX != -1) { state.pushesTopTile = true; state.topTile = new Vector2i(slopeX, topRightTile.y); position.y += freeDown; topRight.y += freeDown; bottomLeft.y += freeDown; return true; } return false; }
That's it.
Horizontal Slope Check
The horizontal check will be a bit more complicated, as it is here where we'll be handling the most troublesome cases.
Let's start with handling the slopes on the right. There are a couple things that we'll need to be aware of, mostly concerning moving up the slopes. Let's consider the following situations.
We'll need to handle those cases with special care because at some point when we move along the slope we're going to hit the ceiling. To prevent that, we'll need to do some more checks in case the character is moving horizontally.
For the vertical checks, we did move the object up from the tile, but in general we won't be using that functionality there. Since we're always checking a pixel that's just outside the object bounds, we'll never really overlap an obstacle. For the horizontal checks, it's a bit different, because this is the place where we'll be handling moving along the slope, so naturally the height adjustment will mainly take place here.
To make the proper collision response for the cases illustrated above, it'll be easier to check whether we can enter into a space horizontally, and if that's possible then check whether the object doesn't overlap with any solid pixels if it had to be moved vertically due to moving along a slope. If we fail to find the space, we know that it's impossible to move towards the checked direction, and we can set the horizontal wall flag.
Let's move to the CollidesWithTileRight
function, to the part where we handle the slopes.
default://slope Vector2 tileCenter = mMap.GetMapTilePosition(topRightTile.x, y); float leftTileEdge = (tileCenter.x - Map.cTileSize / 2); float rightTileEdge = (leftTileEdge + Map.cTileSize); float bottomTileEdge = (tileCenter.y - Map.cTileSize / 2);
We get the offset in a similar way we get it for the vertical checks, but the offset we care about is the one that's bigger.
var offset = Slopes.GetOffsetHeight(tileCenter, bottomLeft.x + 0.5f, topRight.x + 0.5f, bottomLeft.y + 0.5f, topRight.y - 0.5f, tileCollisionType); slopeOffset = Mathf.Abs(offset.freeUp) < Mathf.Abs(offset.freeDown) ? offset.freeUp : offset.freeDown;
Now, let's see if our character should treat the checked tile as a wall. We do this if either the slope offset is greater or equal to our cSlopeWallHeight
constant or to get out of collision we'd need to offset the character up or down while we are already colliding with a tile in the same direction, which means that our object is squeezed between the top and bottom tiles.
var offset = Slopes.GetOffsetHeight(tileCenter, bottomLeft.x + 0.5f, topRight.x + 0.5f, bottomLeft.y + 0.5f, topRight.y - 0.5f, tileCollisionType); slopeOffset = Mathf.Abs(offset.freeUp) < Mathf.Abs(offset.freeDown) ? offset.freeUp : offset.freeDown; if (Mathf.Abs(slopeOffset) >= Constants.cSlopeWallHeight || (slopeOffset < 0 && state.pushesBottomTile) || (slopeOffset > 0 && state.pushesTopTile)) { state.pushesRightTile = true; state.rightTile = new Vector2i(topRightTile.x, y); return true; }
If that's not the case and the offset is greater than 0, then we hit a slope. One problem here is that we do not know whether we hit a wall on other tiles that we have yet to check, so for now we'll just save the slope offset and tile collision type in case we need to use them later.
Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x + 0.5f, topRight.y - 0.5f)); Vector2i bottomLeftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y + 0.5f)); float slopeOffset = 0.0f, oldSlopeOffset = 0.0f; TileCollisionType slopeCollisionType = TileCollisionType.Empty;
Now, instead of seeing if the slope offset is greater than zero, let's compare it to another tile's slope offset, in case we already found a colliding slope in previous iterations.
var offset = Slopes.GetOffsetHeight(tileCenter, bottomLeft.x + 0.5f, topRight.x + 0.5f, bottomLeft.y + 0.5f, topRight.y - 0.5f, tileCollisionType); slopeOffset = Mathf.Abs(offset.freeUp) < Mathf.Abs(offset.freeDown) ? offset.freeUp : offset.freeDown; if (Mathf.Abs(slopeOffset) >= Constants.cSlopeWallHeight || (slopeOffset < 0 && state.pushesBottomTile) || (slopeOffset > 0 && state.pushesTopTile)) { state.pushesRightTile = true; state.rightTile = new Vector2i(topRightTile.x, y); return true; } else if (Mathf.Abs(slopeOffset) > Mathf.Abs(oldSlopeOffset)) { slopeCollisionType = tileCollisionType; state.rightTile = new Vector2i(topRightTile.x, y); } else slopeOffset = oldSlopeOffset;
Handle the Squeezing Between Tiles
After we finish looping through all the tiles of interest, let's see if we need to move the object. Let's handle the case where the slope offset ended up being non-zero.
if (slopeOffset != 0.0f) { }
We'll have to handle two cases here, and we need to do slightly different things depending whether we need to offset our object up or down.
if (slopeOffset != 0.0f) { if (slopeOffset > 0 && slopeOffset < Constants.cSlopeWallHeight) { } }
First off, we need to check whether we can fit into the space after offsetting the object. If that's the case, then we're handling one of the cases illustrated above. Where the character is trying to move right, the offset is positive, but if we offset the object then it will be pushed into the top wall, so instead we'll just mark that it's colliding with the wall on the right side to block the movement in that direction.
if (slopeOffset > 0 && slopeOffset < Constants.cSlopeWallHeight) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y += slopeOffset - Mathf.Sign(slopeOffset); tr.y += slopeOffset - Mathf.Sign(slopeOffset); bl.y += slopeOffset - Mathf.Sign(slopeOffset); PositionState s = new PositionState(); if (CollidesWithTileTop(ref pos, ref tr, ref bl, ref s)) { state.pushesRightTile = true; state.pushesRightSlope = true; return true; } }
If we fit into the space, we'll mark that we collide with the bottom tile and offset the object's position appropriately.
if (CollidesWithTileTop(ref pos, ref tr, ref bl, ref s)) { state.pushesRightTile = true; state.pushesRightSlope = true; return true; } else { position.y += slopeOffset; bottomLeft.y += slopeOffset; topRight.y += slopeOffset; state.pushesBottomTile = true; state.pushesBottomSlope = true; }
We handle the case in which the object needs to be offset down in a similar manner.
if (slopeOffset != 0.0f) { if (slopeOffset > 0 && slopeOffset < Constants.cSlopeWallHeight) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y += slopeOffset - Mathf.Sign(slopeOffset); tr.y += slopeOffset - Mathf.Sign(slopeOffset); bl.y += slopeOffset - Mathf.Sign(slopeOffset); PositionState s = new PositionState(); if (CollidesWithTileTop(ref pos, ref tr, ref bl, ref s)) { state.pushesRightTile = true; state.pushesRightSlope = true; return true; } else { position.y += slopeOffset; bottomLeft.y += slopeOffset; topRight.y += slopeOffset; state.pushesBottomTile = true; state.pushesBottomSlope = true; } } else if (slopeOffset < 0 && slopeOffset > -Constants.cSlopeWallHeight) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y += slopeOffset - Mathf.Sign(slopeOffset); tr.y += slopeOffset - Mathf.Sign(slopeOffset); bl.y += slopeOffset - Mathf.Sign(slopeOffset); PositionState s = new PositionState(); if (CollidesWithTileBottom(ref pos, ref tr, ref bl, ref s)) { state.pushesRightTile = true; state.pushesRightSlope = true; return true; } else { position.y += slopeOffset; bottomLeft.y += slopeOffset; topRight.y += slopeOffset; state.pushesTopTile = true; state.pushesTopSlope = true; } } }
Moving Object in Collision Check
Now this function will offset the object up or down as is necessary if we want to step on the tile to the right, but what if we want to use this function just as a check, and we don't really want to move the character by calling it? To solve this issue, let's add an additional variable named 'move' to mark whether the function can move the object or not.
public bool CollidesWithTileRight(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state, bool move = false) { }
And move the object only if this new flag is set to true.
if (CollidesWithTileTop(ref pos, ref tr, ref bl, ref s)) { state.pushesRightTile = true; state.pushesRightSlope = true; return true; } else if (move) { position.y += slopeOffset; bottomLeft.y += slopeOffset; topRight.y += slopeOffset; state.pushesBottomTile = true; state.pushesBottomSlope = true; } //... if (CollidesWithTileBottom(ref pos, ref tr, ref bl, ref s)) { state.pushesRightTile = true; state.pushesRightSlope = true; return true; } else if (move) { position.y += slopeOffset; bottomLeft.y += slopeOffset; topRight.y += slopeOffset; state.pushesTopTile = true; state.pushesTopSlope = true; }
Handle Slope Sticking
Now let's handle sticking to slopes. It's pretty straightforward, but we'll need to handle all the corner cases properly, so that the character will stick to the slope without any hiccups along the way.
Before we handle the corner cases, though, we can very easily handle slope sticking within a single tile in the vertical collision check. It will be enough if we add the following condition in the CollidesWithTileBottom
function.
if ((sf.freeUp >= 0 && sf.collidingBottom == sf.freeUp) || (mSticksToSlope && state.pushedBottom && sf.freeUp - sf.collidingBottom < Constants.cSlopeWallHeight && sf.freeUp >= sf.collidingBottom)) { state.onOneWay = isOneWay; state.oneWayY = bottomleftTile.y; state.pushesBottomTile = true; state.bottomTile = new Vector2i(x, bottomleftTile.y); position.y += sf.collidingBottom; topRight.y += sf.collidingBottom; bottomLeft.y += sf.collidingBottom; return true; }
This condition makes it so that if the distance between the object's position and the nearest ground is between 0 and the cSlopeWallHeight
, then the character will get pushed down too, in addition to the original condition. This unfortunately works only within a single tile; the following illustration pinpoints the problem which we need to solve.
The corner case we are talking about is just this: the character moves down and to the left from tile number one to tile number two. Tile number two is empty, so we need to check the tile below it and see if the offset from the character to tile number 3 is proper to keep walking along the slope there.
Handle the Corner Cases
It's going to be easier to handle these corner cases in the horizontal collision checks, so let's head back to the CollidesWithTileRight
function. Let's go to the end of the function and handle the troublesome cases here.
First off, to handle the slope sticking, the mSticksToSlope
flag needs to be set, the object must have been on the ground the previous frame, and the move flag needs to be on.
if (mSticksToSlope && state.pushedBottomTile && move) { }
Now we need to find the tile to which we should stick. Since this function checks the collision on the right edge of the object, we'll be handling the slope sticking for the character's bottom left corner.
var nextX = mMap.GetMapTileXAtPoint(topRight.x - 1.5f); var bottomY = mMap.GetMapTileYAtPoint(bottomLeft.y + 1.0f) - 1; var prevPos = mMap.GetMapTilePosition(new Vector2i(topRightTile.x, bottomLeftTile.y)); var nextPos = mMap.GetMapTilePosition(new Vector2i(nextX, bottomY)); var prevCollisionType = mMap.GetCollisionType(new Vector2i(topRightTile.x, bottomLeftTile.y)); var nextCollisionType = mMap.GetCollisionType(new Vector2i(nextX, bottomY));
Now we need to find a way to compare the height the object currently is on to the one it wants to step onto. If the next height is lower than the current one, but still higher than our cSlopeWallHeight
constant, we'll push our object down onto the ground.
Get Slope Height
Let's go back to our Slope class to make a function which will return the height of a slope at a particular position.
public static int GetSlopeHeightFromBottom(int x, TileCollisionType type) { switch (type) { case TileCollisionType.Empty: return 0; case TileCollisionType.Full: case TileCollisionType.OneWayPlatform: return Map.cTileSize; } }
The parameters for the function are the x value on the slope and the slope type. If the slope is empty we can immediately return 0, and if it's full then we return the tile size.
We can easily get the height of a slope by using our cached offsets. If the tile is not transformed in any way, we just get an offset for an object that is one pixel wide at the position x, and its height is equal to the tile size.
public static int GetSlopeHeightFromBottom(int x, TileCollisionType type) { switch (type) { case TileCollisionType.Empty: return 0; case TileCollisionType.Full: case TileCollisionType.OneWayPlatform: return Map.cTileSize; } var offset = new SlopeOffsetI(slopeOffsets[(int)type][x][0][0][Map.cTileSize - 1]); return offset.collidingBottom; }
Let's handle this for different transforms. If a slope is flipped on the X axis, we just need to mirror the x argument.
public static int GetSlopeHeightFromBottom(int x, TileCollisionType type) { switch (type) { case TileCollisionType.Empty: return 0; case TileCollisionType.Full: case TileCollisionType.OneWayPlatform: return Map.cTileSize; } if (IsFlippedX(type)) x = Map.cTileSize - 1 - x; var offset = new SlopeOffsetI(slopeOffsets[(int)type][x][0][0][Map.cTileSize - 1]); return offset.collidingBottom; }
If the slope is flipped on the Y axis, we need to return the collidingTop
instead of collidingBottom
offset. Since collidingTop
in this case will be negative, we'll also need to flip the sign for it.
var offset = new SlopeOffsetI(slopeOffsets[(int)type][x][0][0][Map.cTileSize - 1]); return IsFlippedY(type) ? -offset.collidingTop : offset.collidingBottom;
Finally, if the tile is rotated by 90 degrees, we'll need to be returning collidingLeft
or collidingRight
offsets. Aside from that, to get a proper cached offset, we'll need to swap the x and y positions and size.
if (!IsFlipped90(type)) { var offset = new SlopeOffsetI(slopeOffsets[(int)type][x][0][0][Map.cTileSize - 1]); return IsFlippedY(type) ? -offset.collidingTop : offset.collidingBottom; } else { var offset = new SlopeOffsetI(slopeOffsets[(int)type][0][x][Map.cTileSize - 1][0]); return IsFlippedY(type) ? offset.collidingLeft : -offset.collidingRight; }
That's the final function.
public static int GetSlopeHeightFromBottom(int x, TileCollisionType type) { switch (type) { case TileCollisionType.Empty: return 0; case TileCollisionType.Full: case TileCollisionType.OneWayPlatform: return Map.cTileSize; } if (IsFlippedX(type)) x = Map.cTileSize - 1 - x; if (!IsFlipped90(type)) { var offset = new SlopeOffsetI(slopeOffsets[(int)type][x][0][0][Map.cTileSize - 1]); return IsFlippedY(type) ? -offset.collidingTop : offset.collidingBottom; } else { var offset = new SlopeOffsetI(slopeOffsets[(int)type][0][x][Map.cTileSize - 1][0]); return IsFlippedY(type) ? offset.collidingLeft : -offset.collidingRight; } }
Back to Corner Cases
Let's move back to the CollidesWithTileRight
function, right where we finished determining the slope types for the tiles the character moves between.
To use the function we just created, we need to determine the position at which we want to get the height of a tile.
var prevCollisionType = mMap.GetCollisionType(new Vector2i(bottomLeftTile.x, bottomLeftTile.y)); var nextCollisionType = mMap.GetCollisionType(new Vector2i(nextX, bottomY)); int x1 = (int)Mathf.Clamp((bottomLeft.x - (prevPos.x - Map.cTileSize / 2)), 0.0f, 15.0f); int x2 = (int)Mathf.Clamp((bottomLeft.x + 1.0f - (nextPos.x - Map.cTileSize / 2)), 0.0f, 15.0f);
Now let's calculate the height between those two points.
int slopeHeight = Slopes.GetSlopeHeightFromBottom(x1, prevCollisionType); int nextSlopeHeight = Slopes.GetSlopeHeightFromBottom(x2, nextCollisionType); var offset = slopeHeight + Map.cTileSize - nextSlopeHeight;
If the offset is between 0 and the cSlopeWallHeight
constant, then we're going to push the object down, but first we need to check whether we actually can push the object down. This is exactly the same routine we did earlier.
if (offset < Constants.cSlopeWallHeight && offset > 0) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y -= offset - Mathf.Sign(offset); tr.y -= offset - Mathf.Sign(offset); bl.y -= offset - Mathf.Sign(offset); bl.x += 1.0f; tr.x += 1.0f; PositionState s = new PositionState(); if (!CollidesWithTileBottom(ref pos, ref tr, ref bl, ref s)) { position.y -= offset; bottomLeft.y -= offset; topRight.y -= offset; state.pushesBottomTile = true; state.pushesBottomSlope = true; } }
All in all, the function should look like this.
public bool CollidesWithTileRight(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state, bool move = false) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x + 0.5f, topRight.y - 0.5f)); Vector2i bottomLeftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y + 0.5f)); float slopeOffset = 0.0f, oldSlopeOffset = 0.0f; TileCollisionType slopeCollisionType = TileCollisionType.Empty; for (int y = bottomLeftTile.y; y <= topRightTile.y; ++y) { var tileCollisionType = mMap.GetCollisionType(topRightTile.x, y); switch (tileCollisionType) { default://slope Vector2 tileCenter = mMap.GetMapTilePosition(topRightTile.x, y); float leftTileEdge = (tileCenter.x - Map.cTileSize / 2); float rightTileEdge = (leftTileEdge + Map.cTileSize); float bottomTileEdge = (tileCenter.y - Map.cTileSize / 2); oldSlopeOffset = slopeOffset; var offset = Slopes.GetOffsetHeight(tileCenter, bottomLeft.x + 0.5f, topRight.x + 0.5f, bottomLeft.y + 0.5f, topRight.y - 0.5f, tileCollisionType); slopeOffset = Mathf.Abs(offset.freeUp) < Mathf.Abs(offset.freeDown) ? offset.freeUp : offset.freeDown; if (Mathf.Abs(slopeOffset) >= Constants.cSlopeWallHeight || (slopeOffset < 0 && state.pushesBottomTile) || (slopeOffset > 0 && state.pushesTopTile)) { state.pushesRightTile = true; state.rightTile = new Vector2i(topRightTile.x, y); return true; } else if (Mathf.Abs(slopeOffset) > Mathf.Abs(oldSlopeOffset)) { slopeCollisionType = tileCollisionType; state.rightTile = new Vector2i(topRightTile.x, y); } else slopeOffset = oldSlopeOffset; break; case TileCollisionType.Empty: break; } } if (slopeOffset != 0.0f) { if (slopeOffset > 0 && slopeOffset < Constants.cSlopeWallHeight) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y += slopeOffset - Mathf.Sign(slopeOffset); tr.y += slopeOffset - Mathf.Sign(slopeOffset); bl.y += slopeOffset - Mathf.Sign(slopeOffset); PositionState s = new PositionState(); if (CollidesWithTileTop(ref pos, ref tr, ref bl, ref s)) { state.pushesRightTile = true; state.pushesRightSlope = true; return true; } else if (move) { position.y += slopeOffset; bottomLeft.y += slopeOffset; topRight.y += slopeOffset; state.pushesBottomTile = true; state.pushesBottomSlope = true; } } else if (slopeOffset < 0 && slopeOffset > -Constants.cSlopeWallHeight) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y += slopeOffset - Mathf.Sign(slopeOffset); tr.y += slopeOffset - Mathf.Sign(slopeOffset); bl.y += slopeOffset - Mathf.Sign(slopeOffset); PositionState s = new PositionState(); if (CollidesWithTileBottom(ref pos, ref tr, ref bl, ref s)) { state.pushesRightTile = true; state.pushesRightSlope = true; return true; } else if (move) { position.y += slopeOffset; bottomLeft.y += slopeOffset; topRight.y += slopeOffset; state.pushesTopTile = true; state.pushesTopSlope = true; } } } if (mSticksToSlope && state.pushedBottomTile && move) { var nextX = mMap.GetMapTileXAtPoint(bottomLeft.x + 1.0f); var bottomY = mMap.GetMapTileYAtPoint(bottomLeft.y + 1.0f) - 1; var prevPos = mMap.GetMapTilePosition(new Vector2i(bottomLeftTile.x, bottomLeftTile.y)); var nextPos = mMap.GetMapTilePosition(new Vector2i(nextX, bottomY)); var prevCollisionType = mMap.GetCollisionType(new Vector2i(bottomLeftTile.x, bottomLeftTile.y)); var nextCollisionType = mMap.GetCollisionType(new Vector2i(nextX, bottomY)); int x1 = (int)Mathf.Clamp((bottomLeft.x - (prevPos.x - Map.cTileSize / 2)), 0.0f, 15.0f); int x2 = (int)Mathf.Clamp((bottomLeft.x + 1.0f - (nextPos.x - Map.cTileSize / 2)), 0.0f, 15.0f); int slopeHeight = Slopes.GetSlopeHeightFromBottom(x1, prevCollisionType); int nextSlopeHeight = Slopes.GetSlopeHeightFromBottom(x2, nextCollisionType); var offset = slopeHeight + Map.cTileSize - nextSlopeHeight; if (offset < Constants.cSlopeWallHeight && offset > 0) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y -= offset - Mathf.Sign(offset); tr.y -= offset - Mathf.Sign(offset); bl.y -= offset - Mathf.Sign(offset); bl.x += 1.0f; tr.x += 1.0f; PositionState s = new PositionState(); if (!CollidesWithTileBottom(ref pos, ref tr, ref bl, ref s)) { position.y -= offset; bottomLeft.y -= offset; topRight.y -= offset; state.pushesBottomTile = true; state.pushesBottomSlope = true; } } } return false; }
Now we need to do everything analogically for the CollidesWithTileLeft
function. The final version of it should take the following form.
public bool CollidesWithTileLeft(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state, bool move = false) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x - 0.5f, topRight.y - 0.5f)); Vector2i bottomLeftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x - 0.5f, bottomLeft.y + 0.5f)); float slopeOffset = 0.0f, oldSlopeOffset = 0.0f; TileCollisionType slopeCollisionType = TileCollisionType.Empty; for (int y = bottomLeftTile.y; y <= topRightTile.y; ++y) { var tileCollisionType = mMap.GetCollisionType(bottomLeftTile.x, y); switch (tileCollisionType) { default://slope Vector2 tileCenter = mMap.GetMapTilePosition(bottomLeftTile.x, y); float leftTileEdge = (tileCenter.x - Map.cTileSize / 2); float rightTileEdge = (leftTileEdge + Map.cTileSize); float bottomTileEdge = (tileCenter.y - Map.cTileSize / 2); oldSlopeOffset = slopeOffset; var offset = Slopes.GetOffsetHeight(tileCenter, bottomLeft.x - 0.5f, topRight.x - 0.5f, bottomLeft.y + 0.5f, topRight.y - 0.5f, tileCollisionType); slopeOffset = Mathf.Abs(offset.freeUp) < Mathf.Abs(offset.freeDown) ? offset.freeUp : offset.freeDown; if (Mathf.Abs(slopeOffset) >= Constants.cSlopeWallHeight || (slopeOffset < 0 && state.pushesBottomTile) || (slopeOffset > 0 && state.pushesTopTile)) { state.pushesLeftTile = true; state.leftTile = new Vector2i(bottomLeftTile.x, y); return true; } else if (Mathf.Abs(slopeOffset) > Mathf.Abs(oldSlopeOffset)) { slopeCollisionType = tileCollisionType; state.leftTile = new Vector2i(bottomLeftTile.x, y); } else slopeOffset = oldSlopeOffset; break; case TileCollisionType.Empty: break; } } if (slopeCollisionType != TileCollisionType.Empty && slopeOffset != 0) { if (slopeOffset > 0 && slopeOffset < Constants.cSlopeWallHeight) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y += slopeOffset - Mathf.Sign(slopeOffset); tr.y += slopeOffset - Mathf.Sign(slopeOffset); bl.y += slopeOffset - Mathf.Sign(slopeOffset); PositionState s = new PositionState(); if (CollidesWithTileTop(ref pos, ref tr, ref bl, ref s)) { state.pushesLeftTile = true; state.pushesLeftSlope = true; return true; } else if (move) { position.y += slopeOffset; bottomLeft.y += slopeOffset; topRight.y += slopeOffset; state.pushesBottomTile = true; state.pushesBottomSlope = true; } } else if (slopeOffset < 0 && slopeOffset > -Constants.cSlopeWallHeight) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y += slopeOffset - Mathf.Sign(slopeOffset); tr.y += slopeOffset - Mathf.Sign(slopeOffset); bl.y += slopeOffset - Mathf.Sign(slopeOffset); PositionState s = new PositionState(); if (CollidesWithTileBottom(ref pos, ref tr, ref bl, ref s)) { state.pushesLeftTile = true; state.pushesLeftSlope = true; return true; } else if (move) { position.y += slopeOffset; bottomLeft.y += slopeOffset; topRight.y += slopeOffset; state.pushesTopTile = true; state.pushesTopSlope = true; } } } if (mSticksToSlope && state.pushedBottomTile && move) { var nextX = mMap.GetMapTileXAtPoint(topRight.x - 1.5f); var bottomY = mMap.GetMapTileYAtPoint(bottomLeft.y + 1.0f) - 1; var prevPos = mMap.GetMapTilePosition(new Vector2i(topRightTile.x, bottomLeftTile.y)); var nextPos = mMap.GetMapTilePosition(new Vector2i(nextX, bottomY)); var prevCollisionType = mMap.GetCollisionType(new Vector2i(topRightTile.x, bottomLeftTile.y)); var nextCollisionType = mMap.GetCollisionType(new Vector2i(nextX, bottomY)); int x1 = (int)Mathf.Clamp((topRight.x - 1.0f - (prevPos.x - Map.cTileSize / 2)), 0.0f, 15.0f); int x2 = (int)Mathf.Clamp((topRight.x - 1.5f - (nextPos.x - Map.cTileSize / 2)), 0.0f, 15.0f); int slopeHeight = Slopes.GetSlopeHeightFromBottom(x1, prevCollisionType); int nextSlopeHeight = Slopes.GetSlopeHeightFromBottom(x2, nextCollisionType); var offset = slopeHeight + Map.cTileSize - nextSlopeHeight; if (offset < Constants.cSlopeWallHeight && offset > 0) { Vector2 pos = position, tr = topRight, bl = bottomLeft; pos.y -= offset - Mathf.Sign(offset); tr.y -= offset - Mathf.Sign(offset); bl.y -= offset - Mathf.Sign(offset); bl.x -= 1.0f; tr.x -= 1.0f; PositionState s = new PositionState(); if (!CollidesWithTileBottom(ref pos, ref tr, ref bl, ref s)) { position.y -= offset; bottomLeft.y -= offset; topRight.y -= offset; state.pushesBottomTile = true; state.pushesBottomSlope = true; } } } return false; }
That's it. The code should be able to handle all manners of untranslated slopes.
Handle Translation Types
Before we start handling translated tiles, let's make a few functions that will return whether a particular TileCollisionType
is translated in a particular way. Our collision type enum is structured in this way:
public enum TileCollisionType { Empty = 0, //normal tiles Full, OneWayPlatform, SlopesStart, //starting point for slopes Slope45, //basic version of the slope Slope45FX, //slope flipped on the X axis Slope45FY, //slope flipped on the Y axis Slope45FXY, //slope flipped on the X and Y axes Slope45F90, //slope rotated 90 degrees Slope45F90X, //slope rotated and flipped on X axis Slope45F90Y, //slope rotated and flipped on Y axis Slope45F90XY, //slope rotated and flipped on both axes ... }
We can use these patterns to tell just by the value of the enum how is a particular collision type translated. Let's start by identifying flip on the X axis.
public static bool IsFlippedX(TileCollisionType type) { }
First, let's get the slope id. We'll do that by calculating the offset from the first defined slope tile to the one we want to identify.
public static bool IsFlippedX(TileCollisionType type) { int typeId = (int)type - (int)TileCollisionType.SlopesStart + 1; }
We have eight kinds of translations, so now all we need is get the remainder of dividing the typeId
by 8.
public static bool IsFlippedX(TileCollisionType type) { int typeId = ((int)type - ((int)TileCollisionType.SlopesStart + 1)) % 8; }
So now the translations have an assigned number for them.
Slope45, //0 Slope45FX, //1 Slope45FY, //2 Slope45FXY, //3 Slope45F90, //4 Slope45F90X, //5 Slope45F90Y, //6 Slope45F90XY, //7
The flip on the X axis is present in the types equal to 1, 3, 5, and 7, so if it's equal to one of those then the function should return true, otherwise return false.
public static bool IsFlippedX(TileCollisionType type) { int typeId = ((int)type - ((int)TileCollisionType.SlopesStart + 1)) % 8; switch (typeId) { case 1: case 3: case 5: case 7: return true; } return false; }
In the same way, let's create a function which tells whether a type is flipped on the Y axis.
public static bool IsFlippedY(TileCollisionType type) { int typeId = ((int)type - ((int)TileCollisionType.SlopesStart + 1)) % 8; switch (typeId) { case 2: case 3: case 6: case 7: return true; } return false; }
And finally, if the collision type is rotated.
public static bool IsFlipped90(TileCollisionType type) { int typeId = ((int)type - ((int)TileCollisionType.SlopesStart + 1)) % 8; return (typeId > 3); }
That's all that we need.
Transform the Offset
Let's go back to the Slopes class and make our GetOffset
function support the translated tiles.
public static SlopeOffsetI GetOffset(Vector2 tileCenter, float leftX, float rightX, float bottomY, float topY, TileCollisionType tileCollisionType) { int posX, posY, sizeX, sizeY; float leftTileEdge = tileCenter.x - Map.cTileSize / 2; float rightTileEdge = leftTileEdge + Map.cTileSize; float bottomTileEdge = tileCenter.y - Map.cTileSize / 2; float topTileEdge = bottomTileEdge + Map.cTileSize; SlopeOffsetI offset; posX = (int)Mathf.Clamp(leftX - leftTileEdge, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp(rightX - (leftTileEdge + posX), 0.0f, Map.cTileSize - 1); posY = (int)Mathf.Clamp(bottomY - bottomTileEdge, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp(topY - (bottomTileEdge + posY), 0.0f, Map.cTileSize - 1); offset = new SlopeOffsetI(slopeOffsets[(int)tileCollisionType][posX][posY][sizeX][sizeY]); if (topTileEdge < topY) { if (offset.freeDown < 0) offset.freeDown -= (int)(topY - topTileEdge); offset.collidingTop = offset.freeDown; } if (bottomTileEdge > bottomY) { if (offset.freeUp > 0) offset.freeUp += Mathf.RoundToInt(bottomTileEdge - bottomY); offset.collidingBottom = offset.freeUp; } return offset; }
As usual, since we don't have cached data for translated slopes, we'll be translating the object's position and size so the result is identical as if the tile has been translated. Let's start with the flip on the X axis. All we need to do here is flip the object along the center of the tile.
if (IsFlippedX(tileCollisionType)) { posX = (int)Mathf.Clamp(rightTileEdge - rightX, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp((rightTileEdge - posX) - leftX, 0.0f, Map.cTileSize - 1); } else { posX = (int)Mathf.Clamp(leftX - leftTileEdge, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp(rightX - (leftTileEdge + posX), 0.0f, Map.cTileSize - 1); }
Similarly for the flip on the Y axis.
if (IsFlippedY(tileCollisionType)) { posY = (int)Mathf.Clamp(topTileEdge - topY, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp((topTileEdge - posY) - bottomY, 0.0f, Map.cTileSize - 1); } else { posY = (int)Mathf.Clamp(bottomY - bottomTileEdge, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp(topY - (bottomTileEdge + posY), 0.0f, Map.cTileSize - 1); }
Now in case we flipped the tile on the y axis, the offsets we received are actually swapped. Let's translate them so they actually work the same way as the offsets of the untranslated tile, which means up is up and down is down!
if (IsFlippedY(tileCollisionType)) { int tmp = offset.freeDown; offset.freeDown = -offset.freeUp; offset.freeUp = -tmp; tmp = offset.collidingTop; offset.collidingTop = -offset.collidingBottom; offset.collidingBottom = -tmp; }
Now let's handle the 90-degree rotation.
if (!IsFlipped90(tileCollisionType)) { if (IsFlippedX(tileCollisionType)) { posX = (int)Mathf.Clamp(rightTileEdge - rightX, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp((rightTileEdge - posX) - leftX, 0.0f, Map.cTileSize - 1); } else { posX = (int)Mathf.Clamp(leftX - leftTileEdge, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp(rightX - (leftTileEdge + posX), 0.0f, Map.cTileSize - 1); } if (IsFlippedY(tileCollisionType)) { posY = (int)Mathf.Clamp(topTileEdge - topY, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp((topTileEdge - posY) - bottomY, 0.0f, Map.cTileSize - 1); } else { posY = (int)Mathf.Clamp(bottomY - bottomTileEdge, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp(topY - (bottomTileEdge + posY), 0.0f, Map.cTileSize - 1); } offset = new SlopeOffsetI(slopeOffsets[(int)tileCollisionType][posX][posY][sizeX][sizeY]); if (IsFlippedY(tileCollisionType)) { int tmp = offset.freeDown; offset.freeDown = -offset.freeUp; offset.freeUp = -tmp; tmp = offset.collidingTop; offset.collidingTop = -offset.collidingBottom; offset.collidingBottom = -tmp; } } else { }
Here everything should be rotated by 90 degrees, so instead of basing our posX
and sizeX
on the left and right edges of the object, we'll be basing them on the top and bottom.
if (IsFlippedY(tileCollisionType)) { posX = (int)Mathf.Clamp(bottomY - bottomTileEdge, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp(topY - (bottomTileEdge + posX), 0.0f, Map.cTileSize - 1); } else { posX = (int)Mathf.Clamp(topTileEdge - topY, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp((topTileEdge - posX) - bottomY, 0.0f, Map.cTileSize - 1); } if (IsFlippedX(tileCollisionType)) { posY = (int)Mathf.Clamp(rightTileEdge - rightX, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp((rightTileEdge - posY) - leftX, 0.0f, Map.cTileSize - 1); } else { posY = (int)Mathf.Clamp(leftX - leftTileEdge, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp(rightX - (leftTileEdge + posY), 0.0f, Map.cTileSize - 1); }
Now we need to do a similar thing to what we did previously if the tile was flipped on the Y axis, but this time we need to do it for both the 90-degree rotation and the Y flip.
if (IsFlippedY(tileCollisionType)) { offset.collidingBottom = offset.collidingLeft; offset.freeDown = offset.freeLeft; offset.collidingTop = offset.collidingRight; offset.freeUp = offset.freeRight; } else { offset.collidingBottom = -offset.collidingRight; offset.freeDown = -offset.freeRight; offset.collidingTop = -offset.collidingLeft; offset.freeUp = -offset.freeLeft; }
This is it. Since our final up and down offsets are adjusted to make sense in the world space, our out of tile bounds adjustments are still working properly.
public static SlopeOffsetI GetOffsetHeight(Vector2 tileCenter, float leftX, float rightX, float bottomY, float topY, TileCollisionType tileCollisionType) { int posX, posY, sizeX, sizeY; float leftTileEdge = tileCenter.x - Map.cTileSize / 2; float rightTileEdge = leftTileEdge + Map.cTileSize; float bottomTileEdge = tileCenter.y - Map.cTileSize / 2; float topTileEdge = bottomTileEdge + Map.cTileSize; SlopeOffsetI offset; if (!IsFlipped90(tileCollisionType)) { if (IsFlippedX(tileCollisionType)) { posX = (int)Mathf.Clamp(rightTileEdge - rightX, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp((rightTileEdge - posX) - leftX, 0.0f, Map.cTileSize - 1); } else { posX = (int)Mathf.Clamp(leftX - leftTileEdge, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp(rightX - (leftTileEdge + posX), 0.0f, Map.cTileSize - 1); } if (IsFlippedY(tileCollisionType)) { posY = (int)Mathf.Clamp(topTileEdge - topY, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp((topTileEdge - posY) - bottomY, 0.0f, Map.cTileSize - 1); } else { posY = (int)Mathf.Clamp(bottomY - bottomTileEdge, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp(topY - (bottomTileEdge + posY), 0.0f, Map.cTileSize - 1); } offset = new SlopeOffsetI(slopeOffsets[(int)tileCollisionType][posX][posY][sizeX][sizeY]); if (IsFlippedY(tileCollisionType)) { int tmp = offset.freeDown; offset.freeDown = -offset.freeUp; offset.freeUp = -tmp; tmp = offset.collidingTop; offset.collidingTop = -offset.collidingBottom; offset.collidingBottom = -tmp; } } else { if (IsFlippedY(tileCollisionType)) { posX = (int)Mathf.Clamp(bottomY - bottomTileEdge, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp(topY - (bottomTileEdge + posX), 0.0f, Map.cTileSize - 1); } else { posX = (int)Mathf.Clamp(topTileEdge - topY, 0.0f, Map.cTileSize - 1); sizeX = (int)Mathf.Clamp((topTileEdge - posX) - bottomY, 0.0f, Map.cTileSize - 1); } if (IsFlippedX(tileCollisionType)) { posY = (int)Mathf.Clamp(rightTileEdge - rightX, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp((rightTileEdge - posY) - leftX, 0.0f, Map.cTileSize - 1); } else { posY = (int)Mathf.Clamp(leftX - leftTileEdge, 0.0f, Map.cTileSize - 1); sizeY = (int)Mathf.Clamp(rightX - (leftTileEdge + posY), 0.0f, Map.cTileSize - 1); } offset = new SlopeOffsetI(slopeOffsets[(int)tileCollisionType][posX][posY][sizeX][sizeY]); if (IsFlippedY(tileCollisionType)) { offset.collidingBottom = offset.collidingLeft; offset.freeDown = offset.freeLeft; offset.collidingTop = offset.collidingRight; offset.freeUp = offset.freeRight; } else { offset.collidingBottom = -offset.collidingRight; offset.freeDown = -offset.freeRight; offset.collidingTop = -offset.collidingLeft; offset.freeUp = -offset.freeLeft; } } if (topTileEdge < topY) { if (offset.freeDown < 0) offset.freeDown -= (int)(topY - topTileEdge); offset.collidingTop = offset.freeDown; } if (bottomTileEdge > bottomY) { if (offset.freeUp > 0) offset.freeUp += Mathf.RoundToInt(bottomTileEdge - bottomY); offset.collidingBottom = offset.freeUp; } return offset; }
That's it—now we can use translated slopes as well.
On the animation above, there are 45, 22, 15 and 11-degree slopes. Thanks to the 90-degree rotations, we also can get 79, 75 and 68-degree slopes without defining additional slope tiles. You can also see that the 79-degree slope is too steep to move on smoothly with our value of cSlopeWallHeight
.
Handle One-Way Platforms
In all this hassle, we've broken our support for one-way platforms. We need to fix that, and extend the functionality to slopes as well. One-way platforms are as important or often even more important than the solid tiles, so we can't afford to miss them.
Add the One-Way Types
The first thing we need to do is to add new collision types for one-way platforms. We'll add them past the non-one-way collision types and also mark where they start, so later on we have an easy time telling whether a particular collision type is one-way or not.
public enum TileCollisionType { Empty = 0, Full, SlopesStart, ... Slope45, Slope45FX, Slope45FY, Slope45FXY, Slope45F90, Slope45F90X, Slope45F90Y, Slope45F90XY, //... OneWayStart, OneWaySlope45, OneWaySlope45FX, OneWaySlope45FY, OneWaySlope45FXY, OneWaySlope45F90, OneWaySlope45F90X, OneWaySlope45F90Y, OneWaySlope45F90XY, //... SlopeEnd = OneWaySlopeMid4RevF90XY, OneWayFull, OneWayEnd, Count, }
Now all one-way platforms are between the OneWayStart
and OneWayEnd
enums, so we can easily create a function which will return this information.
public static bool IsOneWay(TileCollisionType type) { return ((int)type > (int)TileCollisionType.OneWayStart && (int)type < (int)TileCollisionType.OneWayEnd); }
The one-way variants of slopes should point to the same data that the non-one-way platforms do, so no worries of extending memory requirements further here.
case TileCollisionType.Slope45: slopesHeights[i] = slope45; slopesExtended[i] = Extend(slopesHeights[i]); posByHeightCaches[i] = CachePosByHeight(slopesHeights[i]); slopeHeightByPosAndSizeCaches[i] = CacheSlopeHeightByPosAndLength(slopesHeights[i]); slopeOffsets[i] = CacheSlopeOffsets(slopesExtended[i]); break; case TileCollisionType.Slope45FX: case TileCollisionType.Slope45FY: case TileCollisionType.Slope45FXY: case TileCollisionType.Slope45F90: case TileCollisionType.Slope45F90X: case TileCollisionType.Slope45F90XY: case TileCollisionType.Slope45F90Y: case TileCollisionType.OneWaySlope45: case TileCollisionType.OneWaySlope45FX: case TileCollisionType.OneWaySlope45FY: case TileCollisionType.OneWaySlope45FXY: case TileCollisionType.OneWaySlope45F90: case TileCollisionType.OneWaySlope45F90X: case TileCollisionType.OneWaySlope45F90XY: case TileCollisionType.OneWaySlope45F90Y: slopesHeights[i] = slopesHeights[(int)TileCollisionType.Slope45]; slopesExtended[i] = slopesExtended[(int)TileCollisionType.Slope45]; posByHeightCaches[i] = posByHeightCaches[(int)TileCollisionType.Slope45]; slopeHeightByPosAndSizeCaches[i] = slopeHeightByPosAndSizeCaches[(int)TileCollisionType.Slope45]; slopeOffsets[i] = slopeOffsets[(int)TileCollisionType.Slope45]; break;
Cover the Additional Data
Now let's add variables which will allow us to make an object ignore one-way platforms. One will be an object flag, which will basically be for setting permanent ignoring of one-way platforms—this will be useful for flying monsters and other objects which do not have any need for using the platforms, and another flag to temporarily disable collision with one-way platforms, just for the sake of falling through them.
The first variable will be inside the MovingObject
class.
public bool mIgnoresOneWay = false; public bool mOnOneWayPlatform = false; public bool mSticksToSlope = true; public bool mIsKinematic = false;
The second one is inside the PositionState
structure.
public bool onOneWay; public bool tmpIgnoresOneWay;
We'll also add another variable here which will hold the Y coordinate of the platform we want to skip.
public bool onOneWay; public bool tmpIgnoresOneWay; public int oneWayY;
To make one-way platforms work, we'll simply be ignoring a single horizontal layer of platforms. As we enter another layer, that is our character's Y position has changed in the map coordinates, then we set the character to collide with the one-way platforms again.
Modify the Collision Checks
Let's go to our CollidesWithTileBottom
function. First of all, as we iterate through tiles, let's check if it's a one-way platform, and if so, whether we should even consider colliding with this tile or not.
public bool CollidesWithTileBottom(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x - 0.5f, topRight.y - 0.5f)); Vector2i bottomleftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y - 0.5f)); bool isOneWay; for (int x = bottomleftTile.x; x <= topRightTile.x; ++x) { var tileCollisionType = mMap.GetCollisionType(x, bottomleftTile.y); isOneWay = Slopes.IsOneWay(tileCollisionType); if ((mIgnoresOneWay || state.tmpIgnoresOneWay) && isOneWay) continue;
We should collide with one-way platforms only if the distance to the top of the platform is less than the cSlopeWallHeightConstant
, so we can actually come on top of it. Let's add this to the condition already laid out, and we also need to assign proper values to state.onOneWay
and state.oneWayY
.
if (((sf.freeUp >= 0 && sf.collidingBottom == sf.freeUp) || (mSticksToSlope && state.pushedBottom && sf.freeUp - sf.collidingBottom < Constants.cSlopeWallHeight && sf.freeUp >= sf.collidingBottom))&& !(isOneWay && Mathf.Abs(sf.collidingBottom) >= Constants.cSlopeWallHeight)) { state.onOneWay = isOneWay; state.oneWayY = bottomleftTile.y; state.pushesBottomTile = true; state.bottomTile = new Vector2i(x, bottomleftTile.y); position.y += sf.collidingBottom; topRight.y += sf.collidingBottom; bottomLeft.y += sf.collidingBottom; return true; }
For the CollidesWithTileTop
function, we simply ignore one-way platforms.
public bool CollidesWithTileTop(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x - 0.5f, topRight.y + 0.5f)); Vector2i bottomleftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y + 0.5f)); for (int x = bottomleftTile.x; x <= topRightTile.x; ++x) { var tileCollisionType = mMap.GetCollisionType(x, topRightTile.y); if (Slopes.IsOneWay(tileCollisionType)) continue;
For the horizontal collision check, there will be a bit more work. First off, let's create two additional booleans at the beginning, which will serve as information about whether the currently processed tile is one-way, and whether the tile from the previous iteration has been a one-way platform.
public bool CollidesWithTileRight(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state, bool move = false) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x + 0.5f, topRight.y - 0.5f)); Vector2i bottomLeftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y + 0.5f)); float slopeOffset = 0.0f, oldSlopeOffset = 0.0f; bool wasOneWay = false, isOneWay; TileCollisionType slopeCollisionType = TileCollisionType.Empty;
Now we're interested in iterating through a one-way platform if we're moving along it. We can't really collide with one-way platforms from right or left, but if the character moves along a slope that's also a one-way platform, then it needs to be handled in the same way that a normal slope would.
public bool CollidesWithTileRight(ref Vector2 position, ref Vector2 topRight, ref Vector2 bottomLeft, ref PositionState state, bool move = false) { Vector2i topRightTile = mMap.GetMapTileAtPoint(new Vector2(topRight.x + 0.5f, topRight.y - 0.5f)); Vector2i bottomLeftTile = mMap.GetMapTileAtPoint(new Vector2(bottomLeft.x + 0.5f, bottomLeft.y + 0.5f)); float slopeOffset = 0.0f, oldSlopeOffset = 0.0f; bool wasOneWay = false, isOneWay; TileCollisionType slopeCollisionType = TileCollisionType.Empty; for (int y = bottomLeftTile.y; y <= topRightTile.y; ++y) { var tileCollisionType = mMap.GetCollisionType(topRightTile.x, y); isOneWay = Slopes.IsOneWay(tileCollisionType); if (isOneWay && (!move || mIgnoresOneWay || state.tmpIgnoresOneWay || y != bottomLeftTile.y)) continue;
Now make sure we can't collide with a slope as if it was a wall.
if (!isOneWay && (Mathf.Abs(slopeOffset) >= Constants.cSlopeWallHeight || (slopeOffset < 0 && state.pushesBottomTile) || (slopeOffset > 0 && state.pushesTopTile))) { state.pushesRightTile = true; state.rightTile = new Vector2i(topRightTile.x, y); return true; }
And if that's not the case and the offset is small enough to climb it, then remember that we're moving along a one-way platform now.
if (!isOneWay && (Mathf.Abs(slopeOffset) >= Constants.cSlopeWallHeight || (slopeOffset < 0 && state.pushesBottomTile) || (slopeOffset > 0 && state.pushesTopTile))) { state.pushesRightTile = true; state.rightTile = new Vector2i(topRightTile.x, y); return true; } else if (Mathf.Abs(slopeOffset) > Mathf.Abs(oldSlopeOffset)) { wasOneWay = isOneWay; slopeCollisionType = tileCollisionType; state.rightTile = new Vector2i(topRightTile.x, y); }
Now what's left here is to make sure that every time we change the position state we also need to update the onOneWay
variable.
state.onOneWay = wasOneWay;
Jumping Down
We need to stop ignoring the one-way platforms once we change the Y position in the map coordinates. We're going to set up our condition after the movement on the Y axis in the Move function. We need to add it at the end of the second case.
else if (move.y != 0.0f && move.x == 0.0f) { MoveY(ref position, ref foundObstacleY, move.y, step.y, ref topRight, ref bottomLeft, ref state); if (step.y > 0.0f) state.pushesBottomTile = CollidesWithTileBottom(ref position, ref topRight, ref bottomLeft, ref state); else state.pushesTopTile = CollidesWithTileTop(ref position, ref topRight, ref bottomLeft, ref state); if (!mIgnoresOneWay && state.tmpIgnoresOneWay && mMap.GetMapTileYAtPoint(bottomLeft.y - 0.5f) != state.oneWayY) state.tmpIgnoresOneWay = false; }
And also at the end of the third case.
else { float speedRatio = Mathf.Abs(speed.y) / Mathf.Abs(speed.x); float vertAccum = 0.0f; while (!foundObstacleX && !foundObstacleY && (move.x != 0.0f || move.y != 0.0f)) { vertAccum += Mathf.Sign(step.y) * speedRatio; MoveX(ref position, ref foundObstacleX, step.x, step.x, ref topRight, ref bottomLeft, ref state); move.x -= step.x; while (!foundObstacleY && move.y != 0.0f && (Mathf.Abs(vertAccum) >= 1.0f || move.x == 0.0f)) { move.y -= step.y; vertAccum -= step.y; MoveY(ref position, ref foundObstacleX, step.y, step.y, ref topRight, ref bottomLeft, ref state); } } if (step.x > 0.0f) state.pushesLeftTile = CollidesWithTileLeft(ref position, ref topRight, ref bottomLeft, ref state); else state.pushesRightTile = CollidesWithTileRight(ref position, ref topRight, ref bottomLeft, ref state); if (step.y > 0.0f) state.pushesBottomTile = CollidesWithTileBottom(ref position, ref topRight, ref bottomLeft, ref state); else state.pushesTopTile = CollidesWithTileTop(ref position, ref topRight, ref bottomLeft, ref state); if (!mIgnoresOneWay && state.tmpIgnoresOneWay && mMap.GetMapTileYAtPoint(bottomLeft.y - 0.5f) != state.oneWayY) state.tmpIgnoresOneWay = false; }
That should do it. Now the only thing we need to do for a character to drop from a one-way platform is to set its tmpIgnoresOneWay
to true.
if (KeyState(KeyInput.GoDown)) mPS.tmpIgnoresOneWay = true;
Let's see how this looks in action.
Summary
Whew, that was a lot of work, but it was worth it. The result is very flexible and robust. We can define any kind of slope thanks to our handling of collision bitmaps, translate the tiles, and turn them into one-way platforms.
This implementation still isn't optimized, and I'm sure I've missed a lot of opportunities for that handed by our new one-pixel integration method. I'm also pretty sure that a lot of additional collision checks could be skipped, so if you improve this implementation then let me know in the comments section!
Thanks for sticking with me this far, and I hope this tutorial is of use to you!