How To Build A SpriteKit Game In Swift 3 (Part 3) — Smashing Magazine

webberoDesign0 Comments


Have you ever wondered what it takes to create a SpriteKit game? Do buttons seem like a bigger task than they should be? Ever wonder how to persist settings in a game? Game-making has never been easier on iOS since the introduction of SpriteKit. In part three of this three-part series, we will finish up our RainCat game and complete our introduction to SpriteKit.

If you missed out on the previous lesson, you can catch up by getting the code on GitHub. Remember that this tutorial requires Xcode 8 and Swift 3.

Raincat, lesson 3
RainCat, lesson 3

This is lesson three in our RainCat journey. In the previous lesson, we had a long day going though some simple animations, cat behaviors, quick sound effects and background music.

Today we will focus on the following:

  • heads-up display (HUD) for scoring;
  • main menu — with buttons;
  • options for muting sounds;
  • game-quitting option.

Even More Assets

The assets for the final lesson are available on GitHub. Drag the images into Assets.xcassets again, just as we did in the previous lessons.

Heads Up!

We need a way to keep score. To do this, we can create a heads-up display (HUD). This will be pretty simple; it will be an SKNode that contains the score and a button to quit the game. For now, we will just focus on the score. The font we will be using is Pixel Digivolve, which you can get at Dafont.com. As with using images or sounds that are not yours, read the font’s license before using it. This one states that it is free for personal use, but if you really like the font, you can donate to the author from the page. You can’t always make everything yourself, so giving back to those who have helped you along the way is nice.

Next, we need to add the custom font to the project. This process can be tricky the first time.

Download and move the font into the project folder, under a “Fonts” folder. We’ve done this a few times in the previous lessons, so we’ll go through this process a little more quickly. Add a group named Fonts to the project, and add the Pixel digivolve.otf file.

Now comes the tricky part. If you miss this part, you probably won’t be able to use the font. We need to add it to our Info.plist file. This file is in the left pane of Xcode. Click it and you will see the property list (or plist). Right-click on the list, and click “Add Row.”

Add Row
Add a row to the plist.

When the new row comes up, enter in the following:

Fonts provided by application

Then, under Item 0, we need to add our font’s name. The plist should look like the following:

Pixel digivolve.otf
Font added to plist successfully!

The font should be ready to use! We should do a quick test to make sure it works as intended. Move to GameScene.swift, and in sceneDidLoad add the following code at the top of the function:

let label = SKLabelNode(fontNamed: "PixelDigivolve")
label.text = "Hello World!"
label.position = CGPoint(x: size.width / 2, y: size.height / 2)
label.zPosition = 1000

addChild(label)

Does it work?

Hello world!
Testing our SKLabelNode. Oh no! The “Hello World” label is back.

If it works, then you’ve done everything correctly. If not, then something is wrong. Code With Chris has a more in-depth troubleshooting guide, but note that it is for an older version of Swift, so you will have to make minor tweaks to bring it up to Swift 3.

Now that we can load in custom fonts, we can start on our HUD. Delete the “Hello World” label, because we only used it to make sure our font loads. The HUD will be an SKNode, acting like a container for our HUD elements. This is the same process we followed when creating the background node in lesson one.

Create the HudNode.swift file using the usual methods, and enter the following code:

import SpriteKit

class HudNode : SKNode {
  private let scoreKey = "RAINCAT_HIGHSCORE"
  private let scoreNode = SKLabelNode(fontNamed: "PixelDigivolve")
  private(set) var score : Int = 0
  private var highScore : Int = 0
  private var showingHighScore = false

  /// Set up HUD here.
  public func setup(size: CGSize) {
    let defaults = UserDefaults.standard

    highScore = defaults.integer(forKey: scoreKey)

    scoreNode.text = "(score)"
    scoreNode.fontSize = 70
    scoreNode.position = CGPoint(x: size.width / 2, y: size.height - 100)
    scoreNode.zPosition = 1

    addChild(scoreNode)
  }

  /// Add point.
  /// - Increments the score.
  /// - Saves to user defaults.
  /// - If a high score is achieved, then enlarge the scoreNode and update the color.
  public func addPoint() {
    score += 1

    updateScoreboard()

    if score > highScore {

      let defaults = UserDefaults.standard

      defaults.set(score, forKey: scoreKey)

      if !showingHighScore {
        showingHighScore = true

        scoreNode.run(SKAction.scale(to: 1.5, duration: 0.25))
        scoreNode.fontColor = SKColor(red:0.99, green:0.92, blue:0.55, alpha:1.0)
      }
    }
  }

  /// Reset points.
  /// - Sets score to zero.
  /// - Updates score label.
  /// - Resets color and size to default values.
  public func resetPoints() {
    score = 0

    updateScoreboard()

    if showingHighScore {
      showingHighScore = false

      scoreNode.run(SKAction.scale(to: 1.0, duration: 0.25))
      scoreNode.fontColor = SKColor.white
    }
  }

  /// Updates the score label to show the current score.
  private func updateScoreboard() {
    scoreNode.text = "(score)"
  }
}

Before we do anything else, open up Constants.swift and add the following line to the bottom of the file — we will be using it to retrieve and persist the high score:

let ScoreKey = "RAINCAT_HIGHSCORE"

In the code, we have five variables that pertain to the scoreboard. The first variable is the actual SKLabelNode, which we use to present the label. Next is our variable to hold the current score; then the variable that holds the best score. The last variable is a boolean that tells us whether we are currently presenting the high score (we use this to establish whether we need to run an SKAction to increase the scale of the scoreboard and to colorize it to the yellow of the floor).

The first function, setup(size:), is there just to set everything up. We set up the SKLabelNode the same way we did earlier. The SKNode class does not have any size properties by default, so we need to create a way to set a size to position our scoreNode label. We’re also fetching the current high score from UserDefaults. This is a quick and easy way to save small chunks of data, but it isn’t secure. Because we’re not worried about security for this example, UserDefaults is perfectly fine.

In our addPoint(), we’re incrementing the current score variable and checking whether the user has gotten a high score. If they have a high score, then we save that score to UserDefaults and check whether we are currently showing the best score. If the user has achieved a high score, we can animate the size and color of scoreNode.

In the resetPoints() function, we set the current score to 0. We then need to check whether we were showing the high score, and reset the size and color to the default values if needed.

Finally, we have a small function named updateScoreboard. This is an internal function to set the score to scoreNode’s text. This is called in both addPoint() and resetPoints().

Hooking Up The HUD

We need to test whether our HUD is working correctly. Move over to GameScene.swift, and add the following line below the foodNode variable at the top of the file:

private let hudNode = HudNode()

Add the following two lines in the sceneDidLoad() function, near the top:

hudNode.setup(size: size)
addChild(hudNode)

Then, in the spawnCat() function, reset the points in case the cat has fallen off the screen. Add the following line after adding the cat sprite to the scene:

hudNode.resetPoints()

Next, in the handleCatCollision(contact:) function, we need to reset the score again when the cat is hit by rain. In the switch statement at the end of the function — when the other body is a RainDropCategory — add the following line:

hudNode.resetPoints()

Finally, we need to tell the scoreboard when the user has earned points. At the end of the file in handleFoodHit(contact:), find the following lines up to here:

//TODO increment points
print("fed cat")

And replace them with this:

hudNode.addPoint()

Voilà!

HUD unlocked!
HUD unlocked!

You should see the HUD in action. Run around and collect some food. The first time you collect food, you should see the score turn yellow and grow in scale. When you see this happen, let the cat get hit. If the score resets, then you’ll know you are on the right track!

High Score!
Highest score ever (… at the time of writing)!

The Next Scene

That’s right, we are moving to another scene! In fact, when completed, this will be the first screen of our app. Before you do anything else, open up Constants.swift and add the following line to the bottom of the file — we will be using it to retrieve and persist the high score:

let ScoreKey = "RAINCAT_HIGHSCORE"

Create the new scene, place it under the “Scenes” folder, and call it MenuScene.swift. Enter the following code in the MenuScene.swift file:

import SpriteKit

class MenuScene : SKScene {
  let startButtonTexture = SKTexture(imageNamed: "button_start")
  let startButtonPressedTexture = SKTexture(imageNamed: "button_start_pressed")
  let soundButtonTexture = SKTexture(imageNamed: "speaker_on")
  let soundButtonTextureOff = SKTexture(imageNamed: "speaker_off")

  let logoSprite = SKSpriteNode(imageNamed: "logo")
  var startButton : SKSpriteNode! = nil
  var soundButton : SKSpriteNode! = nil

  let highScoreNode = SKLabelNode(fontNamed: "PixelDigivolve")

  var selectedButton : SKSpriteNode?

  override func sceneDidLoad() {
    backgroundColor = SKColor(red:0.30, green:0.81, blue:0.89, alpha:1.0)

    //Set up logo - sprite initialized earlier
    logoSprite.position = CGPoint(x: size.width / 2, y: size.height / 2 + 100)

    addChild(logoSprite)

    //Set up start button
    startButton = SKSpriteNode(texture: startButtonTexture)
    startButton.position = CGPoint(x: size.width / 2, y: size.height / 2 - startButton.size.height / 2)

    addChild(startButton)

    let edgeMargin : CGFloat = 25

    //Set up sound button
    soundButton = SKSpriteNode(texture: soundButtonTexture)
    soundButton.position = CGPoint(x: size.width - soundButton.size.width / 2 - edgeMargin, y: soundButton.size.height / 2 + edgeMargin)

    addChild(soundButton)

    //Set up high-score node
    let defaults = UserDefaults.standard

    let highScore = defaults.integer(forKey: ScoreKey)

    highScoreNode.text = "(highScore)"
    highScoreNode.fontSize = 90
    highScoreNode.verticalAlignmentMode = .top
    highScoreNode.position = CGPoint(x: size.width / 2, y: startButton.position.y - startButton.size.height / 2 - 50)
    highScoreNode.zPosition = 1

    addChild(highScoreNode)
  }
}

Because this scene is relatively simple, we won’t be creating any special classes. Our scene will consist of two buttons. These could be (and possibly deserve to be) their own class of SKSpriteNodes, but because they are different enough, we will not need to create new classes for them. This is an important tip for when you build your own game: You need to be able to determine where to stop and refactor code when things get complex. Once you’ve added more than three or four buttons to a game, it might be time to stop and refactor the menu button’s code into its own class.

The code above isn’t doing anything special; it is setting the positions of four sprites. We are also setting the scene’s background color, so that the whole background is the correct value. A nice tool to generate color codes from HEX strings for Xcode is UI Color. The code above is also setting the textures for our button states. The button to start the game has a normal state and a pressed state, whereas the sound button is a toggle. To simplify things for the toggle, we will be changing the alpha value of the sound button upon the user’s press. We are also pulling and setting the high-score SKLabelNode.

Our MenuScene is looking pretty good. Now we need to show the scene when the app loads. Move to GameViewController.swift and find the following line:

let sceneNode = GameScene(size: view.frame.size)

Replace it with this:

let sceneNode = MenuScene(size: view.frame.size)

This small change will load MenuScene by default, instead of GameScene.

Our new scene!
Our new scene! Note the 1.0 frames per second: Nothing is moving, so no need to update anything.

Button States

Buttons can be tricky in SpriteKit. Plenty of third-party options are available (I even made one myself), but in theory you only need to know the three touch methods:

  • touchesBegan(_ touches: with event:)
  • touchesMoved(_ touches: with event:)
  • touchesEnded(_ touches: with event:)

We covered this briefly when updating the umbrella, but now we need to know the following: which button was touched, whether the user released their tap or clicked that button, and whether the user is still touching it. This is where our selectedButton variable comes into play. When a touch begin, we can capture the button that the user started clicking with that variable. If they drag outside the button, we can handle this and give the appropriate texture to it. When they release the touch, we can then see whether they are still touching inside the button. If they are, then we can apply the associated action to it. Add the following lines to the bottom of MenuScene.swift:

override func touchesBegan(_ touches: Set, with event: UIEvent?) {
  if let touch = touches.first {
    if selectedButton != nil {
      handleStartButtonHover(isHovering: false)
      handleSoundButtonHover(isHovering: false)
    }

    // Check which button was clicked (if any)
    if startButton.contains(touch.location(in: self)) {
      selectedButton = startButton
      handleStartButtonHover(isHovering: true)
    } else if soundButton.contains(touch.location(in: self)) {
      selectedButton = soundButton
      handleSoundButtonHover(isHovering: true)
    }
  }
}

override func touchesMoved(_ touches: Set, with event: UIEvent?) {
  if let touch = touches.first {

    // Check which button was clicked (if any)
    if selectedButton == startButton {
      handleStartButtonHover(isHovering: (startButton.contains(touch.location(in: self))))
    } else if selectedButton == soundButton {
      handleSoundButtonHover(isHovering: (soundButton.contains(touch.location(in: self))))
    }
  }
}

override func touchesEnded(_ touches: Set, with event: UIEvent?) {
  if let touch = touches.first {

    if selectedButton == startButton {
      // Start button clicked
      handleStartButtonHover(isHovering: false)

      if (startButton.contains(touch.location(in: self))) {
        handleStartButtonClick()
      }

    } else if selectedButton == soundButton {
      // Sound button clicked
      handleSoundButtonHover(isHovering: false)

      if (soundButton.contains(touch.location(in: self))) {
        handleSoundButtonClick()
      }
    }
  }

  selectedButton = nil
}

/// Handles start button hover behavior
func handleStartButtonHover(isHovering : Bool) {
  if isHovering {
    startButton.texture = startButtonPressedTexture
  } else {
    startButton.texture = startButtonTexture
  }
}

/// Handles sound button hover behavior
func handleSoundButtonHover(isHovering : Bool) {
  if isHovering {
    soundButton.alpha = 0.5
  } else {
    soundButton.alpha = 1.0
  }
}

/// Stubbed out start button on click method
func handleStartButtonClick() {
  print("start clicked")
}

/// Stubbed out sound button on click method
func handleSoundButtonClick() {
  print("sound clicked")
}

This is simple button-handling for our two buttons. In touchesBegan(_ touches: with events:), we start off by checking whether we have any currently selected buttons. If we do, we need to reset the state of the button to unpressed. Then, we need to check whether any button is pressed. If one is pressed, it will show the highlighted state for the button. Then, we set selectedButton to the button for use in the other two methods.

In touchesMoved(_ touches: with events:), we check which button was originally touched. Then, we check whether the current touch is still within the bounds of selectedButton, and we update the highlighted state from there. The startButton’s highlighted state changes the texture to the pressed-state’s texture, where the soundButton’s highlighted state has the alpha value of the sprite set to 50%.

Finally, in touchesEnded(_ touches: with event:), we check again which button is selected, if any, and then whether the touch is still within the bounds of the button. If all cases are satisfied, we call handleStartButtonClick() or handleSoundButtonClick() for the correct button.

A Time For Action

Now that we have the basic button behavior down, we need an event to trigger when they are clicked. The easier button to implement is startButton. On click, we only need to present the GameScene. Update handleStartButtonClick() in the MenuScene.swift function to the following code:

func handleStartButtonClick() {
  let transition = SKTransition.reveal(with: .down, duration: 0.75)
  let gameScene = GameScene(size: size)
  gameScene.scaleMode = scaleMode
  view?.presentScene(gameScene, transition: transition)
}

If you run the app now and press the button, the game will start!

Now we need to implement the mute toggle. We already have a sound manager, but we need to be able to tell it whether muting is on or off. In Constants.swift, we need to add a key to persist when muting is on. Add the following line:

let MuteKey = "RAINCAT_MUTED"

We will use this to save a boolean value to UserDefaults. Now that this is set up, we can move into SoundManager.swift. This is where we will check and set UserDefaults to see whether muting is on or off. At the top of the file, under the trackPosition variable, add the following line:

private(set) var isMuted = false

This is the variable that the main menu (and anything else that will play sound) checks to determine whether sound is allowed. We initialize it as false, but now we need to check UserDefaults to see what the user wants. Replace the init() function with the following:

private override init() {
  //This is private, so you can only have one Sound Manager ever.
  trackPosition = Int(arc4random_uniform(UInt32(SoundManager.tracks.count)))

  let defaults = UserDefaults.standard

  isMuted = defaults.bool(forKey: MuteKey)
}

Now that we have a default value for isMuted, we need the ability to change it. Add the following code to the bottom of SoundManager.swift:

func toggleMute() -> Bool {
  isMuted = !isMuted

  let defaults = UserDefaults.standard
  defaults.set(isMuted, forKey: MuteKey)
  defaults.synchronize()

  if isMuted {
    audioPlayer?.stop()
  } else {
    startPlaying()
  }

  return isMuted
}

This method will toggle our muted variable, as well as update UserDefaults. If the new value is not muted, playback of the music will begin; if the new value is muted, playback will not begin. Otherwise, we will stop the current track from playing. After this, we need to edit the if statement in startPlaying().

Find the following line:

if audioPlayer == nil || audioPlayer?.isPlaying == false {

And replace it with this:

if !isMuted && (audioPlayer == nil || audioPlayer?.isPlaying == false) {

Now, if muting is off and either the audio player is not set or the current audio player is no longer playing, we will play the next track.

From here, we can move back into MenuScene.swift to finish up our mute button. Replace handleSoundbuttonClick() with the following code:

func handleSoundButtonClick() {
  if SoundManager.sharedInstance.toggleMute() {
    //Is muted
    soundButton.texture = soundButtonTextureOff
  } else {
    //Is not muted
    soundButton.texture = soundButtonTexture
  }
}

This toggles the sound in SoundManager, checks the result and then appropriately sets the texture to show the user whether the sound is muted or not. We are almost done! We only need to set the initial texture of the button on launch. In sceneDidLoad(), find the following line:

soundButton = SKSpriteNode(texture: soundButtonTexture)

And replace it with this:

soundButton = SKSpriteNode(texture: SoundManager.sharedInstance.isMuted ?
  soundButtonTextureOff : soundButtonTexture)

The example above uses a ternary operator to set the correct texture.

Now that the music is hooked up, we can move to CatSprite.swift to disable the cat meowing when muting is on. In the hitByRain(), we can add the following if statement after removing the walking action:

if SoundManager.sharedInstance.isMuted {
  return
}

This statement will return whether the user has muted the app. Because of this, we will completely ignore our currentRainHits, maxRainHits and meowing sound effects.

After all of that, now it is time to try out our mute button. Run the app and verify whether it is playing and muting sounds appropriately. Mute the sound, close the app, and reopen it. Make sure that the mute setting persists. Note that if you just mute and rerun the app from Xcode, you might not have given enough time for UserDefaults to save. Play the game, and make sure the cat never meows when you are muted.

Now that we have the first type of button for the main menu, we can get into some tricky business by adding the quit button to our game scene. Some interesting interactions can come up with our style of game; currently, the umbrella will move to wherever the user touches or moves their touch. Obviously, the umbrella moving to the quit button when the user is attempting to exit the game is a pretty poor user experience, so we will attempt to stop this from happening.

The quit button we are implementing will mimic the start game button that we added earlier, with much of the process staying the same. The change will be in how we handle touches. Get your quit_button and quit_button_pressed assets into the Assets.xcassets file, and add the following code to the HudNode.swift file:

This will handle our quitButton reference, along with the textures that we will set for the button states. To ensure that we don’t inadvertently update the umbrella while trying to quit, we need a variable that tells the HUD (and the game scene) that we are interacting with the quit button and not the umbrella. Add the following code below the showingHighScore boolean variable:

Again, this is a variable that only the HudNode can set but that other classes can check. Now that our variables are set up, we can add in the button to the HUD. Add the following code to the setup(size:) function:

The code above will set the quit button with the texture of our non-pressed state. We’re also setting the position to the upper-right corner and setting the zPosition to a high number in order to force it to always draw on top. If you run the game now, it will show up in GameScene, but it will not be clickable yet.

Now that the button has been positioned, we need to be able to interact with it. Right now, the only place where we have interaction in GameScene is when we are interacting with umbrellaSprite. In our example, the HUD will have priority over the umbrella, so that users don’t have to move the umbrella out of the way in order to exit. We can create the same functions in HudNode.swift to mimic the touch functionality in GameScene.swift. Add the following code to HudNode.swift:

The code above is a lot like the code that we created for MenuScene. The difference is that there is only one button to keep track of, so we can handle everything within these touch methods. Also, because we will know the location of the touch in GameScene, we can just check whether our button contains the touch point.

Move over to GameScene.swift, and replace the touchesBegan(_ touches with event:) and touchesMoved(_ touches: with event:) methods with the following code:

Here, each method handles everything in pretty much the same way. We’re telling the HUD that the user has interacted with the scene. Then, we check whether the quit button is currently capturing the touches. If it is not, then we move the umbrella. We’ve also added the touchesEnded(_ touches: with event:) function to handle the end of the click for the quit button, but we are still not using it for umbrellaSprite.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *