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

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

Wrap up the game code by adding final polishing including animations to the win screen, connect screens using an unwind segue, and ensure cross-device functionality.

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

There are just a few items left to complete that will add polish to the game. Before proceeding, take a minute to review the accomplishments from Part 3 of this tutorial series:

  • Completed the shoot(sender:) method that animates the firing mechanism for both players.
  • Added logic that calculates start and end points of a shot at the opponent's base.
  • Determined if a shot resulted in a winner and triggered the segue to the Win Screen.
  • Integrated code that plays a unique sound for each player when they fire their weapon.

The final stages involve completing the WinScreenViewController animations, tying the unwind segue to the MainGameScreenViewController, and making sure the game renders properly on all devices.

The first three parts of the tutorial series are located in the links below:

  • Part 1: Turn fundamental knowledge of CABasicAnimation, CALayers, Delegates, embedded UIViewControllers and Auto Layout into a simple and fun game
  • Part 2: Write the Swift code connecting the Interface Builder design from Part 1 of the series to the engine and logic that will drive it
  • Part 3: Use CALayers and CABasicAnimation to build firing animations and finalize the interface using UIView animations

Remember to download this entire project from the repo on bitbucket.org

Overview

The game finally works now based on the code from previous tutorials. Players can smash buttons to fire at each other and a winner is declared when a player's base is reduced in size down to zero.

Don't stop here though. Players still can't start a new game without restarting the app, it doesn't render properly on all devices, and more animations and tweaks will improve the feel of the game.

To that point, here's an overview of what this tutorial will demonstrate:

  • Handling the Unwind Segue when the players choose to play another game from the WinScreenViewController
  • Adding code to ensure that the game renders properly on all sizes of targeted devices.
  • Adding new animations to the WinScreenViewController to make the screen more interesting and fun.

Unwind Segue

First, create an unwind segue method inside the MainGameScreenViewController. This will handle a return value from the WinScreenViewController.

Add the following code to MainGameScreenViewController:

 @IBAction func unwindFromWinScreen(_ sender:UIStoryboardSegue){

    setBaseHeight()
    overlayView.alpha = 1.0
    overlayView.isHidden = false

    lowerShootCount = 0
    upperShootCount = 0

}

This method does the following:

  • Resets the height of each player's base using the setBaseHeight() method. More info on the other operation of the method here
  • Forces the overlayView to be visible
  • Adjusts the lowerShootCount and upperShootCount back to zero for the new game.

An @IBAction was created inside the WinScreenViewController named returnToMainScreen(_ sender:). That is the trigger of the demo that passes data back to the MainGameScreenViewController using a closure.

That button must also trigger the unwind segue.

Go to the Storyboard and Control-select the Play Again button of the WinScreenViewController, dragging it up to the Exit button on the top of the UIViewController frame. Next, select the unwindFromWinScreen(sender:) method from the popup, refer below:

from_button

Lastly, Control-drag from the top leftmost button on the frame in Storyboard over to the Exit button and select the unwindFromWinScreen(sender:) method from the popup, refer below:

unwind_segue

Run the game, fire from one side until there's a winner and the WinScreenViewController should be triggered. Hit the Play Again button.

If everything is setup correctly the transition back to theMainGameScreenViewController and the countdown to the next game will be visible.

Cross Device Operation

The unwindFromWinScreen(sender:) method, added in the Unwind Segue section called another method named setBaseHeight(). What will this method do? Each time the game is reset the size of each player's base needs resetting to its original size.

There is a problem though. The size set in Interface Builder works well on iPhone X but not so well on an iPhone 4. On a small device the bases will overlap if their height remains set at 350. There needs to be a way to test the device the app is running on and then change the height of the player bases based on that device.

So that's the secondary function of the setBaseHeight() method, to make that adjustment per device when it's needed.

Add a new file to the project named Utility.swift and fill it with the following code:


import UIKit


struct ScreenSize
{
    static let SCREEN_WIDTH         = UIScreen.main.bounds.size.width
    static let SCREEN_HEIGHT        = UIScreen.main.bounds.size.height
    static let SCREEN_MAX_LENGTH    = max(ScreenSize.SCREEN_WIDTH, ScreenSize.SCREEN_HEIGHT)
    static let SCREEN_MIN_LENGTH    = min(ScreenSize.SCREEN_WIDTH, ScreenSize.SCREEN_HEIGHT)
}

struct DeviceType
{
    static let IS_IPHONE_4_OR_LESS  = UIDevice.current.userInterfaceIdiom == .phone && ScreenSize.SCREEN_MAX_LENGTH < 568.0
    static let IS_IPHONE_5          = UIDevice.current.userInterfaceIdiom == .phone && ScreenSize.SCREEN_MAX_LENGTH == 568.0
    static let IS_IPHONE_REG          = UIDevice.current.userInterfaceIdiom == .phone && ScreenSize.SCREEN_MAX_LENGTH == 667.0
    static let IS_IPHONE_PLUS         = UIDevice.current.userInterfaceIdiom == .phone && ScreenSize.SCREEN_MAX_LENGTH == 736.0
    static let IS_IPHONE_X         = UIDevice.current.userInterfaceIdiom == .phone && ScreenSize.SCREEN_MAX_LENGTH == 812.0
    static let IS_IPHONE_X_PLUS_OR_R        = UIDevice.current.userInterfaceIdiom == .phone && ScreenSize.SCREEN_MAX_LENGTH == 896.0
    static let IS_IPAD_PRO_10         = UIDevice.current.userInterfaceIdiom == .pad && ScreenSize.SCREEN_MAX_LENGTH == 1112.0
    static let IS_IPAD_OR_PRO9_OR_MINI              = UIDevice.current.userInterfaceIdiom == .pad && ScreenSize.SCREEN_MAX_LENGTH == 1024.0
    static let IS_IPAD_11              = UIDevice.current.userInterfaceIdiom == .pad && ScreenSize.SCREEN_MAX_LENGTH == 1194.0
    static let IS_IPAD_PRO_12          = UIDevice.current.userInterfaceIdiom == .pad && ScreenSize.SCREEN_MAX_LENGTH == 1366.0
    
}

Giving credit where it's due, this code is from this great StackOverflow answer.

First, it gets the current device's screen width/height, then checks the max value between the width and height of the screen bounds against known values of the largest resolution of all current iOS device types available. It secondarily checks the UIDevice.current.userInterfaceIdiom to see if it's .phone or .pad.

With that code, it's possible to run different code based on the device with a simple boolean test:

if DeviceType.IS_IPHONE_4_OR_LESS{
 // do XYZ for iPhone 4
}

Now add the following method in MainGameScreenViewController:

private func setBaseHeight(){

    if DeviceType.IS_IPHONE_5 || DeviceType.IS_IPHONE_4_OR_LESS || DeviceType.IS_IPHONE_REG{
        lowerContainerHeightConstraint.constant = 275
        upperContainerHeightConstraint.constant = 275
    }else{
        lowerContainerHeightConstraint.constant = 350
        upperContainerHeightConstraint.constant = 350
    }

}

If the device is a small one, like an iPhone 4,5, or 6, then the base height will be set to 275, otherwise 350. Play with these numbers then run on different devices to check how well it works.

One last step, add a call to setBaseHeight() in viewDidLoad() so the initial run of the game sets the correct base height. Each subsequent call will be performed from the unwind segue when unwindFromWinScreen() is called.

WinScreenViewController Animations

There is some code that's still needed to complete the WinScreenViewController functionality before animations are added. First, add the following to viewDidLoad():

self.view.backgroundColor = winnerColor
winnerLabel.text = (winner == .orange) ? "Orange Wins!!!" : "Blue Wins!!!"

After the performSegue(withIdentifier:sender:) is invoked from shoot(sender:) when a base height is reduced to zero, the prepare(for:sender) method is called by the OS before the transition to WinScreenViewController. Simultaneously, the winnerColor and winner are set to the appropriate values inside MainGameScreenViewController.

WinScreenViewController Rotation Animation

Moving on, the look and feel of WinScreenViewController is fine but it's static and a bit boring. What some animations were added to spice it up a bit? One thing comes to mind, the topWinCountLabel and bottomWinCountLabel face one direction so they'd be upside down for the opponent.

What if those are rotated so they're initially facing to each player then flip them around so the opposite player can see the opponent's score from the correct angle for them?

To do this, first use a CGAffineTransform to position the topWinCountLabel and bottomWinCountLabel the correct way before animating them. Add the following code to viewDidLoad in WinScreenViewController:

winnerLabel.transform = (winner == .orange) ? CGAffineTransform.init(rotationAngle:0) : CGAffineTransform.init(rotationAngle:.pi)
topWinCountLabel.transform = CGAffineTransform.init(rotationAngle: .pi)

The first line will rotate the winnerLabel to face the losing side. The second line rotates the topWinCountLabel towards the top player so it reads properly. Thus, each player's own score is setup to be initially readable to themselves.

Next, make the animations that will run when the WinScreenViewController becomes fully visible to the players. Add the following code, overriding the viewDidAppear() method:

override func viewDidAppear(_ animated: Bool) {
        
    super.viewDidAppear(animated)

    // 1 Disable the buttons so the animation plays in full before user can play again or reset scores.
    playAgainButton.isUserInteractionEnabled = false
    resetScores.isUserInteractionEnabled = false

    // 2
    UIView.animate(withDuration: 0.5, animations: {
         self.winnerLabel.transform = CGAffineTransform.init(scaleX: 1.5, y: 1.5)
    }, completion: {_ in
        UIView.animate(withDuration: 0.5, animations: {
            self.winnerLabel.transform = CGAffineTransform.init(scaleX: 1.0, y: 1.0)
        })

    });

    // 3
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(3000)) {

        // 4
        UIView.animate(withDuration: 0.5, animations: {

            // Flip the scores so other player can see
            self.topWinCountLabel.transform = CGAffineTransform.init(rotationAngle: 3.14159 - .pi)
            self.bottomWinCountLabel.transform = CGAffineTransform.init(rotationAngle: .pi)

            // Flip the winner text so other player can see
            self.winnerLabel.transform = (self.winner == .orange) ? CGAffineTransform.init(rotationAngle:.pi) :  CGAffineTransform.init(rotationAngle:3.14159 - .pi)
        }, completion: { (Bool) in

            // 5
            UIView.animate(withDuration: 0.5, delay: 1.5, options: [], animations: {

                // reset all the positions
                self.topWinCountLabel.transform = CGAffineTransform.init(rotationAngle: .pi)
                self.bottomWinCountLabel.transform = CGAffineTransform.init(rotationAngle: 3.14159 - .pi)
                self.winnerLabel.transform = (self.winner == .orange) ? CGAffineTransform.init(rotationAngle:.pi)  : CGAffineTransform.init(rotationAngle:3.14159 - .pi)
            }, completion: { (Bool) in
                // 6 allow user to reset scores or play again
                self.playAgainButton.isUserInteractionEnabled = true
                self.resetScores.isUserInteractionEnabled = true
            })
        })

    }

}

Here's a breakdown of that code:

  1. Disable the two buttons, playAgainButton and resetScores, by setting isUserInteractionEnabled to false for each UIButton. The players cannot start a new game until the animations are complete.
  2. Use another CGAffineTransform to grow and shrink the winnerLabel across a duration of 1 second total.
  3. Use DispatchQueue asyncAfter method to trigger the next set of animations 3 seconds from now
  4. Use transforms to rotate the topWinCountLabel, bottomWinCountLabel, and winnerLabel to the opposite player's angle so they can read the opponent's score. The winnerLabel is flipped depending on who the winner is, as described above.
  5. Rotate the topWinCountLabel, bottomWinCountLabel, and winnerLabel back to original position.
  6. Set isUserInteractionEnabled to true for the playAgainButton and resetScores buttons.

WinScreenViewController Shake Animation

The final animation involves the closure setup in Part 2 of the series. That's used as a demonstration of passing data back using closures. The changed animation to apply takes the winner pass in the closure when the players hit the Play Again button and animate the loser's fire button to shake as a "penalty" for losing the previous game.

The framework for the closure is already complete but it just prints the winner to output. Modify that in MainGameScreenViewController to perform the animation.

First add the following class variable in MainGameScreenViewController:

 var lastLoser:UIButton? = nil

Next, add the following code in MainGameScreenViewController, replacing the winScreenSegue section that handles the passBackClosure piece in prepare(segue:sender) with the following code:

winVC.passBackClosure = {winner in
    if let winner_return_value = winner as? Winner{
        self.lastLoser = winner_return_value == .orange ? self.lowerVC.lowerFire : self.upperVC.upperFire
    }
}

The lastLoser variable is used as a reference to the UIButton of the losing player. Where will it be referenced it and what is done with it?

Add the following to the viewDidAppear() method:

shakeLoser()

Then add the following private method inside MainGameScreenViewController:

 private func shakeLoser(){

    if self.lastLoser != nil{
        let animation = CABasicAnimation(keyPath: "position")
        animation.duration = 0.07
        animation.repeatCount = 40
        animation.autoreverses = true
        animation.fromValue = NSValue(cgPoint: CGPoint(x: lastLoser!.center.x - 20, y: lastLoser!.center.y ))
        animation.toValue = NSValue(cgPoint: CGPoint(x: lastLoser!.center.x + 20, y: lastLoser!.center.y ))

        lastLoser?.layer.add(animation, forKey: "position")
    }
}

Detailing the functionality of the code. CABasicAnimation is used to shake the button along with the following:

  • Initialize the CABasicAnimation with the keyPath string "position" to indicate moving the object in the animation block.
  • Set the duration to a very small number and repeatCount to a high value so it occurs quickly but many times.
  • Set autoreverses to true to ensure the animation goes back and forth between the fromValue->toValue settings
  • Initialize the fromValue x position to -20 pixels and the toValue to +20 pixels, in both cases we do not adjust the y value.
  • Add the animation to the button pointed to by the lastLoser reference. The forKey is the "position" string.

Now test it by running the app, getting a winner in the game, selecting the Play Again button, and ensuring the correct losing side's button is the one that shakes.

Lock App to Portrait Mode

Landscape mode for this game doesn't make much sense. The bases would be too close together in landscape and the players should not rotate the phone while the game is playing. It makes sense to lock the game into portrait mode only.

Select the info.plist file and add a new item under supported interface orientations. Select Portrait(bottom home button) when the new key is created, refer below:

Screen-Shot-2019-08-11-at-2.41.38-PM

Next, select the project name in the Project Navigator (top line item on left), select General, then make sure Portrait is the only Device Orientation selected, refer below:

Screen-Shot-2019-08-11-at-2.46.21-PM

With that, the app is locked to portrait.

Final Thoughts

This is the final part of a four-part series that has a wide range of topics, from software engineering principles all the way to using animations to spice up the user interface appearance.

This series is an entry-level tutorial for beginners and, as such, there are many possible improvements.

Here are some suggestions for improvements and next steps that would build upon the fundamentals learned thus far:

  • The sound effects don't play for every single player press of the fire button if the player taps their fire button extremely fast. The mp3 files may be too long or there might be a way to playback at a faster speed in AVAudioPlayer. Investigate and correct this.
  • The fire animation originates from the fire button. Design a class that draws a cannon/gun emplacement and write code that rotates it to where the random shot is firing to improve the looks.
  • Think about other additions to the game that could improve gameplay:
    • What if the players could target certain sections of the opponents' base? Replace the fire button with a fire "bar" across the bottom of the base, picking up user touches and firing at the opponent's base on the same side as the player's touch.
    • Defensive weapons could provide shields at the expense of something else. Perhaps slightly reducing the size of the base by a tiny increment over time they're in use.
    • Introduce stronger weapons. For example, a user presses the fire button for an extended period of time to charge up a massive laser blast.

Hopefully one of those ideas sparks the drive to go make something more of this game!