Day 78 - 100 Days of Swift

8 minute read

Project 23 (part 2)

Day 78 is the second part of the twenty third project. Today you get the game into a playable state. You add the ability to toss various sequences of enemies onto the screen. You add the ability to slice through enemies and blow them up. And you add the life tracking code that ends the game when the user runs out of lives.

First, to give the ability to toss enemies on screen in various combinations you add another enum:

1
2
3
4
5
6
7
8
9
10
enum SequenceType: CaseIterable {
    case oneNoBomb
    case one
    case twoWithOneBomb
    case two
    case three
    case four
    case chain
    case fastChain
}

Then you add the function to toss enemies on screen:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
var popupTime = 0.9
var sequence = [SequenceType]()
var sequencePosition = 0
var chainDelay = 3.0
var nextSequenceQueued = true

func tossEnemies() {
    // Speed things up as we go
    popupTime *= 0.991
    chainDelay *= 0.99
    physicsWorld.speed *= 1.02

    let sequenceType = sequence[sequencePosition]

    switch sequenceType {
    // Creates one enemy that isn't a bomb
    case .oneNoBomb:
        createEnemy(forceBomb: .never)

    // Creates one enemy
    case .one:
        createEnemy()

    // Creates two enemies one of which is a bomb
    case .twoWithOneBomb:
        createEnemy(forceBomb: .never)
        createEnemy(forceBomb: .always)

    // Creates two enemies
    case .two:
        (1...2).forEach { _ in self.createEnemy() }

    // Creates three enemies
    case .three:
        (1...3).forEach { _ in self.createEnemy() }

    // Creates four enemies
    case .four:
        (1...4).forEach { _ in self.createEnemy() }

    // Creates a chain of five enemies,
    // thrown one after the other
    case .chain:
        let queue = DispatchQueue.main
        (0...4).forEach { [weak self] in
            let offset = chainDelay / 5.0 * Double($0)
            queue.asyncAfter(deadline: .now() + offset) {
                 self?.createEnemy()
            }
        }

    // Creates a chain of five enemies,
    // thrown quickly one after the other
    case .fastChain:
        let queue = DispatchQueue.main
        (0...4).forEach { [weak self] in
            let offset = chainDelay / 10.0 * Double($0)
            queue.asyncAfter(deadline: .now() + offset) {
                self?.createEnemy()
            }
        }
    }

    sequencePosition += 1
    nextSequenceQueued = false
}

Once the tossEnemies function is ready to go, you can set up the sequence and kick it off in didMove(to:):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// The initial sequence, to ease the user into the game
sequence = [.oneNoBomb,
            .oneNoBomb,
            .twoWithOneBomb,
            .twoWithOneBomb,
            .three,
            .one,
            .chain]

// Add a bunch of random elements
let possible = SequenceType.allCases
(0 ... 1000).forEach { _ in
    if let nextSequence = possible.randomElement() {
        sequence.append(nextSequence)
    }
}

// Start tossing them.
DispatchQueue.main.asyncAfter(deadline: .now() + 2)
{ [weak self] in
    self?.tossEnemies()
}

Finally, you just need to remove the old enemies that fall offscreen and make sure things don’t overlap with the enemy chains in the update method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if activeEnemies.count > 0 {
    let enemies = activeEnemies.enumerated().reversed()
    for (index, node) in enemies {
        if node.position.y < -140 {
            node.removeFromParent()
            activeEnemies.remove(at: index)
        }
    }
} else {
    if !nextSequenceQueued {
        let queue = DispatchQueue.main
        queue.asyncAfter(deadline: .now() + popupTime) {
            [weak self] in self?.tossEnemies()
        }
        nextSequenceQueued = true
    }
}

The first part removes any enemies that have fallen off screen and the second part schedules the next call of tossEnemies if there are no enemies left on screen and nothing else is scheduled.

Next, you add the code for destroying enemies when you swipe through them. The main logic goes in touchesMoved:

1
2
3
4
5
6
7
8
9
let nodesAtPoint = nodes(at: location)

for case let node as SKSpriteNode in nodesAtPoint {
    if node.name == "enemy" {
        destroyPenguin(node)
    } else if node.name == "bomb" {
        destroyBomb(node)
    }
}

You cycle through all the SKSpriteNodes at the point where the user’s touch is and you destroy the ones that make sense to destroy.

The destroyPenguin method looks like this:

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
private func destroyPenguin(_ node: SKSpriteNode) {
    // Add the explosion emitter
    if let emitter = SKEmitterNode(fileNamed: "sliceHitEnemy") {
        emitter.position = node.position
        addChild(emitter)
    }

    // Remove the name (so it can't be destroyed again)
    node.name = ""
    // Stop the movement
    node.physicsBody?.isDynamic = false

    // Set up a sequence to scale and fade the penguin out
    let scaleOut = SKAction.scale(to: 0.001, duration:0.2)
    let fadeOut = SKAction.fadeOut(withDuration: 0.2)
    let group = SKAction.group([scaleOut, fadeOut])
    let seq = SKAction.sequence([group, .removeFromParent()])
    node.run(seq)

    // Add one to the score
    score += 1
    // Remove it from the activeEnemies array
    if let index = activeEnemies.firstIndex(of: node) {
        activeEnemies.remove(at: index)
    }
    // Play the sound effect for detroying a penguin
    run(SKAction.playSoundFileNamed("whack.caf",
                                    waitForCompletion: false))
}

The destroyBomb method looks like this:

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
private func destroyBomb(_ node: SKSpriteNode) {
    guard let bombContainer = node.parent as? SKSpriteNode else { return }

    if let emitter = SKEmitterNode(fileNamed: "sliceHitBomb") {
        emitter.position = bombContainer.position
        addChild(emitter)
    }

    node.name = ""
    bombContainer.physicsBody?.isDynamic = false

    let scaleOut = SKAction.scale(to: 0.001, duration:0.2)
    let fadeOut = SKAction.fadeOut(withDuration: 0.2)
    let group = SKAction.group([scaleOut, fadeOut])

    let seq = SKAction.sequence([group, .removeFromParent()])
    bombContainer.run(seq)

    if let index = activeEnemies.firstIndex(of: bombContainer) {
        activeEnemies.remove(at: index)
    }

    run(SKAction.playSoundFileNamed("explosion.caf",
                                    waitForCompletion: false))
    endGame(triggeredByBomb: true)
}

That calls the endGame function, but before you add that you update the update method with this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if node.position.y < -140 {
    node.removeAllActions()

    if node.name == "enemy" {
        node.name = ""
        subtractLife()

        node.removeFromParent()
        activeEnemies.remove(at: index)
    } else if node.name == "bombContainer" {
        node.name = ""
        node.removeFromParent()
        activeEnemies.remove(at: index)
    }
}

This subtracts one life if the user lets a penguin fall offscreen. The subtractLife function looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private func subtractLife() {
    lives -= 1

    run(SKAction.playSoundFileNamed("wrong.caf",
                                    waitForCompletion: false))

    var life: SKSpriteNode

    if lives == 2 {
        life = livesImages[0]
    } else if lives == 1 {
        life = livesImages[1]
    } else {
        life = livesImages[2]
        endGame(triggeredByBomb: false)
    }

    life.texture = SKTexture(imageNamed: "sliceLifeGone")

    life.xScale = 1.3
    life.yScale = 1.3
    life.run(SKAction.scale(to: 1, duration:0.2))
}

This updates the UI to reflect how many lives the user has left and ends the game if they are out of lives.

Finally, the endGame method looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var isGameEnded = false

func endGame(triggeredByBomb: Bool) {
    guard !isGameEnded else { return }

    isGameEnded = true
    physicsWorld.speed = 0
    isUserInteractionEnabled = false

    bombSoundEffect?.stop()
    bombSoundEffect = nil

    if triggeredByBomb {
        let newTexture = SKTexture(imageNamed: "sliceLifeGone")
        livesImages.forEach { $0.texture = newTexture }
    }
}

And you just have to add a couple of guard statements to make sure nothing else happens when the game is over:

1
2
3
4
5
// At the beginning of tossEnemies()
guard !isGameEnded else { return }

// At the beginning of touchesMoved()
guard !isGameEnded else { return }

With that the game works, except it just freezes when the game ends and the user has no way to restart without quitting the app and reopening it. I’m assuming those are things he leaves for you to do on your own tomorrow on the challenge day. Here’s what it looks like so far:

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