Day 87 - 100 Days of Swift

6 minute read

Project 26 (part 3)

Day 87 is the third part of the twenty-sixth project. You review the stuff you learned and then he gives you three challenges to take the marble maze app a little farther. He challenges you to rewrite loadLevel so that is is built up of smaller functions. He challenges you to actually do something when the player gets to the “finish” trophy. And he challenges you to add a new block type that teleports the player from one place to the next.

The first challenge I did as I wrote it the first time around, so I didn’t have much to do there. I did add an extra little method to fade nodes in when you add them though, which makes things transition a little nicer when you go from one level to the next:

1
2
3
4
5
6
7
8
// This is called pretty much anywhere addChild was called before.
func addNode(_ node: SKNode?) {
    guard let node = node else { return }
    node.alpha = 0
    addChild(node)
    let fade = SKAction.fadeIn(withDuration: 1)
    node.run(fade)
}

For the second part I added currentLevel property to track what level the user is on. Then I checked for that at the beginning of loadLevel and if there isn’t a “next level”, I know the game is over so I can present the end of game stuff. This is kind of a hacky way to go about this, but it lets me add an arbitrary number of levels and meant very little change to the existing code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var currentLevel = 0

// At the beginning of loadLevel
currentLevel += 1
guard let levelURL = Bundle.main.url(forResource: "level\(currentLevel)",
                                     withExtension: "txt") else {
    endGame()
    return
}

func endGame() {
    let gameOverNode = SKLabelNode(fontNamed: "Chalkduster")
    gameOverNode.horizontalAlignmentMode = .center
    gameOverNode.fontSize = 64
    gameOverNode.position = CGPoint(x: 512, y: 360)
    gameOverNode.zPosition = 2
    gameOverNode.text = "You Made It To The End!"
    gameOverNode.setScale(0.0001)
    addChild(gameOverNode)
    let duration = 0.5
    gameOverNode.run(SKAction.scale(to: 1, duration: duration))

    let moveAction = SKAction.move(to: CGPoint(x: 48, y: 300),
                                   duration: duration)
    let scaleAction = SKAction.scale(to: 2, duration: duration)
    let group = SKAction.group([moveAction, scaleAction])
    scoreLabel.run(group)
}

For the third part, I added a new image, scaled to be the same size as all the others and gave it it’s own loadPortal method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func loadPortal(at position: CGPoint) {
    let node = SKSpriteNode(imageNamed: "portal")
    node.name = "portal"
    node.position = position
    node.run(SKAction.repeatForever(SKAction.rotate(byAngle: -.pi,
                                                    duration: 1)))
    node.physicsBody = SKPhysicsBody(circleOfRadius: node.size.width / 2)
    node.physicsBody?.isDynamic = false

    node.physicsBody?.categoryBitMask = CollisionTypes.vortex.rawValue
    node.physicsBody?.contactTestBitMask = CollisionTypes.player.rawValue
    node.physicsBody?.collisionBitMask = 0

    portals.append(node)
    addNode(node)
}

// In loadLevel
else if letter == "p" {
    loadPortal(at: position)
}

The main difference here is that it gets added to the portals array which is how I decided to keep track of what the “next” portal is. I also added a portalIndex which points to the next portal we should teleport to from the given one. And, I added a shouldTeleport bool, to add a little delay between teleport times so the marble didn’t just teleport back and forth forever:

1
2
3
4
5
6
7
8
9
10
11
var portals: [SKNode] = []
private var _portalIndex = 0
var portalIndex: Int {
    get {
        return _portalIndex
    }
    set {
        _portalIndex = newValue % portals.count
    }
}
var shouldTeleport = true

The private var with a getter and setter is not really a very Swifty way to do this, but it makes sure the index is always valid. And it is what it is.

Then, I just added to my switch statement for when a marble comes in contact with a portal:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// In playerCollided(with:)
case "portal":
    guard shouldTeleport else { return }
    shouldTeleport = false
    player.physicsBody?.isDynamic = false
    isGameOver = true

    let move = SKAction.move(to: node.position,
                             duration: 0.25)
    let scale = SKAction.scale(to: 0.0001,
                               duration: 0.25)
    let remove = SKAction.removeFromParent()
    let actions = [move, scale, remove]
    let sequence = SKAction.sequence(actions)

    player.run(sequence) { [weak self] in
        if let portal = self?.nextPortal(from: node) {
            self?.player.position = portal.position
        }
        self?.player.setScale(1)
        self?.player.physicsBody?.isDynamic = true
        self?.addNode(self?.player)
        self?.isGameOver = false
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            self?.shouldTeleport = true
        }
    }

func nextPortal(from portal: SKNode) -> SKNode {
    var nextPortal = portals[portalIndex]
    if nextPortal === portal {
        portalIndex += 1
        nextPortal = portals[portalIndex]
    }
    portalIndex += 1
    return nextPortal
}

It’s very similar to what happens when the player hits the vortex, but instead of sending them back to start and subtracting a point, it sends them to the next portal. The nextPortal helper just makes sure the player doesn’t get teleported to the portal they are on. Again, this method lets me have an arbitrary number of portals that provide a reasonable behavior from the user’s perspective.

When it’s all said and done it looks like this (if you don’t want to wait for me to poorly play this game, you can skip to about 1:10 to see th portal part in action):

You can find my version of this project at the end of day 87 on Github here.