instructions

Catalogue of articles in the SceneKit series

For more on iOS, check out WeekWeekUpProject on Github

In this tutorial, you will learn how to make a game like Stack.

This tutorial will cover the following:

  • Create 3D scenes visually.
  • Programmatically load and render 3D scenes.
  • Use the physical body of the node.
  • Use UIKit and SceneKit together.
  • Play audio in SceneKit game.

start

Download the starter Project. Within the starter project, you’ll find the SceneKit directory file with audio and scene files. In addition, there are some SCNVector3 class extensions to perform simple vector arithmetic and generate gradient images. App Icon has also been added! Take some time to familiarize yourself with the project.

You will create a game similar to Stack. The object of the game is to stack another square on top of one square. One thing to be careful of is that when you stack the squares a little bit too far, the excess will be cut away. Totally out of alignment, that’s game over!

Establish scene

You’ll start by building your game scene. Open the GameScene. SCN.

camera
Node Inspector
Main Camera
X: 3, Y: 4.5, Z: 3
X: -40, Y: 45, Z:0

Switch to the Attributes Inspector and change the camera’s Projection type to Orthographic. Next, add lights to the scene. Drag a new Directional Light from the object library into the scene named Directional Light. Because the camera only sees one side of the scene, you don’t have to illuminate the invisible side. Go back to the Attributes Inspector and set the positions to X: 0, Y: 0, Z: 0 and rotate to X: -65, Y: 20, Z:-30:

Amazing! It lights up!

Now back to the top of the tower. You need a base block to support the tower and allow the player to build on it. Drag and drop a box into the scene and set the properties:

  • In the Node Inspector, change the name to Base Block and set the positions to X:0,Y:-4, and Z:0.
  • In the Attributes Inspector, change the dimensions to Width: 1, Height: 8, Length: 1.
  • In the Material Inspector, change the diffuse color to# 434343.

You need to add a dynamic shape to the base square, switch to the Physics Inspector, and change the physical shape to Static.

Now let’s go with a nice background color! While selecting the base square, switch to the Scene Inspector and drag the file gradient. PNG into the background selection box:

You need a way to show the player how high their towers are stacked. Open the Main. The storyboard; See that it already has a SCNView. Add a label to the top of the SCNView and set the text to 0. Then add a constraint to center the label like this:

Add another constraint to align the top of label with the top of the screen.

Then switch to the Attributes Inspector and change the font to Custom, Thonburi, Regular, 50.

Then use the Assistant Editor to add a reference from label to controller named scoreLabel:

Compile and run, and see what we have.

Add your first square

Do you know how to make towers taller and taller? Yeah, create more cubes. Create properties to help you track which blocks you are using. To do this, open viewController.swift () and add the following variables before viewDidLoad() :

/ / 1
var direction = true
var height = 0

/ / 2
var previousSize = SCNVector3(1.0.2.1)
var previousPosition = SCNVector3(0.0.1.0)
var currentSize = SCNVector3(1.0.2.1)
var currentPosition = SCNVector3Zero

/ / 3
var offset = SCNVector3Zero
var absoluteOffset = SCNVector3Zero
var newSize = SCNVector3Zero

/ / 4
var perfectMatches = 0
Copy the code

The meaning of this code:

  1. Direction is used to indicate whether the position of the square is going up or down, and height is used to indicate how tall the tower is.
  2. The previousSize and previousPosition variables represent the size and location of the current layer.
  3. You need to use offset, absoluteOffset newSize variables to calculate the size of the new layer.
  4. The perfectMatches variable represents the number of times a player perfectly aligns to the previous tier.

Now, it’s time to add squares to the scene. Add the following code at the bottom of viewDidLoad() :

/ / 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)"
    
/ / 2blockNode.geometry? .firstMaterial? .diffuse.contents =UIColor(colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
scnScene.rootNode.addChildNode(blockNode)
Copy the code

Code Meaning:

  1. You create a new square from the cube of SCNNode, place it on the Z and Y axes, and name it according to the height property placed on the tower.
  2. Based on the increasing height, the red component of the diffuse color is calculated. Finally, add the node to the scene.

Create and run, and see your new block appear on the screen!

Mobile square

You now have a new square to place. However, I think it would be more fun if the cubes were moving. To implement this move, you need to set up the controller as the scene rendering agent and implement the methods in the SCNSceneRendererDelegate protocol. Add this extension at the bottom of the class:

extension ViewController: SCNSceneRendererDelegate {
  func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval){}}Copy the code

Here we need to implement the SCNSceneRendererDelegate protocol and add renderer(_:updateAtTime:). Add the following code to renderer(_:updateAtTime:) :

/ / 1
if let currentNode = scnScene.rootNode.childNode(withName: "Block\(height)", recursively: false) {
      / / 2
      if height % 2= =0 {
        / / 3
        if currentNode.position.z >= 1.25 {
          direction = false
        } else if currentNode.position.z <= -1.25 {
          direction = true
        }
        
        / / 4
        switch direction {
        case true:
          currentNode.position.z += 0.03
        case false:
          currentNode.position.z -= 0.03
        }
      / / 5
      } else {
        if currentNode.position.x >= 1.25 {
          direction = false
        } else if currentNode.position.x <= -1.25 {
          direction = true
        }
       
        switch direction {
        case true:
          currentNode.position.x += 0.03
        case false:
          currentNode.position.x -= 0.03}}}Copy the code

Code Meaning:

  1. Find the square in the scene by name.
  2. Move the squares along the X or Z axis, depending on the position of the layers. The odd-numbered layers move along the Z axis and the even-numbered layers move along the X axis. Use the remainder operator (%) to get the remainder and judge whether it is even or odd.
  3. If the square is at 1.25 or -1.25, change its direction and go the other way.
  4. We move forward and backward along the Z axis, depending on the direction.
  5. Repeat the same code, only along the X-axis.

By default,SceneKit pauses the scene. To see the movement of objects in the scene, add the following code at the bottom of viewDidLoad:

scnView.isPlaying = true
scnView.delegate = self
Copy the code

In this code, we set this controller as the rendering agent for the scene, which will execute the agent method we just wrote. Create a run and view the movement!

Handle click

Now that we have the block moving, we need to add a new block and resize the old block when the player clicks on the screen. Switch to main. storyboard and add a Tap Gesture recognizer to the SCNView, like this:

Now create an action in the controller using the helper editor and name it handleTap.

Switch to the standard edit area and open viewController.swift, then add code inside handlTap(_:) :

if let currentBoxNode = scnScene.rootNode.childNode(
  withName: "Block\(height)", recursively: false) {
      currentPosition = currentBoxNode.presentation.position
      let boundsMin = currentBoxNode.boundingBox.min
      let boundsMax = currentBoxNode.boundingBox.max
      currentSize = boundsMax - boundsMin
      
      offset = previousPosition - currentPosition
      absoluteOffset = offset.absoluteValue()
      newSize = currentSize - absoluteOffset
      
      currentBoxNode.geometry = SCNBox(width: CGFloat(newSize.x), height: 0.2, 
        length: CGFloat(newSize.z), chamferRadius: 0)
      currentBoxNode.position = SCNVector3Make(currentPosition.x + (offset.x/2),
        currentPosition.y, currentPosition.z + (offset.z/2))
      currentBoxNode.physicsBody = SCNPhysicsBody(type: .static, 
        shape: SCNPhysicsShape(geometry: currentBoxNode.geometry! , options:nil))}Copy the code

Here we get the currentBoxNode from the scene. Then we calculate the offset and the size of the new square. Change the size and position of the square and give it a static physical shape.

The offset is equal to the difference between the position of the previous layer and the current layer. The new size is obtained by subtracting the absolute value of the difference from the current size. Notice that by setting the current node to the offset, the edge of the box perfectly aligns with the edge of the previous layer. This creates the illusion of cutting off squares.

Next, you need a method to create the next block on the tower. Add code under handleTap(_:) :

func addNewBlock(_ currentBoxNode: SCNNode) {
  let newBoxNode = SCNNode(geometry: currentBoxNode.geometry)
  newBoxNode.position = SCNVector3Make(currentBoxNode.position.x, 
    currentPosition.y + 0.2, currentBoxNode.position.z)
  newBoxNode.name = "Block\(height+1)"newBoxNode.geometry? .firstMaterial? .diffuse.contents =UIColor(
    colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
    
  if height % 2= =0 {
    newBoxNode.position.x = -1.25
  } else {
    newBoxNode.position.z = -1.25
  }
    
  scnScene.rootNode.addChildNode(newBoxNode)
}
Copy the code

Here we create a new node the same size as the previous square. Place it above the current square and change the position of the X or Z axis according to the height. Finally, change the diffuse color and add it to the scene.

You need to use handleTap(_:) to keep the properties up to date. Add code to the if else statement in handleTap(_:) :

addNewBlock(currentBoxNode)

if height >= 5 {
  let moveUpAction = SCNAction.move(by: SCNVector3Make(0.0.0.2.0.0), duration: 0.2)
  let mainCamera = scnScene.rootNode.childNode(withName: "Main Camera", recursively: false)!
  mainCamera.runAction(moveUpAction)
}
      
scoreLabel.text = "\(height+1)"
      
previousSize = SCNVector3Make(newSize.x, 0.2, newSize.z)
previousPosition = currentBoxNode.position
height += 1
Copy the code

The first thing to do is call addNewBlock(_:). If the size of the tower is greater than or equal to 5, move the camera up.

You also need to update the score to set the previous size and position to equal the current size and position. You can use newSize because you set the size of the current node to newSize. Then increase the height.

Up and running. Everything looks stacked perfectly!

Achieve physics

The game resized the blocks correctly, but the game would look cooler if the cut parts fell off the tower.

Define a new method under addNewBlock(_:) :

func addBrokenBlock(_ currentBoxNode: SCNNode) {
    let brokenBoxNode = SCNNode()
    brokenBoxNode.name = "Broken \(height)"
    
    if height % 2= =0 && absoluteOffset.z > 0 {
      / / 1
      brokenBoxNode.geometry = SCNBox(width: CGFloat(currentSize.x), 
        height: 0.2, length: CGFloat(absoluteOffset.z), chamferRadius: 0)
      
      / / 2
      if offset.z > 0 {
        brokenBoxNode.position.z = currentBoxNode.position.z - 
          (offset.z/2) - ((currentSize - offset).z/2)}else {
        brokenBoxNode.position.z = currentBoxNode.position.z - 
          (offset.z/2) + ((currentSize + offset).z/2)
      }
      brokenBoxNode.position.x = currentBoxNode.position.x
      brokenBoxNode.position.y = currentPosition.y
      
      / / 3
      brokenBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic, 
        shape: SCNPhysicsShape(geometry: brokenBoxNode.geometry! , options:nil)) brokenBoxNode.geometry? .firstMaterial? .diffuse.contents =UIColor(colorLiteralRed: 0.01 * 
        Float(height), green: 0, blue: 1, alpha: 1)
      scnScene.rootNode.addChildNode(brokenBoxNode)

    / / 4
    } else if height % 2! =0 && absoluteOffset.x > 0 {
      brokenBoxNode.geometry = SCNBox(width: CGFloat(absoluteOffset.x), height: 0.2, 
        length: CGFloat(currentSize.z), chamferRadius: 0)
      
      if offset.x > 0 {
        brokenBoxNode.position.x = currentBoxNode.position.x - (offset.x/2) - 
          ((currentSize - offset).x/2)}else {
        brokenBoxNode.position.x = currentBoxNode.position.x - (offset.x/2) + 
          ((currentSize + offset).x/2)
      }
      brokenBoxNode.position.y = currentPosition.y
      brokenBoxNode.position.z = currentBoxNode.position.z
      
      brokenBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic, 
        shape: SCNPhysicsShape(geometry: brokenBoxNode.geometry! , options:nil)) brokenBoxNode.geometry? .firstMaterial? .diffuse.contents =UIColor(
        colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
      scnScene.rootNode.addChildNode(brokenBoxNode)
    }
  }
Copy the code

Here you create a new node and name it height. You use the if statement to determine the axes and make sure the offset is greater than 0, because equal does not generate a square fragment.

  1. Just now, you subtracted the offset to set the new size. Here, you don’t have to calculate, the size you need is the offset.
  2. Change the position of the fragment.
  3. Add a physical shape to the shard to make it fall. You also need to change the color and add it to the scene.
  4. Repeat the same operation on the X-axis.

You get the position of the fragment by half the offset of the current position. Then add or subtract half of the current size minus the offset, depending on the square position.

Call this method before addNewBlock(_:) in handleTap(_:) :

addBrokenBlock(currentBoxNode)
Copy the code

When the shard node falls out of sight, it is still falling and not destroyed. Add code at the top of renderer(_:updateAtTime:) :

for node in scnScene.rootNode.childNodes {
  if node.presentation.position.y <= -20 {
    node.removeFromParentNode()
  }
}
Copy the code

This code removes all nodes with a Y value less than -20. Run to see the cut squares!

The end of the touch

Now that the core of the game’s mechanics are complete, there are still some finishing touches. There should be a reward when the player perfectly aligns to the next tier. Also, there is no win/loss judgment yet, and you can’t start a new game if you lose! The game doesn’t have sound yet, so some sounds need to be added.

Handle perfect alignment

In the case of perfect alignment, add the following method to 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}}Copy the code

A perfect match is considered if the player places it within 0.03 of the previous piece. As long as the error is close enough, set the position of the current square to the position of the previous square.

Make them match exactly numerically by setting the current and last positions equal and recalculate the offset and new size. After calculating the offset and new size in handleTap(_:), call this method:

checkPerfectMatch(currentBoxNode)
Copy the code

Deal with total dislocation

Now you’ve dealt with perfectly aligned and partially aligned cases, but you also need to deal with completely missed cases.

Before checkPerfectMatch(_:) in handleTap(_:), add the following code:

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
      }
Copy the code

If the player misses a block, the calculated new size should be negative. Check this value to see if the player missed a block. If the player misses, you increase the height by one, so that the moving code no longer moves the current block. Then you add a dynamic physical shape to drop the block.

Finally,return, so the code will no longer run, such as checkPerfectMatch(_:), and addBrokenBlock(_:).

Add sound effects

Because audio files are short, they can be pre-loaded. Add a new dictionary attribute to the attribute declaration called sounds:

var sounds = [String: SCNAudioSource] ()Copy the code

Next, add two methods under 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))}Copy the code

The first method loads an audio file from the specified directory and stores it in the Sounds dictionary. The second method plays the methods stored in the sounds dictionary.

Add the following code in 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")
Copy the code

There are several places where you need to play sound. In handleTap(_:), add the following code to each if statement that checks if the player has missed a block:

playSound(sound: "GameOver", node: currentBoxNode)
Copy the code

After calling addNewBlock, add a line:

playSound(sound: "SliceBlock", node: currentBoxNode)
Copy the code

Scroll to checkPerfectMatch(_:) and add a line to the branches of the two if statements:

playSound(sound: "PerfectFit", node: currentBoxNode)
Copy the code

Build and run — games with sound are more fun, right?

Deal with winning and losing conditions

How does the game end? Now let’s deal with this problem!

Go to main. storyboard and drag a new button into the view. Change the text color to #FF0000, text content to Play. Then change the font to Custom, Helvetica Neue, 66.

Next, set the alignment mode align to center and set the base constant to 100.

Drag the lead to the controller and name it playButton. Then create an action and name it playGame and write the following code:

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)
Copy the code

You notice that you have reset all the variables in the game to their default values and added the first square.

Now that the first block has been added, remove the following code from viewDidLoad(_:), from declaring blockNode to adding 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)"
    
    / / 2blockNode.geometry? .firstMaterial? .diffuse.contents =UIColor(colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
    scnScene.rootNode.addChildNode(blockNode)  
Copy the code

Define a new method below the one you just created:

func gameOver(a) {
    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
  }
Copy the code

Here, you zoom in to reveal the entire tower. Finally, make the Play button visible so that the player can start a new game.

Inside handleTap(_:), in the if statement that completely misses the block, gameover() is called before the return statement, in both if statements:

gameOver()
Copy the code

Compile and run. When you fail, you can start a new game.

Start the pictures

The game starts with an ugly white screen. Open launchscreen.storyboard and drag it into an image view. Align the screen around:

Change the image to gradient.png

Now we’ve replaced the white screen with a nice gradient image!

Congratulations, you’ve done it! You can download the final project here

end