Handling Movement Part 2: Scaling and Z-Positions

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:

An illustration of a piazza in Lecce, Italy, with a man in trench coat standing next to a box with a rabbit poking out of it. He is saying 'Are...are you seeing this?'

If we tap the alley, then as he moves, he should end up behind the box.

An illustration of a piazza in Lecce, Italy, with a man in trench coat standing behind a box with a rabbit poking out of it.

Or if we tap the bottom of the screen, then as he comes towards us, he should end up in front of the box.

An illustration of a piazza in Lecce, Italy, with a man in trench coat standing in front of a box with a rabbit poking out of it.

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:

An illustration of a piazza in Lecce, Italy, with a man in trench coat standing behind a box with a rabbit poking out of it, except he's drawn on top of it so it looks completely wrong and broken
His size and y-position is correct, but because he has a higher z-position, he appears in front of the crate.

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:

An illustration of a piazza in Lecce, Italy. There are four men with trench coats but their perspective is all wrong so it looks like they go from tiny to gigantic.
Note the menacing giant in the alley…

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:

An illustration of a piazza in Lecce, Italy. There are three men in trench coats standing in the piazza and their perspective so they gradually get smaller the further into the scene they go.

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.

Screenshot of Indiana Jones and the Fate of Atlantis. Indy is meeting Sophia backstage at her presentation.
An example of deep perspective. If Indy walked the length of the stage, you’d want him to move slower the further into the scene he walked.

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…)

A screenshot of my adventure game scene after reloading. The scene has two characters facing each other in a dark alley, one in a pale yellow trench coat the other in a purple suit. The non-player character is now in a new position.
There’s almost no depth here.

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.