What you'll build
- A game where players try to hit targets with a bouncing ball.
What you'll learn
- What callbacks are and how they work.
- How to use a sophisticated API.
- How to use incremental development to build a complex app one piece at a time.
- How to refactor your code.
Key vocabulary
Introduction
So far, you've built simple apps with minimal code. In this lesson, you'll tackle the challenge of building a game that uses physics and touch interaction. It'll look something like this:
In this lesson, you'll be following a common software development strategy called incremental development. You'll build the BouncyBall app in stages, each one ending in a working app. You'll run and test your code at each step, gradually building its capabilities.
You might think you could look at the game in the video and build the app all at once. But think again. It will be much easier to build the game bit by bit, increasing its complexity in manageable steps. Along the way, you can evaluate your code and make adjustments when the need arises. And you can test after each step to make sure the game is behaving the way it should. Incremental development is a key technique that app developers use to manage their work.
Open the project called 'BouncyBall.xcodeproj' from the "BouncyBall" folder in your course resources and click the Run button in the Xcode toolbar to run it. You should see an empty white screen with gray bars at the top and bottom.
Part 1 - Putting Shapes on the Screen
You'll build the entire game from simple elements—flat shapes that can interact with user touches and a physics simulation. The code that implements the game engine is written in the Shape and ShapeScene files. (In turn, the game engine is written on top of the standard iOS SpriteKit API, which is much more complex and powerful.) As you progress through this lesson, you'll learn about the game engine's API.
To start, use the project navigator to open GameCode. This is the only file you'll need to modify as you create the game.
You'll notice that GameCode already has one function: setup(). The setup function is called once when the app launches—without it, your app won't compile. You'll use it now to set up your game.
Build and run the app to see how it looks before you add any content. You should see a white screen in the simulator with gray bars at the top and bottom where the rounded corners begin.
It's time to add your first shape. At the very top of the file, just below import Foundation, add this line:
let circle = OvalShape(width: 150, height: 150)
Then inside the setup() function, add these two lines.
circle.position = Point(x: 250, y: 400)
scene.add(circle)
What does this code do?
OvalShape is a type that describes an oval with a width and a height. You created a new instance of OvalShape named circle.
You then created an instance of Point and assigned it to the position property of the circle, which defines its location on the screen using x and y coordinates. The position of any shape in the scene is the location of its center.
The scene instance represents the white area on the screen where the game takes place. You used the add(_:) method to put the circle into the scene, which makes it appear on the screen.
Run the app again. Try dragging the circle around the screen.
Part 2 - Adding Physics
The core mechanic of the game involves a series of barriers and targets. The user adjusts the position of the barriers to guide the ball towards the targets. In this part, you'll add a barrier and play with the physics.
You already set up the circle, or ball. The hasPhysics property of a shape determines whether it participates in the physics simulation of the game engine. Since gravity is part of the simulation, you want the ball to accelerate downwards and disappear offscreen—as physics would dictate.
Add the following code inside the setup() function.
circle.hasPhysics = true
Note
If you see an error stating, "Expressions are not allowed at the top level," you accidentally added circle.hasPhysics = true outside the setup() function, not inside it. You're used to writing any kind of code wherever you want, but the rules for Xcode projects are different than those for playgrounds. In a project, executable code has to be inside a function—only declarations are exempt.
Run the app now to observe gravity in action.
It's time to add a second object: a barrier that the ball will bump into. This new shape will be a rectangle rather than a circle, so you'll need a different type, PolygonShape.
Add the following declarations to the top of the file, underneath your declaration of circle:
let barrierWidth = 300.0
let barrierHeight = 25.0
let barrierPoints = [
Point(x: 0, y: 0),
Point(x: 0, y: barrierHeight),
Point(x: barrierWidth, y: barrierHeight),
Point(x: barrierWidth, y: 0)
]
let barrier = PolygonShape(points: barrierPoints)
Then add the following code to the setup() function. (Leave a little vertical whitespace below the code for the ball by pressing Return once or twice.)
barrier.position = Point(x: 200, y: 150)
barrier.hasPhysics = true
scene.add(barrier)
Even though there's quite a bit more code to create the barrier, you're not doing anything complex. In your declarations at the top of the file, you defined the width and height of the barrier. Next, you created an array of Point instances that describe the barrier's four corners. Finally, you created a new PolygonShape instance, whose initializer requires you to supply an array of points that define its vertices.
The code inside setup() just copies what you did with the circle.
You may have noticed that the parameter in add(_:) is of type Shape. Both OvalShape and RectangleShape are kinds of Shape, so the add(_:) method will accept arguments of either type.
Build and run the app. You'll notice that your barrier will fall along with the ball, since you've given it physics properties. Both OvalShape and PolygonShape have an additional property named isImmobile. If isImmobile is true, the shape won't move when forces act on it in the simulation. Add the following line inside the setup() function.
barrier.isImmobile = true
Build and run the app again. This time, the barrier will stay fixed at its location on the screen, and the ball will come to rest on it. Although it's immobile in the physics simulation, the rectangle is still responsive to touch. Try dragging it around to see how it interacts with the ball.
Part 3 - Handling Taps
There are two user interactions in the game: dragging the barriers and dropping the ball. You don't have to do any work to enable barrier-dragging, because the game engine already handles that for you. In this part, you'll complete the work for handling the second interaction, enabling a user tap that drops the ball from a funnel.
Add a Funnel
First, you'll need to create a new shape to represent the funnel through which the ball will drop. You'll follow the same pattern you did with the ball and the barrier to add a funnel. First add the following declarations to the top of the file, underneath your declaration of barrier:
let funnelPoints = [
Point(x: 0, y: 50),
Point(x: 80, y: 50),
Point(x: 60, y: 0),
Point(x: 20, y: 0)
]
let funnel = PolygonShape(points: funnelPoints)
Then add the following lines to the bottom of the setup() function. Again, leave a bit of vertical whitespace below the barrier code.
funnel.position = Point(x: 200, y: scene.height - 25)
scene.add(funnel)
You used a scene property named height to position the funnel at the top of the screen. Try running your app again to see the funnel.
Callbacks
When the user taps the funnel, you want the ball to drop. You can write a function to drop the ball, but you'll never call that function directly, because it's determined by the user's tap. Remember the QuestionBot project? The responseTo(_:) function was called every time the user tapped the Ask button. QuestionBot already had a link from the button (the user interaction) to the corresponding code.
Linking code to user interactions is essential to app programming. These links are commonly known as callbacks. A callback is a bit of code, often a function, that runs when something happens that you don't control. (You can think of the term "callback" as describing the way that an app can "call back" to your code, rather than you calling the functions yourself.)
In this project, you'll learn one way to set up callbacks. First, you'll need to write the code that will run when the user taps the funnel. Below the setup() function, create a new function to reposition the ball:
func dropBall() {
circle.position = funnel.position
}
Next, you'll set up the callback. A shape has multiple ways of responding to user interactions. Each of these callbacks is just a property that tells it which function to call. Add the following code to the end of your setup() function:
funnel.onTapped = dropBall
You're used to seeing functions declared like func doSomething() { ... } and called like doSomething(). You think of them as actions that perform work when you call them. But you can also treat functions as data—and do things such as assigning them to variables.
Like any other property, the onTapped property of a shape has a type. But rather than an Int or a String, the type of onTapped is a function. You can assign any function to it as long as the function has no parameters and returns no results. The dropBall() function satisfies those conditions, so you can assign it as the callback function.
Note that you didn't write funnel.onTapped = dropBall(). That's because putting parentheses after the function's identifier will call the function. In this case, you want to assign the function as the callback that will run later, when the funnel is tapped—so you're just using the name of the function.
Build and run the app. When you tap the funnel, you should see your ball drop from it. Congratulations! You've written your first callback.
Part 4 - Tidying Up
Your shapes are a little boring. And the ball is way too big to fit in the funnel. It's time to add a little artistry to your game.
Shapes
You'll start by adding color to the shapes. In the setup() function, add the following line at the bottom of the code segment that handles the ball (just above the comment starting the segment for the barrier).
circle.fillColor = .blue
The fillColor property of a shape is of type Color, which you encountered in the Visualization Revisited playground. Feel free to customize the colors in your game using the predefined constants, or use one of the available initializers to make your own. While you're at it, go ahead and set the fill colors of the other items in the scene.
Your ball is too big for the funnel. To resize the ball so it fits in the funnel, alter the arguments you're passing to its initializer at the top of the file.
let circle = OvalShape(width: 40, height: 40)
Build and run to see a much nicer-looking game.
But take a look at your code—maybe it could use some tidying as well.
Refactoring—Round 1
Your setup() function should look something like this:
func setup() {
circle.position = Point(x: 250, y: 400)
scene.add(circle)
circle.hasPhysics = true
circle.fillColor = .blue
barrier.position = Point(x: 200, y: 150)
barrier.hasPhysics = true
scene.add(barrier)
barrier.isImmobile = true
barrier.fillColor = .brown
funnel.position = Point(x: 200, y: scene.height - 25)
scene.add(funnel)
funnel.onTapped = dropBall
funnel.fillColor = .gray
}
As you've added more and more code, your setup() function has become a little long. How you can make it easier to read? You really have three distinct code segments, each setting up one object. Extracting each of those code segments into its own function would definitely add a little more structure.
Refactoring is the practice of reorganizing your code without changing what it does. As you gain confidence as a programmer, you'll begin to get a sense for when your code should be refactored—by splitting one function into multiple, creating a new function from existing code segments, abstracting separate data into a new custom type, or other techniques. Above all, your code should be readable, easy to debug, and well organized. When you get a feeling that you're straying away from one or more of these goals, you'll know it's time to step back and evaluate your options.
To create a new function from existing code, you could manually declare an empty function, cut the code from setup(), and paste it into the new function. But Xcode can automate this process for you. Try it now with the code that sets up the ball.
First, select the four lines of code at the top of the setup() function that deal with the ball (it doesn't matter if they're in a different order in your code):
circle.position = Point(x: 250, y: 400)
circle.hasPhysics = true
circle.fillColor = .blue
scene.add(circle)
Then choose Editor > Refactor > Extract to Method (or right-click the selected code and choose Refactor > Extract to Method).
The code you selected will be moved into a new function named extractedFunc(), and be replaced by a call to it. (Xcode started the declaration with the fileprivate keyword, which indicates that you can't see this function from any other source file.) You'll see that both the declaration and call are highlighted. Type the name for your new function, setupBall, and watch both places update at the same time. Press Return when you're done.
Now do the same thing for the code in setup that sets up the barrier and the funnel, making new setupBarrier and setupFunnel functions.
Note that your code still does the same thing after all this work—you've just added a procedural abstraction that makes it easier to read and think about.
The name of the constant, "circle," doesn't properly describe the object it represents in the game. To change its name to "ball," you could manually change each occurrence of the identifier. But that would be time-consuming and could result in an error. Alternatively, you could use the find-and-replace function of Xcode (Find > Find and Replace... or Command-Option-F).
But Xcode can automate this process for you as well. Select the circle identifier where it's declared, then choose "Edit All in Scope" from the Editor menu. All the places where "circle" is used in your code are highlighted. Type ball to replace the existing text, and you'll see it change everywhere else simultaneously.
The primary advantage of this method over find-and-replace is that it understands your code structure. It can select just those references in the selected scope (the portion of your code that can "see" the variable—in this case, the entire file). If you had two identically named variables in separate scopes or another identifier that included the text "circle" (like circle2 or circleShape), you could rename one without affecting the others.
Renaming your variable also didn't change how your app worked, but it made your code more consistent to read.
Part 5 - Adding a Target
The next missing element from the game is the set of targets that players will try to hit with the ball. In this part, you'll start by creating one target and you'll finish with multiple arbitrarily placed targets that change color when they're hit.
Add a Target Shape
A small diamond shape should distinguish the target from other objects in the scene. To keep the code in setup() clean, you'll put this code in a new function.
First add declarations for the polygon points and the target itself to the top of the file along with the rest of your shape declarations.
let targetPoints = [
Point(x: 10, y: 0),
Point(x: 0, y: 10),
Point(x: 10, y: 20),
Point(x: 20, y: 10)
]
let target = PolygonShape(points: targetPoints)
Then add a function to set it up and add it to the scene.
func setupTarget() {
target.position = Point(x: 200, y: 400)
target.hasPhysics = true
target.isImmobile = true
target.isImpermeable = false
target.fillColor = .yellow
scene.add(target)
}
Finally, call the function from the setup() method.
func setup() {
...
setupTarget()
}
Build and run your project to see the target on the screen.
Respond to Collisions
For the game to function properly, you need to detect when the ball touches the target. The API of the game engine handles collisions with callbacks similar to the one you already used to handle touches. Before you write the code, though, it's helpful to know a bit more about how callbacks work.
Function Types
In the setupFunnel() function, find the line of code that sets the onTapped callback for your funnel and Option-click "onTapped." The documentation popup should show you information about the property, including the declaration, which looks like var onTapped: () -> () { get set }—a little different from the property declarations you're used to seeing in other types.
Here's a breakdown of the declaration.
var onTapped – Declares the property's name. (This should be familiar.)
: () -> () – Declares the property's type. This expression is different from the types of properties—like : Int or : String—that you've seen before.
{ get set } – Indicates that you can both inspect the value of this property and assign new values to it.
You might be able to guess what the () -> () type means. The clue is the -> arrow, which you've seen when declaring functions that return a value. Since you assigned a function to this property, you can assume that () -> () is a function type.
Just as all kinds of data have a type, so do functions. The type of a function is defined by the kinds of parameters it has and the kind of value it returns. () -> () defines a function type that has no parameters and returns nothing. The first pair of empty parentheses represents the parameters, and the second pair represents the return value.
The onTapped property's type is just a function type. You can set its value to any function, as long as that function has no parameters and doesn't return a value.
A New Callback
The property you need for collisions is named onCollision. Add the following line of code to the setupBall() method. (You'll get an "Expression resolves to an unused property" error, but don't worry—you haven't finished writing the code yet.)
ball.onCollision
Option-click "onCollision." The documentation tells you that the type of that property is (Shape) -> (). Now that you can read function types, you'll recognize that the onCollision property requires a function that has one Shape parameter and doesn't return a value. The shape that's passed to the callback function is the other shape involved in the collision. (Recall that Shape is a general way to refer to both OvalShape and PolygonShape. The scene doesn't distinguish between these two types, so its methods and callbacks use the general Shape type.)
You'll need a new function to handle collisions between the ball and the targets. Create it now.
func ballCollided(with otherShape: Shape) {
otherShape.fillColor = .green
}
Then go back and finish setting up your callback in setupBall(). You'll get an autocompletion suggestion as you start typing the name of your new function.
ball.onCollision = ballCollided(with:)
Note that the full name of this function is ballCollided(with:), as suggested by Xcode. You could also use ballCollided, but the best practice is to use the function's full identifier, which includes argument labels.
Build and run your code. You'll see that when the ball touches the target, it turns green. Of course, the barrier also turns green. You'll use another property of shapes to fix this problem.
Add the following code to the setupTarget() function.
target.name = "target"
The name property is a way to identify shapes. You can choose any string you want, and multiple shapes can have the same name. By default, a shape's name is the empty string "".
Now that you can tell which shape is the target, you can modify the ballCollided(with:) function.
func ballCollided(with otherShape: Shape) {
if otherShape.name != "target" { return }
otherShape.fillColor = .green
}
It might look a little odd to have the return statement on the same line as the if statement. That's an accepted style if the if clause contains only a return statement.
Build and run your code to verify that your target reacts appropriately, while the barrier doesn’t.
Part 6 - Refining Game Mechanics
Stop the Runaway Ball
There's an issue with the ball that you'll see if you move the barrier out of the way so that the ball can fall from the funnel directly off the screen. Every time you tap the funnel, the ball exits with greater and greater speed. If you think about it for a second, you'll realize that the ball doesn't stop moving after it exits the scene at the bottom, and it retains that momentum when you reposition it in the funnel.
Add this line to dropBall() to fix that.
ball.stopAllMotion()
Prevent Dragging
To make the game challenging, you'll allow the user to move only the barriers—the other items will be excluded from drag interactions. And the barriers will only be movable when the ball isn't in play; otherwise the user could guide the ball with a barrier to hit the targets.
The isDraggable property of shapes controls whether or not the user can drag them. Inside setupBall(), add the following line:
ball.isDraggable = false
Now do the same for the funnel and target inside setupFunnel() and setupTarget().
Build and run the app to verify that you've successfully enforced these game rules.
Lock the Barrier
The other three objects should never be draggable. But since the barrier needs to be draggable between drops of the ball, the code to handle this rule is a bit more complicated. You have a function that drops the ball, so it's reasonable to lock the barrier there. But how will you know when to unlock it again?
Right now, the ball falls forever—even when it leaves the bottom of the screen. The scene doesn't have any physical boundaries, and neither does the physics simulation. The right time to reset the process and reenable barrier interactions is after the ball has disappeared.
First, create a function to act as the callback for when the ball exits the scene.
func ballExitedScene() {
}
Next, add the following code to the setupBall() function to let the scene know that it should keep track of the ball's location, and set the callback on the ball.
scene.trackShape(ball)
ball.onExitedScene = ballExitedScene
Now you can lock and unlock the barrier. Add the following code to dropBall().
barrier.isDraggable = false
And add this code to ballExitedScene().
barrier.isDraggable = true
Build and run your app. Initially the barrier should still be movable. After the ball comes to rest on it, move it out of the way so that the ball falls straight off the screen when it's dropped from the funnel. Tap the funnel and watch the ball drop out of sight. When you tap the funnel again, the ball will drop again. But if you try to move the barrier while the ball is in play, it should remain in place. Once the ball is out of sight, you should be able to move the barrier again.
Reset the Game
There's a problem with the game, though. To see it, move the barrier back in the ball's path and drop the ball. When the ball comes to rest on the barrier, you won't be able to do anything but drop the ball again. That would make for a poor gameplay experience—the user could get stuck in an unwinnable state, unable to change the position of the barrier. One way to fix this issue is to reset the game using a tap callback on the ball.
Add a new function to reset the game.
func resetGame() {
ball.position = Point(x: 0, y: -80)
}
Then set it as the ball's tap callback in setupBall().
ball.onTapped = resetGame
It would also be better if the game started without the ball on the screen. Add a call to resetGame() at the end of your setup() function.
resetGame()
Now when you build and run your app, you shouldn't ever get stuck in an unmovable (and unwinnable) state.
Part 7 - Challenging the User
It's time to set up the game so you can challenge the user. A couple small tweaks will make the game challenging and interesting.
Tilt the Barrier
If the barrier is flat, the game won't be challenging—the ball just bounces up and down in place. You can use the angle property of shapes to tilt the barrier.
Modify the setupBarrier() function by adding the following line.
barrier.angle = 0.1
Make a Bouncier Ball
The game will be more interesting if the ball bounces off the barrier, rather than landing and rolling. Use the bounciness property to add some bounce to the ball. (The value of this property can range from 0 to 1.)
Add the following line to setupBall().
ball.bounciness = 0.6
Set Up a Challenge
Since the game is physics-based, it would take a lot of trial-and-error work to build a good challenge. Instead, you'll build one that's already solved and use it to reverse-engineer the code.
Adjust the the bounciness of the ball and the position, size, and angle of the barrier until you have a bouncing mechanic you like.
To reverse-engineer the challenge, you'll drag target around so that it lies in the path of the ball. To figure out the correct coordinates, you can add a helper function to print (or log) a shape's position to the console.
func printPosition(of shape: Shape) {
print(shape.position)
}
Use the onShapeMoved callback of the scene to print a new position every time a shape is moved. Add the following line to the setup() function.
scene.onShapeMoved = printPosition(of:)
Then make sure that you can manually position your target by commenting out the appropriate line in setupTarget().
Now watch your ball as it drops and bounces. Pick a point along its path and drag your target there. Try dropping the ball again to make sure that it touches the target. When you're satisfied with its position, note the values printed to the console, and then update setupTarget() to set the target's position. (You don't have to worry about all the digits past the decimal.) Finally, comment back in the line of code in setupTarget() to disable dragging.
Congratulations! You've created a game with some fairly complex behaviors, enabled by a physics engine and quite a few callbacks. This is a good place to take a step back and look at what you've accomplished.
When you're ready, you can move on to the next step—creating a more compelling challenge with multiple barriers and targets.
Part 8 - Creating a Complex Challenge
For more interesting gameplay, you should include multiple barriers and targets. A possible solution is to add variables such as barrier2, barrier3, target2, and target3, and manage them in parallel, but that's not a very practical or flexible approach.
A better solution is to generalize your code by maintaining an array of barriers and an array of targets, making your code more flexible to enable further development—another good example of code refactoring. The goal with this step is to restructure your code without changing the functionality of the game.
Refactoring—Round 2
Enable Multiple Barriers
First declare a new variable at the top of the file to store the array.
var barriers: [Shape] = []
You already have a function that adds a barrier to the scene; it's just not general enough. If you make some adjustments, it will play nicely with your new array and will give you a lot of flexibility to put barriers wherever you want.
First, rename the function from setupBarrier to addBarrier. Remember, the easiest way to do this is with the Editor > Edit All in Scope command.
Next, add parameters to the function so that you can specify the width, height, position, and angle. At the top, above the function's existing code, add code to create a new barrier and append it to the array.
fileprivate func addBarrier(at position: Point, width: Double, height: Double, angle: Double) {
let barrierPoints = [
Point(x: 0, y: 0),
Point(x: 0, y: height),
Point(x: width, y: height),
Point(x: width, y: 0)
]
let barrier = PolygonShape(points: barrierPoints)
barriers.append(barrier)
You'll also need to find the lines later in the function that set its position and angle and modify them to use the new function parameters.
...
barrier.position = position
...
barrier.angle = angle
...
Now you can delete the declarations for barrierWidth, barrierHeight, barrierPoints, and barrier from the very top of the file, since you don't need them anymore.
You'll see a few compiler errors crop up after you've made all these changes. Tackle them in order from top to bottom.
The first error, in setup(), resulted from changing the name of setupBarrier() and adding parameters. You can call the new function and pass in arguments to set up an identical barrier. (Of course, your arguments will probably be different from the ones in the code below, since you set up your own custom challenge.)
addBarrier(at: Point(x: 200, y: 150), width: 80, height: 25, angle: 0.1)
The next two errors are caused by your deletion of the barrier constant. In both the affected functions, you'll need to go through the array of all barriers.
func dropBall() {
ball.position = funnel.position
ball.stopAllMotion()
for barrier in barriers {
barrier.isDraggable = false
}
}
func ballExitedScene() {
for barrier in barriers {
barrier.isDraggable = true
}
}
Build and run your code to confirm that the game looks and behaves exactly as it did before.
Enable Multiple Targets
Creating multiple targets is an analogous process. Add a variable at the top of the file for your array of targets.
var targets: [Shape] = []
Then change setupTarget() to act more like addBarrier(), changing its name to addTarget and adding a parameter for position. At the top, above the function's existing code, add code to create a new target and append it to the array.
func addTarget(at position: Point) {
let targetPoints = [
Point(x: 10, y: 0),
Point(x: 0, y: 10),
Point(x: 10, y: 20),
Point(x: 20, y: 10)
]
let target = PolygonShape(points: targetPoints)
targets.append(target)
Find the line later in the function that sets the target's position and update it.
target.position = position
Delete the declarations for targetPoints and target from the very top of the file, since you don't need them anymore.
To wrap up, change your code in setup() to call addTarget() and pass in its position.
addTarget(at: Point(x: 150, y: 400))
Build and run your code to make sure your refactoring didn't change anything.
Construct the Challenge
You're finally ready to create a challenge with multiple barriers and targets. To start, add a few more targets and barriers in your setup function by calling addTarget and addBarrier several times with different arguments.
Comment out the target.isDraggable = false line in addTarget() so that you move the targets around again.
Build and run your app. Then build your solved challenge as follows:
- Position the barriers so that the ball bounces in a fun way from one to the other before falling off the screen. Note the final positions that are printed to the console.
- Watch the path of the ball, and position the targets along the way so that it touches each one. Note their final positions as well.
- Use the final positions of the targets and barriers to modify the code in
setup().
Don't forget to comment the target.isDraggable = false statement back in when you're done. Have fun!
If you get stuck, here's an example of barrier and target positions for a moderate challenge on iPhone 14 Pro.
addBarrier(at: Point(x: 175, y: 100), width: 80, height: 25, angle: 0.1)
addBarrier(at: Point(x: 100, y: 150), width: 30, height: 15, angle: -0.2)
addBarrier(at: Point(x: 325, y: 150), width: 100, height: 25, angle: 0.3)
addTarget(at: Point(x: 184, y: 563))
addTarget(at: Point(x: 238, y: 624))
addTarget(at: Point(x: 269, y: 453))
addTarget(at: Point(x: 213, y: 348))
addTarget(at: Point(x: 113, y: 267))
Part 9 - Keeping Score
The game functions well as it is, but the only feedback to the user is when the targets turn green. It would be nice if the app would detect when the user has hit all the targets and communicate the good news to the user. That's pretty easy to do, using a bit of extra code in the ballExitedScene() function.
var hitTargets = 0
for target in targets {
if target.fillColor == .green {
hitTargets += 1
}
}
if hitTargets == targets.count {
print("Won game!")
}
Every time the ball drops, you'll reset all the targets so the user has to hit them all on one try. Do that in the dropBall() function.
for target in targets {
target.fillColor = .yellow
}
Build and run your code. When you solve your level, you'll see "Won game!" print to the console. And every time you drop the ball, the targets reset to yellow.
You've technically accomplished your goal, but the user won't ever see the console. You should display a message so they'll get a little jolt of satisfaction when they win. There's a method in ShapeScene for that named presentAlert. Modify your code in ballExitedScene() by replacing the print statement with the following line.
scene.presentAlert(text: "You won!", completion: alertDismissed)
You'll get an "unresolved identifier" error, which you can correct by declaring an empty alertDismissed function.
func alertDismissed() {
}
You've just completed another example of a callback. This time, rather than setting the value of a property, you passed a function into another function as an argument. That feels (and is) a little meta—the presentAlert(text:completion:) method calls another function once the alert has been dismissed. For example, you could use that callback to continue to the next level. In this case, you don't need to do anything after the dialog is dismissed, which is why you created an empty function.
If you Option-click the method name, you'll see something familiar: The type of the completion parameter is () -> () (ignoring the @escaping modifier, which is outside the scope of this course). This style of callback—passing a completion function as an argument to another function—is also very common in app development.
Looking Back
Take a few minutes to review your progress through this lesson. You've written well over a hundred lines of code and built a pretty sophisticated game. You did it in small, manageable steps, so that the process was never overwhelming. You refactored your code at a couple key points when it was starting to limit you or to cause readability issues.
Apps always start from a single line of code, and you'll never build an app in one go. Instead, you'll build a key piece in small increments, testing it as you go, and then add more pieces in a modular fashion until you end up with the product you envisioned.
Reflection Questions
How is writing callback-driven code different from writing purely procedural code whose flow is dictated by sequencing, selection, and iteration?
What are some other projects you could envision that use this touch- and physics-based API?
How did the incremental development process help you to manage your project? How would it have been to try to code the entire app and only test it at the end?
Examine some of your favorite apps. For each one, imagine how the developer could have created it in stages. What might it have looked like at various points in its incremental development?
Are there ways you follow an incremental development process in other areas of your life?
Summary
You used the incremental software development process to make this app. Rather than trying to build it all at the same time, you built something small that worked, and kept adding to it. Incremental development is one of the most important techniques for software development because it helps programmers keep tasks small and manageable, giving them many opportunities to test their code and verify they're on the right path.
You've had a chance to experience building a game that uses touch interactions. It was a different experience than any of your programming so far. In the next unit, you'll shift focus away from learning Swift language concepts towards learning how to use Xcode and Interface Builder. Congratulations—you're ready to take what you know and become an iOS developer!