How to Make a Game Like Stack

In this tutorial, you’ll learn how to make a game like Stack using SceneKit and Swift. By Brody Eller.

4 (1) · 1 Review

Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Handling Perfect Matches

To handle the "perfect matching" case, add the following method under addBrokenBlock(_:):

func checkPerfectMatch(_ currentBoxNode: SCNNode) {
    if height % 2 == 0 && absoluteOffset.z <= 0.03 {
      currentBoxNode.position.z = previousPosition.z
      currentPosition.z = previousPosition.z
      perfectMatches += 1
      if perfectMatches >= 7 && currentSize.z < 1 {
        newSize.z += 0.05
      }
      
      offset = previousPosition - currentPosition
      absoluteOffset = offset.absoluteValue()
      newSize = currentSize - absoluteOffset
    } else if height % 2 != 0 && absoluteOffset.x <= 0.03 {
      currentBoxNode.position.x = previousPosition.x
      currentPosition.x = previousPosition.x
      perfectMatches += 1
      if perfectMatches >= 7 && currentSize.x < 1 {
        newSize.x += 0.05
      }
      
      offset = previousPosition - currentPosition
      absoluteOffset = offset.absoluteValue()
      newSize = currentSize - absoluteOffset
    } else {
      perfectMatches = 0
    }
  }

If the player stops the block within 0.03 of the previous position, you’ll consider this a a perfect match. You set the position of the current block equal to the position of the previous block, since it’s not quite a perfect mathematical match, but it’s close enough.

By setting the current and previous positions equal, you make the perfect match mathematically correct and then recalculate the offset and new size. Call this method right after you calculate the offset and new size inside handleTap(_:):

checkPerfectMatch(currentBoxNode)

Handling Misses

Now you've covered the cases where the player matches perfectly and when they partially match, but you haven't covered the case when the player misses.

Add the following right above the call to checkPerfectMatch(_:) inside handleTap(_:):

if height % 2 == 0 && newSize.z <= 0 {
        height += 1
        currentBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic, 
          shape: SCNPhysicsShape(geometry: currentBoxNode.geometry!, options: nil))
        return
      } else if height % 2 != 0 && newSize.x <= 0 {
        height += 1
        currentBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic, 
          shape: SCNPhysicsShape(geometry: currentBoxNode.geometry!, options: nil))
        return
      }

If the player misses the block, the new size calculation will be negative, so you can check for this to see if the player has missed. If the player has missed, you increment the height by one so that the movement code is no longer moving the current block. Then you add a dynamic physics body so the block will fall.

Finally, you return so that the code after does not run, such as checkPerfectMatch(_:), and addBrokenBlock(_:).

Adding Sound Effects

Since the audio files are very short, it makes sense to pre-load the audio. Add a new dictionary property named sounds to the variable declarations:

var sounds = [String: SCNAudioSource]()

Next, add these two methods below viewDidLoad:

func loadSound(name: String, path: String) {
  if let sound = SCNAudioSource(fileNamed: path) {
    sound.isPositional = false
    sound.volume = 1
    sound.load()
    sounds[name] = sound
  }
}
  
func playSound(sound: String, node: SCNNode) {
  node.runAction(SCNAction.playAudio(sounds[sound]!, waitForCompletion: false))
}

The first method loads the audio file at the path specified and stores it inside the sounds dictionary. The second method plays the audio file stored in the sounds dictionary.

Add these inside the middle of viewDidLoad():

loadSound(name: "GameOver", path: "HighRise.scnassets/Audio/GameOver.wav")
loadSound(name: "PerfectFit", path: "HighRise.scnassets/Audio/PerfectFit.wav")
loadSound(name: "SliceBlock", path: "HighRise.scnassets/Audio/SliceBlock.wav")

There are a few places where you'll need to play the sound effects. Inside handleTap(_:), add this line inside each section of the if statement that checks whether the player missed the block, but before the return statement:

playSound(sound: "GameOver", node: currentBoxNode)

Add this line below the call to addNewBlock:

playSound(sound: "SliceBlock", node: currentBoxNode)

Scroll down to checkPerfectMatch(_:) and add this line inside both sections of the if statement:

playSound(sound: "PerfectFit", node: currentBoxNode)

Build and run — things feel much more fun with some sounds, don’t they?

Handling Win/Lose Condition

What good is a game that doesn't end? You're going to fix that right now! :]

Head into Main.storyboard and drag a new button onto the view. Change the text color's hex value to #FF0000 and its text to Play. Then change its font to Custom, Helvetica Neue, 66.

Next, align the button to the center and pin it to the bottom with a constant of 100.

Connect an outlet to the view controller titled playButton. Then create an action titled playGame and place this code inside:

playButton.isHidden = true
    
let gameScene = SCNScene(named: "HighRise.scnassets/Scenes/GameScene.scn")!
let transition = SKTransition.fade(withDuration: 1.0)
scnScene = gameScene
let mainCamera = scnScene.rootNode.childNode(withName: "Main Camera", recursively: false)!
scnView.present(scnScene, with: transition, incomingPointOfView: mainCamera, completionHandler: nil)
    
height = 0
scoreLabel.text = "\(height)"
    
direction = true
perfectMatches = 0
    
previousSize = SCNVector3(1, 0.2, 1)
previousPosition = SCNVector3(0, 0.1, 0)
    
currentSize = SCNVector3(1, 0.2, 1)
currentPosition = SCNVector3Zero
    
let boxNode = SCNNode(geometry: SCNBox(width: 1, height: 0.2, length: 1, chamferRadius: 0))
boxNode.position.z = -1.25
boxNode.position.y = 0.1
boxNode.name = "Block\(height)"
boxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(colorLiteralRed: 0.01 * Float(height),
  green: 0, blue: 1, alpha: 1)
scnScene.rootNode.addChildNode(boxNode)

You'll notice that you're resetting all the game's variables to their default value and adding the first block.

Since you are now adding the first block here, remove the following lines of code out the viewDidLoad(_:) again, specifically from the declaration of blockNode until you add it to the scene.

    //1
    let blockNode = SCNNode(geometry: SCNBox(width: 1, height: 0.2, length: 1, chamferRadius: 0))
    blockNode.position.z = -1.25
    blockNode.position.y = 0.1
    blockNode.name = "Block\(height)"
    
    //2
    blockNode.geometry?.firstMaterial?.diffuse.contents =
      UIColor(colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
    scnScene.rootNode.addChildNode(blockNode)  

Define a new method below the method you just created:

func gameOver() {
    let mainCamera = scnScene.rootNode.childNode(
      withName: "Main Camera", recursively: false)!
    
    let fullAction = SCNAction.customAction(duration: 0.3) { _,_ in
      let moveAction = SCNAction.move(to: SCNVector3Make(mainCamera.position.x, 
        mainCamera.position.y * (3/4), mainCamera.position.z), duration: 0.3)
      mainCamera.runAction(moveAction)
      if self.height <= 15 {
        mainCamera.camera?.orthographicScale = 1
      } else {
        mainCamera.camera?.orthographicScale = Double(Float(self.height/2) / 
          mainCamera.position.y)
      }
    }
    
    mainCamera.runAction(fullAction)
    playButton.isHidden = false
  }

Here, you zoom out the camera to reveal the entire tower. At the end, you set the play button to visible so the player can start a new game.

Place the following call to gameOver() in the missed block if statement inside handleTap(_:), above the return statement and inside both parts of the if statement:

gameOver()

Build and run. You should now be able to start a new game if — I mean when — you lose. :]