In part one, I set up a Movement Component that moved sprites around a space without any regard for the type of scene that they inhabit.
However, many adventure game scenes have some sort of perspective where it’s possible for players to move around an object.
Imagine a game where, say, a character in a trench coat is visiting a square in Lecce, Italy. For some reason, there’s a crate in the middle of it:
If we tap the alley, then as he moves, he should end up behind the box.
Or if we tap the bottom of the screen, then as he comes towards us, he should end up in front of the box.
The problem is that none of this happens automatically. As far as the computer is concerned, these are all on the same plane and which one appears on top is arbitrary. We could easily end up with this, which breaks the illusion of perspective:
Z-Position
A z-position is simple way of telling SpriteKit how sprites are layered in a scene. They determine which sprite will be drawn on top—higher numbers get drawn over lower numbers.
If our man could only walk in front of the box, then this would be easy: background gets 1, box gets 2, our man gets 3. We can set this on the sprite and not think about it again.
The problem is that we want him to be able to go behind the box as well, which means his z-positioning needs to change based on his position within the scene.
Faking Depth
I have a struct called a WalkableArea that stores the rectangle that represents the walkable area.
In order to keep things simple, the walkable area is divided into three sections (in addition to the foreground section for scene items that I might want to appear in front of everything and a background section for things that are truly in the background).
Every frame, the player checks with the walkable area using the following method:
func zPosition( for point : CGPoint ) -> CGFloat { if point.y <= walkableArea.origin.y { // The point is lower than the walkable area, therefore it's closer to us // and should have a higher z-position return 5 } else if point.y >= area.origin.y + area.height { // The point is above the walkable area, therefore it's furthest away from us return 1 } else { // Go through our three divisions, find out which one the point lies in // The divisions are stored in order of higher to lower (which is equivalent to 'deepest' to 'closest') for (idx,rect) in divisions.enumerated() { if rect.contains(point) { switch idx { case 0: return 2 case 1: return 3 default: return 4 } } } } return .middle }
If the z-position is different, then update the sprite property ready for the next frame.
Scaling
Another thing that adventure games have to deal with that top-down or side-on games don’t have to (at least to the same degree) is scaling. If we don’t deal with it correctly, we end up with something like this:
In order for a scene to look realistic, a character needs to be either smaller or larger depending on its y-position within the walkable area:
It’s important to remember that, as far as the computer is concerned, when a sprite moves the only thing that changes is its x- and y-position. The effect of depth is entirely created by making it smaller or larger.
Calculating scale
The range of possible sizes of the player will be different for every scene.
For example, with this scene from Indiana Jones, if Indy was to walk the length of the stage, he could end up being as little as one fifth his current size.
Whereas with this one, we could probably get away with not scaling the sprites at all.
(Not that I would ever do that, the detail-orientated pro that I am…)
So as well as a walkable area, each scene needs to have a maxScale and minScale property to tell us what scale the sprite should be at the upper and lower limits of the walkable area.
With that, can easily work out what scale it should be at any given point.
First, we make sure there actually is a difference. If not, then we can return either the minScale or the maxScale and it makes no difference.
func calculateScale(for target: CGPoint) -> CGFloat { let difference = maxScale - minScale guard difference > 0 else { return maxScale } // More below }
Then we normalise the position of the sprite by dividing it by the total height of the walkable area (taking into account that the bottom of the walkable area might not be at 0).
let normalisedPosition = ( walkableArea.height - ( target.y - walkableArea.origin.y ) ) / walkableArea.height
Then we multiply the difference by the normalisedPosition and add this to the minimum scale to get the new scale.
let newScale = minScale + ( difference * normalisedPosition)
I also added some additional error checking to make sure that we are, in fact, within the acceptable range. If we’re not, then we might be trying to calculate the scale for an invalid point and it means that something has gone wrong somewhere.
if newScale < minScale { print("Error: Scale out of range \(newScale).") } else if newScale > maxScale { print("Error: Scale out of range \(newScale).") } return newScale
With the new scale for the player given its position, we can then use the SpriteKit method setScale()
to set the new scale for the player. All together:
func calculateScale(for target: CGPoint) -> CGFloat { let difference = maxScale - minScale guard difference > 0 else { return maxScale } let normalisedPosition = (walkableArea.height - (target.y - walkableArea.origin.y)) / walkableArea.height let newScale = minScale + ( difference * normalisedPosition) if newScale < minScale { print("Error: Scale out of range \(newScale).") } else if newScale > maxScale { print("Error: Scale out of range \(newScale).") } return minScale + ( difference * factor) } player.setScale( calculateScale(for: player.position ) )
Another thing we can do with this method is adjust the player’s walking speed to take into account their position in the scene. In order to give a feeling of reality, the character should cover less screen distance when they’re smaller (i.e. further away from us) than when they’re larger. We can adjust our update
method in the MoveComponent to take this into account:
let scaledMovementSpeed = moveSpeedInPointsPerSecond * calculateScale(for: currentPosition) let velocity = CGPoint(x: normalisedVector.x * scaledMovementSpeed * CGFloat(seconds), y: normalisedVector.y * scaledMovementSpeed * CGFloat(seconds))
In the final part on movement, we’ll add pathfinding to get the player character to intelligently move around obstacles.