Build a Simple Game for iOS Devices Using Swift - Part 3

Build a Simple Game for iOS Devices Using Swift - Part 3

Use CALayers and CABasicAnimation to build firing animations and finalize the interface using UIView animations

Go to the profile of  Jonathan Banks
Jonathan Banks
14 min read
Time
1.5hours
Platforms
iOS
Difficulty
Medium
Technologies
Swift

Recapping the progress made working through Part 2:

  • Created embedded ViewController classes using Container Views
  • Finalized the connections between Interface Builder components and the custom classes
  • Used a custom Protocol and a closure to enable inter-UIViewController communication
  • Built an overlay and coded the animation to display a countdown to the players.
  • Fleshed out the WinScreenGameController to store player scores and pass data forward and back between WinScreenViewController and MainGameScreenViewController

This is the first of the four-part series with a working game as output. Refer to the other sections of the tutorial:

  • Part 1: Turn fundamental knowledge of CABasicAnimation, CALayers, Delegates, embedded UIViewControllers and Auto Layout into a simple and fun game
  • Part 2: Connect the Interface Builder design from Part 1 of the series to the engine and logic drivers.
  • Part 4: Add animations to the win screen and connect the screens using an unwind segue.

Remember to download the entire project with source code for this app from this repo on bitbucket.org

Overview

The code left to complete revolves around the logic and animations of the firing mechanism. Once those firing animations are complete, the last bits of code will be devoted to improving the look of the game. There are also some mop-up operations and other housekeeping chores to complete.

This tutorial handles the entire section of firing code. Part 4 of the series deals with that housekeeping.

Overview of Firing Animations and Tracking Logic

First, take a minute to review the architecture of the application. Based on the design from Part 1 and the code written thus far, think about how the high-level design of the firing mechanism might work. Refer to one possible design below:

  • UpperViewController and LowerViewController call the BoomDelegate method, boom(sender:Any?), each time the respective player hits the fire button.
  • The Delegate for both embedded UIViewControllers is set to MainGameScreenViewController. Shots are handled by the implementation of the protocol in MainGameScreenViewController
  • Draw the player's shot as a line. The line starts from the button itself and travels across the game board to hit the top of the other player's base
  • The "laser" should be a different color for each player.
  • Randomly change where the shot hits the opponent's base each time it's fired
  • Change the size of the opponents base when a shot hits
  • The opponent wins the game if the size of the other player's base becomes 0

Walkthrough coding each of these design choices in the subsequent sections.

Firing Code

Open the MainGameScreenViewController file and replace the print statement inside the boom(sender:) method with the code below:

extension MainGameScreenViewController:BoomDelegate{
    
    func boom(sender: Any?) {
        shoot(sender: sender)
        pewPew(sender: sender)
        
    }
}

The shoot(sender:) and pewPew(sender:) methods are implemented in MainGameScreenViewController. The shoot(sender:) method handles the animations and logic of the shot and the pewPew(sender:) method plays the sound.

Shoot Method

The shoot(sender:) method handles the animations for both player's shots. The logic of this method is centralized and decides the player that hit the fire button. It then changes the color, destination coordinates, and animation of that shot for each player dynamically.

The logic for the shooting mechanism is as generic as possible to reduce the code down to a single method that is more manageable and maintainable.

Shoot Method Breakdown

Add the following class variables to MainGameScreenViewController:

let HIT_STRENGTH:Int = 5
var lowerShootCount = 0
var upperShootCount = 0

Breaking down each variable:

  • HITSTRENGTH: the "power" of each shot. In other words, the amount the opponent's base is reduced in size per shot.
  • lowerShootCount/upperShootCount: count the times each player has tapped their fire button. Each shot takes time to reach the opponent's base and they must be tracked to calculate where the next shot will need to go.

Next, create an empty private function for shoot(sender: Any?):

 private func shoot(sender: Any?){
 
 }

Create the following local variables that hold computational information for rendering the shot:

let pathLayer = CAShapeLayer()
let path = UIBezierPath()


var center:CGPoint?
var lineColor:CGColor?
var heightConstraint:NSLayoutConstraint!
      

Refer to the breakdown of each variable:

  • pathLayer: Draw the shot on this layer
  • path: pathLayer coordinates of the line
  • center: center of the fire button inside the player's base
  • lineColor: color of the shot that will be rendered. Blue or red depending on the incoming sender
  • heightConstraint: assigned to either the upperContainerHeightConstraint or lowerContainerHeightConstraint depending on the sender (player that fired the weapon)

The next task is to add the code that determines the player that fired. The code should cast the sender as either an UpperViewController or LowerViewController to determine which player is sending the request to shoot(sender:). Then, based on the sender, set the generic variables to the appropriate values for the shooter.

Paste the following code into shoot(sender:):

// Cast the sender to determine if method call came from top or bottom VC.
if let uvc = sender as? UpperViewController{

    //Convert the center point of the fire button inside the Child VC
    //into the coordinates of the Parent VC
    center = uvc.view?.convert(uvc.upperFire.center, to: self.view)
    lineColor = UIColor.red.cgColor

    // Increment the shoot count of the top VC.
    // Used to calculate the predicted Y value of the opponent's base,
    // based on the number of shots inbound..
    upperShootCount += 1

    // Set the generic heightConstraint to the opponent's VC height
    heightConstraint = lowerContainerHeightConstraint

    // Cast the sender to determine if method call came from top or bottom VC.
}else if let lvc = sender as? LowerViewController{

    // See comments above, flipped for opposite VC
    center = lvc.view?.convert(lvc.lowerFire.center, to: self.view)
    lineColor = UIColor.blue.cgColor

    lowerShootCount += 1

    heightConstraint = upperContainerHeightConstraint

}

Breaking down that block of code:

  • The UpperViewController or LowerViewController is always the sender. Cast each and set the following variables on success:
    • center: holds the origin of the shot. Use the convert(from:to:) method to translate the center of the fire button of the incoming UIViewController into the coordinates of the MainGameScreenViewController view (the gameboard)
    • lineColor: the color of the shot. Blue or orange depending on sender.
    • lowerShootCount/upperShootCount: increment this count to track the total number of shots taken by each side. The height of the opponent's base is calculated based on the number of incoming shots. It is decremented on shot impact.
    • heightConstraint: set to upperContainerHeightConstraint or lowerContainerHeightConstraint. If the sender is UpperViewController set this to the lowerContainerHeightConstraint. If the sender is LowerViewController, set it to upperContainerHeightConstraint.

CATransaction

Completing the drawing and animation code is next on the agenda. These are the first examples of CATransaction and CABasicAnimation in the tutorial. Before commencing, a high-level overview of both will be helpful.

CATransaction provides the tools needed to support the granular coordination of multi-step animations. It allows interleaving of standard view property animations and layer property animations. Single CATransaction blocks are used to fine-tune exact timings to smooth and sync them.

According to Apple's documentation:

CATransaction is the Core Animation mechanism for batching multiple layer-tree operations into atomic updates to the render tree. Every modification to a layer tree must be part of a transaction. Nested transactions are supported. Explicit transactions occur when the the application sends the CATransaction class a begin() message before modifying the layer tree, and a commit() message afterwards.

CATransaction allows you to override default animation properties that are set for animatable properties. You can customize duration, timing function, whether changes to properties trigger animations, and provide a handler that informs you when all animations from the transaction group are completed.

Be aware of the setAnimationTimingFunction(CAMediaTimingFunction?) method of CATransaction. It uses a CAMediaTimingFunction as input to define the acceleration and/or deceleration of the animation as a Bezier timing curve.

The CAMediaTimingFunctionName options, such as .linear, .easeIn, .easeOut, and .eastInEaseOut, are pre-determined CAMediaTimingFunctions. Customize the acceleration or deceleration of the animation by initializing a CAMediaTimingFunction and adjusting the control points.

Refer below for an example of a custom CAMediaTimingFunction:

let myTimingFunction = CAMediaTimingFunction(controlPoints: 0.17, 0.67, 1.0, 0.6)

CATransaction.begin()
CATransaction.setAnimationTimingFunction(myTimingFunction)

UIView.animate(withDuration: 2.0, animations: {
    myView.alpha = 1.0
})

CATransaction.commit()

The control points that are defined when initializing the CAMediaTimingFunction are the c1 and c2 control points, as shown here: [(0.0,0.0), (c1x,c1y), (c2x,c2y), (1.0,1.0)]. The beginning and end of the curve are not adjustable. Those endpoints are automatically set to (0.0,0.0) and (1.0,1.0).

Check out this website to play with different values of the Bezier curve and see the outcome inside an actual animation.

CABasicAnimation

High-level animations using UIView.animate() can only be used on view property animations not layer property animations. Those view properties include:

  • alpha
  • background color
  • bounds
  • frame
  • center
  • transforms: rotation, scale

Calls to change view properties are routed to the root backing layer automatically since UIView is a wrapper over CALayer. That root CALayer can have sublayers added to it and the layer properties are modifiable. Layer properties include:

  • borderWidth
  • cornerRadius
  • borderWidth
  • shadow: offset/radius
  • opacity
  • image
  • transform
  • zPosition

Find a complete list of animatable properties at developer.apple.com

There are many types that extend CALayer and add additional functionality as well. Refer to the short list below:

  • CAShapeLayer
  • CAScrollLayer
  • CATextLayer
  • CAEmitterLayer
  • CAGradientLayer
  • CAReplicatorLayer

Returning to CABasicAnimation, when creating a CABasicAnimation, give it a keyPath string naming the animatible property, a fromvalue, and a toValue.

A semi-complete list of keyPaths include the following:

  • opacity
  • backgroundColor
  • position
  • cornerRadius
  • transform.scale (transform.scale.x, transform.scale.y, transform.scale.z)
  • transform.rotation (transform.rotation.x, transform.rotation.y, transform.rotation.z)
  • translation (translation.x, translation.y, translation.z)

The fromValue and toValue parameters define the values being interpolated. That type can vary depending on the keyPath chosen. For example:

  • backgroundColor: NSColor
  • position: [0,0] (as a CGPoint)
  • transform.scale.x: Int

With the overview complete, here's an example of creating a CABasicAnimation that will move the CALayer position:

let myAnimation = CABasicAnimation(keyPath: "position")
myAnimation.fromValue = [50, 100]
myAnimation.toValue = [100, 150]
myAnimation.duration = 2.0
myAnimation.timingFunction = CAMediaTimingFunction(name:CAMediaTimingFunctionName.easeIn)

Notice that the animation duration and timingFunction are set in that code. Mixing CATransaction and CABasicAnimation timing functions and durations is permitted to allow the fine-grained control discussed earlier.

Returning to the Shoot Method

With the CATransaction and CABasicAnimation overview complete, the next step is animating the player shots. Add the following code to the shoot(sender:) method:

CATransaction.begin()

// position initial point of shot to render
path.move(to: CGPoint(x: center!.x, y: center!.y))

// addLine to the calculated CGPoint on opponent's base, returned from calculateHitPoint
path.addLine(to: calculateHitPoint(sender: sender))


//Add the shot CALayer to the superview's layer
pathLayer.frame = view.bounds
pathLayer.path = path.cgPath
pathLayer.strokeColor = lineColor!
pathLayer.fillColor = nil
pathLayer.lineWidth = 2
pathLayer.lineJoin = CAShapeLayerLineJoin.bevel
view.layer.addSublayer(pathLayer)

// Set animation parameters
let pathAnimation = CABasicAnimation(keyPath: "strokeEnd")
pathAnimation.duration = 2.0
pathAnimation.fromValue = 0
pathAnimation.toValue = 1
pathLayer.add(pathAnimation, forKey: "strokeEnd")

// DO IT!
CATransaction.commit()

Here's the breakdown of that code:

  • Position the local UIBezierPath path to the coordinates calculated using the UIView.convert(from:to:) method. The starting point is the center of the fire button.
  • Call the addLine(to:) method to draw a line to the calculated endpoint on the opponent's base. Define the method calculateHitPoint(sender:) to handle that calculation.
  • Add bounds to the CAShapeLayer pathLayer. Set the color of the line based on the sender and give the pathLayer a line width of 2
  • CAShapeLayer has a path attached so add it to the superview.
  • Create the CABasicAnimation with a keyPath of strokeEnd. The strokeEnd variable defines the relative location to stop stroking the path. Set the toValue to 1 so the shot stops at the point of impact.
  • Add the CABasicAnimation to the CAShapeLayer. Commit the entire animation to CATransaction.

Next, implement the calculateHitPoint(sender:) method that calculates the hit point on the opponent's base. Add the following method to MainGameScreenViewController:

private func calculateHitPoint(sender: Any?) -> CGPoint{
        
    var hitPoint:CGPoint = CGPoint.init()

    if let _ = sender as? UpperViewController{

        let lowerRect = lowerVC.view.superview?.convert(lowerVC.view.frame, to: nil)
        hitPoint.y = lowerRect!.minY + (HIT_STRENGTH * CGFloat(upperShootCount))
        hitPoint.x = CGFloat(arc4random_uniform(UInt32(lowerRect!.maxX - lowerRect!.minX))) + lowerRect!.minX
        

    }else if let _ = sender as? LowerViewController{

        let upperRect = upperVC.view.superview?.convert(upperVC.view.frame, to: nil)
        hitPoint.y = upperRect!.maxY - (HIT_STRENGTH * CGFloat(lowerShootCount))
        hitPoint.x = CGFloat(arc4random_uniform(UInt32(upperRect!.maxX - upperRect!.minX))) + upperRect!.minX

    }

    return hitPoint
}

Apply the same sender that triggered the shoot(sender:) method to calculateHitPoint(sender:). Then use the opposite base to the shooter to calculate the hit point.

The UIView coordinate system starts at the top left of the screen at 0,0. Thus, the minY variable is the targeted top Y value for the LowerViewController. The maxY variable is the targeted top for UpperViewController

The logic of this method works as follows:

  • Cast the sender to decide which base to calculate the hit point. Use the frame of the opposite base to the sender UIViewController to calculate the hit.
  • Using the reference to either LowerViewController or UpperViewController, apply the convert(from:to:) method with a nil to parameter. This gets the frame of the opposite base's UIViewController as a CGRect translated to the MainGameScreenViewController coordinate system.
  • Calculating the Y Value of the Hit Point
    • If the sender is the UpperViewController, start with the minY of the CGRect for the frame of the LowerViewController.
    • If the sender is the LowerViewController, begin with the maxY of the CGRect for the frame of the UpperViewController.
    • Calculate where this shot will hit on the y-axis based on how many shots are still inbound from the sender. This is where the global variables lowerShootCount or upperShootCount comes into play. Increment the appropriate count variable for each shot. Decrement the value when the shot hits.
    • Calculate the impending base size reduction for those inbound shots that have not yet hit. Multiply the latest shoot count by the HIT_STRENGTH. This results in the reduction in height on the y-axis based on the inbound shots.
    • Add or subtract from the minY or maxY depending on which base is the sender or the target. If a shot hits the bottom base, its height increases to a higher Y value. If a shot hits the top base, its height shrinks to a lower Y value.
      • If the sender is the UpperViewController, add the amount calculated by (HIT_STRENGTH * CGFloat(upperShootCount) to minY.
      • If the sender is the LowerViewController, subtract the amount calculated by (HIT_STRENGTH * CGFloat(lowerShootCount) to maxY
  • Calculating the X Value of the Hit Point
    • The final calculation is the X coordinate. Generate a random number between the boundaries of the player's base. This will result in random shots across the board at the opponent's base.

Run the game. There are a few problems exposed now, refer to the screenshot:
Untitled-1

  • Problem 1: Every shot adds a new CALayer and does not remove the previous ones that already hit. Remove the CALayer used to animate the shot at the moment in time that shot impacts the opponent's base.
  • Problem 2: The size of the opponent's base is not shrinking when each shot lands. Modify the heightConstraint to the appropriate base earlier.

What is the best time to resolve these issues?

All CATransactions have completion blocks. It is a closure of ()->() type (void) executed when the animation duration is finished at the moment the shot lands on the opponent's base.

Add the following code under the CATransaction.begin() method call in shoot(sender:):

// Completion block used to remove the animated shot CALayer from the view's layer
// and determine if a player has won the game based on this shot.
CATransaction.setCompletionBlock({

    pathLayer.removeFromSuperlayer()

    // Prevent constraint from going negative and throwing output errors
    if heightConstraint.constant - self.HIT_STRENGTH >= 0{

        heightConstraint.constant = heightConstraint.constant - self.HIT_STRENGTH

        if sender is UpperViewController{

            // Decrement the pending shoot count since that shot is complete -
            // necessary for calculating the correct CGPoint on opponents Base
            self.upperShootCount -= 1

            // If the heightConstraint of opponent's base is 0, show win screen
            if heightConstraint.constant == 0{
                self.performSegue(withIdentifier: "winScreenSegue", sender: self.upperVC)
            }

        }else{

            // Decrement the pending shoot count since that shot is complete -
            // necessary for calculating the correct CGPoint on opponents Base
            self.lowerShootCount -= 1

            // If the heightConstraint of opponent's base is 0, show win screen
            if heightConstraint.constant == 0{
                self.performSegue(withIdentifier: "winScreenSegue", sender: self.lowerVC)
            }
        }

    }

})

Breaking down this code:

  • The local shoot(sender:) method variable pathLayer is captured in the completion block closure. Immediately call the removeFromSuperlayer() method to remove the shot from the underlying MainGameScreenViewController view CALayer.
  • The check heightConstraint.constant - self.HIT_STRENGTH >= 0 is performed for two purposes:
    1. Prevent the heightConstraint.constant from going negative which will throw Auto Layout errors
    2. Prevent shots that will hit after an opponent's base is reduced to heightConstrant.constant of 0 from invoking another call to performSegue(withIdentifier:sender). Excessive WinScreenViewControllers will be pushed onto the stack if this check is not performed.
  • Check the sender type and decrement the appropriate shoot count, either upperShootCount or lowerShootCount
  • Check if the heightConstraint.constant is decremented to 0. If so, call performSegue(withIdentifier:sender:) with the segue identifier "winScreenSegue" and sender as either the upperVC or lowerVC. The WinScreenViewController will pick up the winner and display it along with the total scores for both players

The shoot(sender:) method is now complete. Firing from either base animates properly, the CALayers for each shot are added and removed on shot impact, the height of each base is shrunk down for each shot, and the WinScreenViewController is invoked when a winner is determined.

PewPew Method

What about playing sounds when each base fires their weapon?

In Part 1 the player bases are separated out as embedded UIViewControllers to add to MainGameScreenViewController. Effort was made to separate responsibilities and split the code between classes to prevent what's known as "Massive View Controllers". That's where the UIViewController grows in size as functionality is added until it becomes nearly impossible to maintain.

Based on this, place as much audio player code into the embedded UIViewControllers and call what is needed inside them from the MainGameScreenViewController.

Import Audio Files

Select the Assets.xcassets file and create two new Data Sets, one named 'laser_1' and the other 'laser_2'. Drag and drop two different mp3 files for the laser sound into the sets.

The only requirement is the audio should play for a half-second. The downloadable project code has sample files to use, refer to the link at the top of the post.

Next, add the following code to LowerViewController, first importing AVFoundation to gain access to the audio APIs.

import AVFoundation

var lowerPlayer: AVAudioPlayer?


override func viewDidLoad() {
    super.viewDidLoad()

    let lowerSoundAsset:NSDataAsset = NSDataAsset.init(name: "laser_2")!

    do {
        lowerPlayer = try AVAudioPlayer(data: lowerSoundAsset.data, fileTypeHint: AVFileType.mp3.rawValue)


    } catch let error {
        print("Error setting up audio players: \(error)")
    }
}

Breaking down that code:

  • Add an instance of a class variable named lowerPlayer of AVAudioPlayer type.
  • Modify viewDidLoad() to access the Bundle and grab the asset titled "laser_2".
  • Initialize the lowerPlayer AVAudioPlayer with the asset.

Next, modify the UpperViewController with the same changes, naming the class instance of the AVAudioPlayer type upperPlayer.

Create separate instances of the AVAudioPlayer to permit sound playback simultaneously as well as playback of different sounds for the fire buttons.

Finally, make the following changes to MainGameScreenViewController, first importing AVFoundation and then adding the following code to viewDidLoad():

 do{
    // Get the AVAudioSession
    try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
    try AVAudioSession.sharedInstance().setActive(true)

} catch let error {
    print("Error setting up audio players: \(error)")
}

The code configures the AVAudioSession singleton to playback and sets it to active for the game.

Next, add the following extension that defines the method pewPew(sender:). This method is called when the BoomDelegate shoot(sender:) method is invoked:

extension MainGameScreenViewController{
    
    func pewPew(sender:Any?){

        if let _ = sender as? UpperViewController{
            upperVC.upperPlayer?.play()
        }else{
            lowerVC.lowerPlayer?.play()
        }

    }
    
}

The code casts the sender to check which player triggered the method. Call the respective AVAudioPlayer from the correct UIViewController by using the upperVC or lowerVC class variable (upperPlayer or lowerPlayer).

Finally, in the BoomDelegate implementation of shoot(sender:), call the pewPew(sender:) method. The delegate should look like this now:

extension MainGameScreenViewController:BoomDelegate{
    
    func boom(sender: Any?) {
        shoot(sender: sender)
        pewPew(sender: sender)     
    }
}

Every time either player presses the fire button, the BoomDelegate method, boom(sender:) is called. It in turn calls the shoot(sender:) and pewPew(sender:) methods. Both methods use the sender to determine which player triggered the fire button.

Now all the required code is in place and can be tested. Make sure two different sounds are defined for the different fireButtons and can playback simultaneously.

Final Thoughts

Summarizing the accomplishments of this tutorial:

  • Completed the shoot(sender:) method. Built the firing mechanism logic that decides which player hit the fire button and calculates where that shot will hit on the opponent's base.
  • Created the animation of the shot across the board using CALayer, CATransaction, and CABasicAnimation
  • Added logic that decides if a player wins the game and triggers the WinScreenViewController.
  • Created different sound effects for each player when they hit the fire button.

In Part 4 of the series the game will be completed by adding the following:

  • Code to trigger the unwind segue to start another game
  • Additional animations to the WinScreenViewController to make its appearance more vibrant
  • Additional polishing to improve the overall look

Here are some important links that will provide additional detail around topics in this post: