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

< Previous: Buttons… buttons everywhere!   Next: It's play time: index(of:) and joined() >

Loading a level: addTarget and shuffling arrays

This game asks players to spell seven words out of various letter groups, and each word comes with a clue for them to guess. It's important that the total number of letter groups adds up to 20, as that's how many buttons you have. I created the first level for you, and it looks like this:

HA|UNT|ED: Ghosts in residence
LE|PRO|SY: A Biblical skin disease
TW|ITT|ER: Short online chirping
OLI|VER: Has a Dickensian twist
ELI|ZAB|ETH: Head of state, British style
SA|FA|RI: The zoological web
POR|TL|AND: Hipster heartland

As you can see, I've used the pipe symbol to split up my letter groups, meaning that one button will have "HA", another "UNT", and another "ED". There's then a colon and a space, followed by a simple clue. This level is in the files for this project you should download from GitHub at https://github.com/twostraws/HackingWithSwift. You should copy level1.txt into your Xcode project as you have done before.

Our first task will be to load the level and configure all the buttons to show a letter group. We're going to need three arrays to handle this: one to store all the buttons, one to store the buttons that are currently being used to spell an answer, and one for all the possible solutions. Further, we need two integers: one to hold the player's score, which will start at 0 but obviously change during play, and one to hold the current level.

So, declare these properties just below the current @IBOutlets from Interface Builder:

var letterButtons = [UIButton]()
var activatedButtons = [UIButton]()
var solutions = [String]()

var score = 0
var level = 1

Now, you'll notice we don't have @IBOutlet references to any of our buttons, and that's entirely intentional: it wouldn't be very smart to create an @IBOutlet for every button. Interface Builder does have a solution to this, called Outlet Collections, which are effectively an IBOutlet array, but even that solution requires you to Ctrl-drag from every button and quite frankly I don't think you have the patience after spending so much time in Interface Builder!

As a result, we're going to take a simple shortcut. And this shortcut will also deal with calling methods when any of the buttons are tapped, so all in all it's a clean and easy solution. The shortcut is this: all our buttons have the tag 1001, so we can loop through all the views inside our view controller, and modify them only if they have tag 1001. Add this code to your viewDidLoad() method beneath the call to super:

for subview in view.subviews where subview.tag == 1001 {
    let btn = subview as! UIButton
    letterButtons.append(btn)
    btn.addTarget(self, action: #selector(letterTapped), for: .touchUpInside)
}

As you can see, view.subviews is an array containing all the UIViews that are currently placed in our view controller, which is all the buttons and labels, plus that text field. I've used a more enhanced version of a regular for loop that adds a where condition so that the only items inside the loop are subviews with that tag. If we find a view with tag 1001, we typecast it as a UIButton then append it to our buttons array.

I also took this opportunity to use a new method, called addTarget(). This is the code version of Ctrl-dragging in a storyboard and it lets us attach a method to the button click. You should remember .touchUpInside from all the button actions you have made, because that's the event that means the button was tapped.

By adding #selector(letterTapped) to each button in code, we're saving ourselves a lot of Ctrl-dragging in Interface Builder. When the letterTapped() method is called, the button that was tapped will be sent as a parameter, which is perfect for us because we can read the letter group on the button and use it to spell words. We haven’t created that method yet so you’ll get a compiler error, but add this dummy below to make Xcode happy:

@objc func letterTapped(btn: UIButton) {
}

We’ll fill that in later, but first let’s focus on loading level data into the game. This is the first time we’re calling an @objc method from something other than a UIBarButtonItem, but the rules are the same: we used #selector with the UIButton, so we need to use @objc with the method it will call.

We're going to isolate level loading into a single method, called loadLevel(). This needs to do two things: load and parse our level text file in the format I showed you earlier, then randomly assign letter groups to buttons. In project 5 you already learned how to create a String using contentsOfFile to load files from disk, and we'll be using that to load our level. In that same project you learned how to use components(separatedBy:) to split up a string into an array, and we'll use that too.

We'll also need to use the array shuffling code from the GameplayKit framework that we've used before. But: there are some new things to learn, honest! First, we'll be using the enumerated() method to loop over an array. We haven't used this before, but it's helpful because it passes you each object from an array as part of your loop, as well as that object's position in the array.

There's also a new string method to learn, called replacingOccurrences(). This lets you specify two parameters, and replaces all instances of the first parameter with the second parameter. We'll be using this to convert "HA|UNT|ED" into HAUNTED so we have a list of all our solutions.

Before I show you the code, watch out for how I use the method's three variables: clueString will store all the level's clues, solutionString will store how many letters each answer is (in the same position as the clues), and letterBits is an array to store all letter groups: HA, UNT, ED, and so on.

Because we’re going to use GameplayKit, you need to start by adding this import to the top of the file:

import GameplayKit

Now add the loadLevel() method:

func loadLevel() {
    var clueString = ""
    var solutionString = ""
    var letterBits = [String]()

    if let levelFilePath = Bundle.main.path(forResource: "level\(level)", ofType: "txt") {
        if let levelContents = try? String(contentsOfFile: levelFilePath) {
            var lines = levelContents.components(separatedBy: "\n")
            lines = GKRandomSource.sharedRandom().arrayByShufflingObjects(in: lines) as! [String]

            for (index, line) in lines.enumerated() {
                let parts = line.components(separatedBy: ": ")
                let answer = parts[0]
                let clue = parts[1]

                clueString += "\(index + 1). \(clue)\n"

                let solutionWord = answer.replacingOccurrences(of: "|", with: "")
                solutionString += "\(solutionWord.count) letters\n"
                solutions.append(solutionWord)

                let bits = answer.components(separatedBy: "|")
                letterBits += bits
            }
        }
    }

    // Now configure the buttons and labels
}

If you read all that and it made sense first time, great! You can skip over the next few paragraphs and jump to the bold text "All done!". If you read it and only some made sense, these next few paragraphs are for you.

First, the method uses path(forResource:) and String's contentsOfFile to find and load the level string from the disk. String interpolation is used to combine "level" with our current level number, making "level1.txt". The text is then split into an array by breaking on the \n character (that's line break, remember), then shuffled so that the game is a little different each time.

Our loop uses the enumerated() method to go through each item in the lines array. This is different to how we normally loop through an array, but enumerated() is helpful here because it tells us where each item was in the array so we can use that information in our clue string. In the code above, enumerated() will place the item into the line variable and its position into the index variable.

We already split the text up into lines based on finding \n, but now we split each line up based on finding :, because each line has a colon and a space separating its letter groups from its clue. We put the first part of the split line into answer and the second part into clue, for easier referencing later.

Now, here's something new: you've already seen how string interpolation can turn level\(level) into "level1" because the level variable is set to 1, but here we're adding to the clueString variable using \(index + 1). Yes, we're actually doing basic math in our string interpolation. This is needed because the array indexes start from 0, which looks strange to players, so we add 1 to make it count from 1 to 7.

Next comes our new string method call, replacingOccurrences(of:). We're asking it to replace all instances of | with an empty string, so HA|UNT|ED will become HAUNTED. We then use count to get the length of our string then use that in combination with string interpolation to add to our solutions string.

Finally, we make yet another call to components(separatedBy:) to turn the string "HA|UNT|ED" into an array of three elements, then add all three to our letterBits array.

All done!

Time for some more code: our current loadLevel() method ends with a comment saying // Now configure the buttons and labels, and we're going to fill that in with the final part of the method. This needs to set the cluesLabel and answersLabel text, shuffle up our buttons and letter groups, then assign letter groups to buttons.

Before I show you the actual code, there's a new string method to introduce, and it's another long one: trimmingCharacters() removes any letters you specify from the start and end of a string. It's most frequently used with the parameter .whitespacesAndNewlines, which trims spaces, tabs and line breaks, and we need exactly that here because our clue string and solutions string will both end up with an extra line break.

Put this code where the comment was:

cluesLabel.text = clueString.trimmingCharacters(in: .whitespacesAndNewlines)
answersLabel.text = solutionString.trimmingCharacters(in: .whitespacesAndNewlines)

letterBits = GKRandomSource.sharedRandom().arrayByShufflingObjects(in: letterBits) as! [String]

if letterBits.count == letterButtons.count {
    for i in 0 ..< letterBits.count {
        letterButtons[i].setTitle(letterBits[i], for: .normal)
    }
}

That code uses yet another type of loop, and this time it's a range: for i in 0 ..< letterButtons.count means "count from 0 up to but not including the number of buttons." This is useful because we have as many items in our letterBits array as our letterButtons array. Looping from 0 to 19 (inclusive) means we can use the i variable to set a button to a letter group.

The ..< operator is called the "half-open range operator" because it does not include the upper limit. Instead, it counts to one below. There's a closed range operator, ..., which includes the upper limit, but we don't want that here because an array of 20 items will have numbers 0 to 19.

Before you run your program, make sure you add a call to loadLevel() in your viewDidLoad() method. Once that's done, you should be able to see all the buttons and clues configured correctly. Now all that's left is to let the player, well, play.

Swift on the server is here

Get ahead of the game and learn server-side Swift with my latest book – build real-world projects while you learn!

< Previous: Buttons… buttons everywhere!   Next: It's play time: index(of:) and joined() >
Click here to visit the Hacking with Swift store >>