NEW: Master Swift design patterns with my latest book! >>

< Previous: Penguin, show thyself: SKAction moveBy(x:y:duration:)   Next: Wrap up >

Whack to win: SKAction sequences

To bring this project to a close, we still need to do two major components: letting the player tap on a penguin to score, then letting the game end after a while. Right now it never ends, so with popupTime getting lower and lower it means the game will become impossible after a few minutes.

We're going to add a hit() method to the WhackSlot class that will handle hiding the penguin. This needs to wait for a moment (so the player still sees what they tapped), move the penguin back down again, then set the penguin to be invisible again.

We're going to use an SKAction for each of those three things, which means you need to learn some new uses of the class:

  • SKAction.wait(forDuration:) creates an action that waits for a period of time, measured in seconds.
  • SKAction.run(block:) will run any code we want, provided as a closure. "Block" is Objective-C's name for a Swift closure.
  • SKAction.sequence() takes an array of actions, and executes them in order. Each action won't start executing until the previous one finished.

We need to use SKAction.run(block:) in order to set the penguin's isVisible property to be false rather than doing it directly, because we want it to fit into the sequence. Using this technique, it will only be changed when that part of the sequence is reached.

Put this method into the WhackSlot class:

func hit() {
    isHit = true

    let delay = SKAction.wait(forDuration: 0.25)
    let hide = SKAction.moveBy(x: 0, y:-80, duration:0.5)
    let notVisible = SKAction.run { [unowned self] in self.isVisible = false }
    charNode.run(SKAction.sequence([delay, hide, notVisible]))
}

With that new method in place, we can call it from the touchesBegan() method in GameScene.swift. This method needs to figure out what was tapped using the same nodes(at:) method you saw in project 11: find any touch, find out where it was tapped, then get a node array of all nodes at that point in the scene.

We then need to loop through the list of all nodes that are at that point, and see if they have the name "charFriend" or "charEnemy" and take the appropriate action. Rather than dump all the code on you at once, here's the basic outline of touchesBegan() to start with:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let touch = touches.first {
        let location = touch.location(in: self)
        let tappedNodes = nodes(at: location)

        for node in tappedNodes {
            if node.name == "charFriend" {
                // they shouldn't have whacked this penguin
            } else if node.name == "charEnemy" {
                // they should have whacked this one
            }
        }
    }
}

Nothing complicated there – this is all stuff you know already.

What is new is what comes in place of those two comments. The first comment marks the code block that will be executed if the player taps a friendly penguin, which is obviously against the point of the game.

When this happens, we need to call the hit() method to make the penguin hide itself, subtract 5 from the current score, then run an action that plays a "bad hit" sound. All of that should only happen if the slot was visible and not hit.

The code for this block is going to do something interesting that you haven't seen before, and it looks like this:

let whackSlot = node.parent!.parent as! WhackSlot

It gets the parent of the parent of the node, and typecasts it as a WhackSlot. This line is needed because the player has tapped the penguin sprite node, not the slot – we need to get the parent of the penguin, which is the crop node it sits inside, then get the parent of the crop node, which is the WhackSlot object, which is what this code does.

You're also going to meet two new pieces of code. First, the -= operator, which is similar to += and *= and means "subtract and assign." So, a -= 5 means "subtract 5 from a." The second new piece of code is SKAction's playSoundFileNamed() method, which plays a sound and optionally waits for the sound to finish playing before continuing – useful if you're using an action sequence.

We haven't used sound files in iOS yet, but there isn't really a whole lot to say. The three main sound file formats you'll use are MP3, M4A and CAF, with the latter being a renamed AIFF file. AIFF is a pretty terrible file format when it comes to file size, but it's much faster to load and use than MP3s and M4As, so you'll use them often.

Put this code where the // they shouldn't have whacked this penguin comment was:

let whackSlot = node.parent!.parent as! WhackSlot
if !whackSlot.isVisible { continue }
if whackSlot.isHit { continue }

whackSlot.hit()
score -= 5

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

When the player taps a bad penguin, the code is similar. The differences are that we want to add 1 to the score (so that it takes five correct taps to offset one bad one), and run a different sound. But we're also going to set the xScale and yScale properties of our character node so the penguin visibly shrinks in the scene, as if they had been hit.

Put this code where the // they should have whacked this one comment was:

let whackSlot = node.parent!.parent as! WhackSlot
if !whackSlot.isVisible { continue }
if whackSlot.isHit { continue }

whackSlot.charNode.xScale = 0.85
whackSlot.charNode.yScale = 0.85

whackSlot.hit()
score += 1

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

Since we're now potentially modifying the xScale and yScale properties of our character node, we need to reset them to 1 inside the show() method of the slot. Put this just before the run() call inside show():

charNode.xScale = 1
charNode.yScale = 1

This game is almost done. Thanks to the property observer we put in early on the game is now perfectly playable, at least until popupTime gets so low that the game is effectively unplayable.

To fix this final problem and bring the project to a close, we're going to limit the game to creating just 30 rounds of enemies. Each round is one call to createEnemy(), which means it might create up to five enemies at a time.

First, add this property to the top of your game scene:

var numRounds = 0

Every time createEnemy() is called, we're going to add 1 to the numRounds property. When it is greater than or equal to 30, we're going to end the game: hide all the slots, show a "Game over" sprite, then exit the method. Put this code just before the popupTime assignment in createEnemy():

numRounds += 1

if numRounds >= 30 {
    for slot in slots {
        slot.hide()
    }

    let gameOver = SKSpriteNode(imageNamed: "gameOver")
    gameOver.position = CGPoint(x: 512, y: 384)
    gameOver.zPosition = 1
    addChild(gameOver)

    return
}

That uses a position zPosition so that the game over graphic is placed over other items in our game.

The game is now complete! Go ahead and play it for real and see how you do. If you're using the iOS simulator, bear in mind that it's much hard to move a mouse pointer than it is to use your fingers on a real iPad, so don't adjust the difficulty unless you're testing on a real device!

Learn Swift faster!

Take your Swift learning to the next level: buy the Hacking with Swift e-book and get bonus material to help you learn faster!

< Previous: Penguin, show thyself: SKAction moveBy(x:y:duration:)   Next: Wrap up >
Click here to visit the Hacking with Swift store >>