Day 66 - 100 Days of Swift

12 minute read

Consolidation VII

Day 66 is a consolidation day where you review projects 16-18. He also gives you a challenge to build a shooting gallery game from scratch. He gives some general direction, and some resources to get artwork, but other than that it is pretty much all on you to design and implement the game.

The first thing I did was set up the backdrop. I downloaded the artwork from Paul’s shooting game and got it pulled in to the project. His backdrop is split up into five pieces, so that you can have your targets go in between them (to simulate distance), and to let you independently animate the waves.

Screenshot of backdrop in exploded view

I added them all to the scene in a function I called setupBackground:

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
private func setUpBackground() {
    let background = SKSpriteNode(imageNamed: "wood-background")
    background.position = CGPoint(x: 512, y: 384)
    background.setScale(1.28)
    background.zPosition = -1
    addChild(background)

    let curtain = SKSpriteNode(imageNamed: "curtains")
    curtain.position = CGPoint(x: 512, y: 384)
    curtain.setScale(1.28)
    curtain.zPosition = 100
    addChild(curtain)

    let grass = SKSpriteNode(imageNamed: "grass-trees")
    grass.position = CGPoint(x: 512, y: 384)
    grass.setScale(1.28)
    addChild(grass)

    let waterBG = SKSpriteNode(imageNamed: "water-bg")
    waterBG.position = CGPoint(x: 512, y: 248)
    waterBG.setScale(1.28)
    waterBG.zPosition = 2
    addChild(waterBG)

    let waterFG = SKSpriteNode(imageNamed: "water-fg")
    waterFG.position = CGPoint(x: 512, y: 180)
    waterFG.setScale(1.28)
    waterFG.zPosition = 4
    addChild(waterFG)
}

I set their scale because the artwork he gave didn’t quite line up with the iPad size. I’m sure there is a better way to handle this, but it works for this project. I set the position to the center horizontally and figured out the heights by trial and error. The zPosition I set to arbitrary numbers that made sense to me.

I also added actions to the waves to animate them up and down in this function:

1
2
3
4
5
6
7
8
9
10
11
let upAction1 = SKAction.moveBy(x: 0, y: -10, duration: 1.5)
let downAction1 = SKAction.moveBy(x: 0, y: 10, duration: 1.5)
let sequence1 = SKAction.sequence([upAction1, downAction1])
let repeatAction1 = SKAction.repeatForever(sequence1)
waterBG.run(repeatAction1)

let upAction2 = SKAction.moveBy(x: 0, y: -12, duration: 1)
let downAction2 = SKAction.moveBy(x: 0, y: 12, duration: 1)
let sequence2 = SKAction.sequence([upAction2, downAction2])
let repeatAction2 = SKAction.repeatForever(sequence2)
waterFG.run(repeatAction2)

This leads to a scene that looks like this:

Screenshot of backdrop

Next, I set about adding targets to animate through the screen. To do that I wrote a somewhat ugly function called addTarget. It basically has three parts: it loads the necessary SKSpriteNodes, it configures them based on which row they will be in, and it adds their actions to move and remove them:

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
// Outside of the function
var moveInterval: TimeInterval = 3
// These are just the names of all the images
private let targets = ["target0", "target1", "target2", "target3"]
private let sticks = ["stick0", "stick1", "stick2"]

// Inside of addTarget()
// Part 1
let targetName = targets.randomElement()!
let target = SKSpriteNode(imageNamed: targetName)
target.name = SKNode.targetIdentifier
addChild(target)

let stickName = sticks.randomElement()!
let stick = SKSpriteNode(imageNamed: stickName)
stick.position = CGPoint(x: 0, y: -116)
target.addChild(stick)

// Part 2
let kind = Int.random(in: 1...3)
let moveAction: SKAction
if kind == 1 {
    // Front row
    target.position = CGPoint(x: 0, y: 280)
    target.setScale(1.2)
    target.zPosition = 5

    moveAction = SKAction.moveBy(x: 1080,
                                 y: 0,
                                 duration: moveInterval * 1.15)
} else if kind == 2 {
    // Middle row
    target.position = CGPoint(x: 1024, y: 380)
    target.xScale = -1
    target.zPosition = 3

    moveAction = SKAction.moveBy(x: -1080,
                                 y: 0,
                                 duration: moveInterval)
} else if kind == 3 {
    // Back row
    target.position = CGPoint(x: 0, y: 460)
    target.setScale(0.8)
    target.zPosition = 1

    moveAction = SKAction.moveBy(x: 1080,
                                 y: 0,
                                 duration: moveInterval * 0.85)
}  else { return }

// Part 3
let removeAction = SKAction.run {
    stick.removeFromParent()
    target.removeFromParent()
}

let sequence = SKAction.sequence([moveAction, removeAction])
target.run(sequence)

Then I just added a timer to call this function somewhat regularly:

1
2
3
4
5
6
7
8
9
var timer: Timer!
var timerInterval: TimeInterval = 1

// In didMove(to:)
timer = Timer.scheduledTimer(withTimeInterval: timerInterval,
                             repeats: true,
                             block: { _ in
    self.addTarget()
})

Now I had targets that would move through the screen, but they never got any faster and the game never ends. So I made a few tweaks. I pulled setting the timer out into its own function called resetTimer which looks like this:

1
2
3
4
5
6
7
8
9
10
11
timer?.invalidate()

if timerInterval < 0.35 { return }

timer = Timer.scheduledTimer(withTimeInterval: timerInterval,
                             repeats: true,
                             block: { _ in
    self.addTarget()
})

timerInterval *= 0.9

I also added a didSet observer on timerInterval to update the moveInterval so that as the time between targets gets smaller, the speed they move through the screen also gets smaller:

1
2
3
4
5
var timerInterval: TimeInterval = 1 {
    didSet {
        moveInterval = timerInterval * 3
    }
}

Then I added a deployedTargets property and watched it to see if it was time to restart the timer:

1
2
3
4
5
6
7
8
9
10
11
private var deployedNodes = 0 {
    didSet {
        if deployedNodes >= 6 {
            deployedNodes = 0
            resetTimer()
        }
    }
}

// At the end of addTarget
deployedNodes += 1

This leads to targets that are deployed increasingly fast and moving at increasingly fast speeds, until a relatively reasonable limit where the game ends. The game ending isn’t very pretty yet, but I’ll come back to that after the targets are shootable.

To make the targets shootable I implemented touchesBegan:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
override func touchesBegan(_ touches: Set<UITouch>,
                           with event: UIEvent?) {

    let touch = touches.first!
    let location = touch.location(in: self)
    let locationNodes = nodes(at: location)

    let targets = locationNodes.filter { $0.name == SKNode.targetIdentifier }
                                 .sorted { $0.zPosition > $1.zPosition }

    if let target = targets.first {
        shootTarget(target)
    }
}

This gets the touch, then finds the location of the touch in the scene, then it gets an array of all the nodes at that location, filters them to just the ones that have the targetIdentifier, and then sorts them by the highest zPosition. If there is a first node in that resulting array, it calls shootTarget with it.

shootTarget 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
// Update score
score += 15 / Int(node.zPosition)

// Animate out and remove
node.removeAllActions()
let duration = 0.4
let scaleAction = SKAction.scaleX(by: 0.9,
                                  y: 0.7,
                                  duration: duration)
let moveAction = SKAction.moveBy(x: 0,
                                 y: -30,
                                 duration: duration)
let fadeAction = SKAction.fadeAlpha(to: 0,
                                    duration: duration)
let group = [scaleAction,
             moveAction,
             fadeAction]

let groupAction = SKAction.group(group)
let removeAction = SKAction.run {
    node.removeFromParent()
}
let sequence = SKAction.sequence([groupAction, removeAction])
node.run(sequence)

I add to the score 15 divided by the zPosition of the node. The front row is at zPosition 5, so they are worth 3 points. The middle row is at 3 so they are worth 5 points. The back row is at 1 so they are worth 15 points. After playing the final game a few times, I’m not totally satisfied with the scoring, and I could do some work to make it better, but if I started down that road I would end up spending so much time dialing in this score that doesn’t matter. This works well enough for now.

It also adds a sequence of actions that kind of make it look like the target has been hit and is falling over backwards.

The score and scoreLabel look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var score: Int = 0 {
    didSet {
        scoreLabel.text = "Score: \(score)"
    }
}

private let scoreLabel: SKLabelNode = {
    let node = SKLabelNode(fontNamed: "Chalkduster")
    node.fontSize = 48
    node.horizontalAlignmentMode = .left
    node.position = CGPoint(x: 120, y: 64)
    node.zPosition = 101

    return node
}()

At this point I also added a limit to the number of shots the player has before they have to reload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private var remainingShots: Int = 0 {
    didSet {
        remainingShotsNode.texture = SKTexture(imageNamed: "shots\(remainingShots)")
    }
}

private let remainingShotsNode: SKSpriteNode = {
    let node = SKSpriteNode(imageNamed: "shots3")
    node.position = CGPoint(x: 860, y: 84)
    node.zPosition = 101

    return node
}()

// In touchesBegan, before shootTarget is called:
guard remainingShots > 0 else { return }

// Update remaining shots
remainingShots -= 1

That counts down and prevents them from shooting if they don’t have any bullets left. To reload, I decided that the user should have to swipe down on the screen, so I added a property to keep track of where the touch started and then implemented touchesEnded:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private var startTouchPoint: CGPoint?

// In touchesBegan, before the check for remainingShots
startTouchPoint = location

// In touchesEnded
guard let startTouchPoint = startTouchPoint else { return }

let touch = touches.first!
let location = touch.location(in: self)
let delta = startTouchPoint.y - location.y

if delta > 200 {
    reloadBullets()
}
self.startTouchPoint = nil

This means they have to swipe a distance of at least 200 before the shots will reload. reloadBullets looks like this:

1
2
3
4
5
6
7
private func reloadBullets() {
    // update score
    score -= 5

    // update remaining bullets
    remainingShots = 3
}

It costs the player 5 points every time they want to reload. This is to encourage them to make the most of the bullets they have.

Finally, I just added a few things to keep track of when the game is over and to allow the player to restart the game. I added a property to track whether the game is over or not and added a function that ends the game. It animates in the ‘Game Over’ node and sets the isGameOver property:

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
private var isGameOver = false

private func endGame() {

    gameOverNode.setScale(0)
    addChild(gameOverNode)
    let waitAction = SKAction.wait(forDuration: 1)
    let scaleAction = SKAction.scale(to: 1, duration: 0.2)
    let waitAction2 = SKAction.wait(forDuration: 3)
    let endGameAction = SKAction.run {
        self.isGameOver = true
    }

    let sequence = [waitAction,
                    scaleAction,
                    waitAction2,
                    endGameAction]

    let sequenceAction = SKAction.sequence(sequence)
    gameOverNode.run(sequenceAction)
}

// In resetTimer
if timerInterval < 0.35 { endGame(); return }

// At the beginning of touchesBegan
guard !isGameOver else { return }

Then I added a function to restart the game:

1
2
3
4
5
6
7
8
9
10
11
12
private func restartGame() {
    isGameOver = false

    gameOverNode.removeFromParent()

    score = 0
    remainingShots = 3
    timerInterval = 1
    startTouchPoint = nil

    resetTimer()
}

And called it in touchesBegan:

1
guard !isGameOver else { restartGame(); return }

This means that when the game is over and the user taps the screen, it will restart the game. When it is all said and done, it looks like this:

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