instructions

Code address for this article

ARKit series of articles directory

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

This tutorial will include the following:

  • Step 1: Identify the plane using ARKit.
  • Step 2: Modify the Stack scene from the previous article.
  • Step 3: Port the original 3D version of the game to the AR scene.
  • Step 4: Fix bugs and logic errors after merging

Step1: Use ARKit to identify the plane

First, open Xcode, create a new AR project, select Swift and SceneKit, and create the project

To modify the storyboard appropriately, add: Information label– display AR scene information Play button — after recognizing the scene, click to enter the game Reset button — reset AR scene recognition and game

In addition, there are three properties that control scene recognition:

  // After identifying the plane, place the base nodes of the game, relatively fixed to the real world scene
    weak var baseNode: SCNNode? 
  // After the plane anchors are identified, the plane nodes displayed are constantly refreshed in size and position
    weak var planeNode: SCNNode?
  // The number of refreshes above a certain number indicates that the plane is obvious and stable enough
    var updateCount: NSInteger = 0
Copy the code

In the viewDidLoad method, remove the load default material, replace it with an empty scene first, and open the feature point display (the aircraft model in art.scnassets can also be deleted):

override func viewDidLoad(a) {
        super.viewDidLoad()
        playButton.isHidden = true
        // Set the view's delegate
        sceneView.delegate = self
        
        // Show statistics such as fps and timing information
        sceneView.showsStatistics = true
        // Display debug feature points
        sceneView.debugOptions = [ARSCNDebugOptions.showFeaturePoints]
        // Create a new scene
        let scene = SCNScene(a)// Set the scene to the view
        sceneView.scene = scene

    }
Copy the code

Configure the tracing option in viewWillAppear

override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) guard ARWorldTrackingConfiguration.isSupported else { fatalError(""" ARKit is not  available on this device. For apps that require ARKit for core functionality, use the `arkit` key in the key in the `UIRequiredDeviceCapabilities` section of the Info.plist to prevent the app from installing. (If the app can't be installed, this error can't be triggered in a production scenario.) In apps where AR is an additive feature, use `isSupported` to determine whether to show UI for launching AR experiences. """) // For details, See https://developer.apple.com/documentation/arkit} / / reset interface, parameter, track configuration resetAll ()} private func resetAll () {/ / 0. Display button playButton. IsHidden = true sessionInfoLabel. IsHidden = false / / 1. Reset plane detection configuration and restart detection resetTracking() //2. Reset the number of update updateCount = 0 sceneView. DebugOptions = [ARSCNDebugOptions. ShowFeaturePoints]}Copy the code

Handle Play button click and Reset button click:

    @IBAction func playButtonClick(_ sender: UIButton) {
        //0. Hide the button
        playButton.isHidden = true
        sessionInfoLabel.isHidden = true
        //1. Stop plane detection
        stopTracking()
        //2. Do not display auxiliary points
        sceneView.debugOptions = []
        //3. Change the transparency and color of the planeplaneNode? .geometry? .firstMaterial? .diffuse.contents =UIColor.clear planeNode? .opacity =1
        //4. Load the game scene
        
    }
    @IBAction func restartButtonClick(_ sender: UIButton) {
        resetAll()
    }
Copy the code

The resetAll method must stop tracing before resetting updateCount. Otherwise, after resetting to 0, updateCount+=1 May not be displayed the next time the plane is recognized.

For more detail, we’ll handle the ARSCNViewDelegate method in a separate extension. Note that in addition to its own methods, the protocol also has an SCNSceneRendererDelegate and an ARSessionObserver After the session delegate is set up, use the ARSessionDelegate method:

extension ViewController:ARSCNViewDelegate {
    // MARK: - ARSCNViewDelegate
    
    // What kind of node will be added after the new anchor is identified. If the proxy is not implemented, a default empty node will be added
    // ARKit automatically manages the visibility and transform properties of the node, so it adds its own content to the node as a child node
    // func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
    //
    // let node = SCNNode()
    //
    // return node
    / /}
    
    // After the node is added to the new anchor point (usually in this method add geometry nodes as children of the node)
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        //1. Obtain the captured flat ground anchor points and identify and add only one plane
        if let planeAnchor = anchor as? ARPlaneAnchor,node.childNodes.count < 1,updateCount < 1 {
            print("Catch the flat ground.")
            //2. Create a flat surface (the flat surface captured by the system is an irregularly sized rectangle, here I turn it into a rectangle)
            let plane = SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
            //3. Render the 3D model using Material (default model is white, I changed this to red)plane.firstMaterial? .diffuse.contents =UIColor.red
            //4. Create a node based on the 3D object model
            planeNode = SCNNode(geometry: plane)
            //5. Set the position of the node to the central position of the captured flat anchor pointplaneNode? .simdPosition = float3(planeAnchor.center.x,0, planeAnchor.center.z)
            //6. 'SCNPlane' is vertical by default, so rotate it to match the horizontal 'ARPlaneAnchor'planeNode? .eulerAngles.x = -.pi /2
            
            //7. Change the transparencyplaneNode? .opacity =0.25
            //8. Add to the parent node
            node.addChildNode(planeNode!)
            
            //9. The size/position of the planeNode node above will change depending on the plane detected. For convenience, a relatively fixed base plane will be added to place the game scene
            let base = SCNBox(width: 0.5, height: 0, length: 0.5, chamferRadius: 0); base.firstMaterial? .diffuse.contents =UIColor.gray;
            baseNode = SCNNode(geometry:base); baseNode? .position =SCNVector3Make(planeAnchor.center.x, 0, planeAnchor.center.z); node.addChildNode(baseNode!) }}// Called before updating the anchor and corresponding node, ARKit will automatically update the Anchor and node to match
    func renderer(_ renderer: SCNSceneRenderer, willUpdate node: SCNNode, for anchor: ARAnchor) {
        // Update only the anchor and node pairs obtained in 'renderer(_:didAdd:for:)'.
        guard let planeAnchor = anchor as?  ARPlaneAnchor.let planeNode = node.childNodes.first,
            let plane = planeNode.geometry as? SCNPlane
            else { return }
        
        updateCount += 1
        if updateCount > 20 {// The plane has been updated more than 20 times, and enough feature points have been captured to display the Enter game button
            DispatchQueue.main.async {
                self.playButton.isHidden = false}}// The center of the plane may change.
        planeNode.simdPosition = float3(planeAnchor.center.x, 0, planeAnchor.center.z)
        
        /* The plane size may be enlarged, or several small planes may be combined into one large plane. When merged, 'ARSCNView' automatically deletes the corresponding nodes on the same plane and then calls this method to update the retained dimensions of the other plane.(After testing, the first detected plane and the corresponding node are retained when merging) */
        plane.width = CGFloat(planeAnchor.extent.x)
        plane.height = CGFloat(planeAnchor.extent.z)
    }
    
    // Called after the anchor point and corresponding node have been updated
    func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor){}// Remove the anchor and corresponding node
    func renderer(_ renderer: SCNSceneRenderer, didRemove node: SCNNode, for anchor: ARAnchor){}// MARK: - ARSessionObserver
    
    func session(_ session: ARSession, didFailWithError error: Error) {
        
        sessionInfoLabel.text = "The Session failure:\(error.localizedDescription)"
        resetTracking()
    }
    
    func sessionWasInterrupted(_ session: ARSession) {
        
        sessionInfoLabel.text = "Session interrupted"
    }
    
    func sessionInterruptionEnded(_ session: ARSession) {
        
        sessionInfoLabel.text = "Session interruption ends"
        resetTracking()
    }
    
    func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
        updateSessionInfoLabel(for: session.currentFrame! , trackingState: camera.trackingState) } }Copy the code

So let’s run that, and we can identify the plane

After clicking the Play button, hide unwanted UI content and stop recognizing planes

Step2: modify the 3D Stack game

3 d version of the final code koenig-media.raywenderlich.com/uploads/201 here… First, here’s what we’re going to do: remove the camera code and allow the cameraControl

In 3D games, you need to control the camera to show different scenes, including animation; In AR, the phone is the camera, and you can’t control where the camera is anymore. Instead of adding an action to the mainCamera, add it to the scnscene. rootNode. Of course, you need to reverse the direction of the action, like the original gameover method:

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

The gameOver method after the modification:

func gameOver(a) {
    
    let fullAction = SCNAction.customAction(duration: 0.3) { _._ in
      let moveAction = SCNAction.move(to: SCNVector3Make(0.0.0), duration: 0.3)
      self.scnScene.rootNode.runAction(moveAction)
    }
    
    scnScene.rootNode.runAction(fullAction)
    playButton.isHidden = false
  }
Copy the code

Next, we edited the scene in gamescene.scn:

  • Delete camera – The camera has been removed from the code and is not needed here;
  • Delete background image – no background image is required in AR;
  • Add white ambient light – you can move the phone in AR and see behind the block, so you need to light the back as well
  • The base was made smaller – because the original dimensions (1,0.2,1) meant 1 m long,0.2 m high and 1 m wide. This is too big for the AR scene.

Next, modify the size of the block in the code, the speed of movement, the matching accuracy of the perfect alignment, etc. Define some global constants at the beginning of the file so that we can modify them

let boxheight:CGFloat = 0.05 // The original is 0.2
let boxLengthWidth:CGFloat = 0.4 // It used to be 1
let actionOffet:Float = 0.6 // Originally it was 1.25
let actionSpeed:Float = 0.011 // The original value was 0.3
Copy the code

Difficulty is not big, but the place that should modify is more, a few serious ok.

Finally, I found that the color of the square would not change, so I changed the color to the original:

// Take brokenBoxNode as an example. The rest is similarbrokenBoxNode.geometry? .firstMaterial? .diffuse.contents =UIColor(colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
Copy the code

To:

brokenBoxNode.geometry? .firstMaterial? .diffuse.contents =UIColor(red: 0.1 * CGFloat(height % 10), green: 0.03*CGFloat(height%30), blue: 1-0.1 * CGFloat(height % 10), alpha: 1)
Copy the code

So the color difference is more obvious. In addition, the color of the new box is always the same as the color of the previous one. The geometry of the original box is used when creating newNode. You need to modify the addNewBlock method:

  func addNewBlock(_ currentBoxNode: SCNNode) {
// Create a new SCNBox
    let newBoxNode = SCNNode(geometry: SCNBox(width: CGFloat(newSize.x), height: boxheight, length: CGFloat(newSize.z), chamferRadius: 0))
    newBoxNode.position = SCNVector3Make(currentBoxNode.position.x, currentPosition.y + Float(boxheight), currentBoxNode.position.z)
    newBoxNode.name = "Block\(height+1)"
// Change the color to height+1 layernewBoxNode.geometry? .firstMaterial? .diffuse.contents =UIColor(red: 0.1 * CGFloat((height+1) % 10), green: 0.03*CGFloat((height+1) %30), blue: 1-0.1 * CGFloat((height+1) % 10), alpha: 1)
    
    if height % 2= =0 {
      newBoxNode.position.x = -actionOffet
    } else {
      newBoxNode.position.z = -actionOffet
    }
    
    scnScene.rootNode.addChildNode(newBoxNode)
  }
Copy the code

In addition, the handleTap method also needs to set a color, otherwise the square will have no color, will turn white.

currentBoxNode.geometry? .firstMaterial? .diffuse.contents =UIColor(red: 0.1 * CGFloat(height % 10), green: 0.03*CGFloat(height%30), blue: 1-0.1 * CGFloat(height % 10), alpha: 1)
Copy the code

When you run it, you can see that the objects in the scene are smaller, the camera can move around, the colors change, and there is light in the back…

Step3: merge the two projects and complete the AR version of Stack square game

First, add the ScoreLabel and click gesture in ARStack

Then copy the.SCN footage, audio files, and a category into the step 1 project in step 2.

Add a property that represents a game node:

var gameNode:SCNNode?
Copy the code

Copy the code that entered the game over in the playButtonClick method 4. Continue to write:

//4. Load the game scenegameNode? .removeFromParentNode()// Remove the scene node from the previous game
        gameNode = SCNNode(a)let gameChildNodes = SCNScene(named: "art.scnassets/Scenes/GameScene.scn")! .rootNode.childNodesfor node ingameChildNodes { gameNode? .addChildNode(node) } baseNode? .addChildNode(gameNode!) resetGameData()// Reset the game data
        
// Copy the code.....
Copy the code

Copy the rest of the code and note that the audio file address is changed to art.scnassets. The rest of the scnView everywhere. The rootNode. AddChildNode () gameNode instead? .addChildNode(boxNode)

Then, the resetAll() method needs to reset the game’s parameters and pull out the resetGameData method:

private func resetAll(a) {
        //0. Display buttons
        playButton.isHidden = true
        sessionInfoLabel.isHidden = false
        //1. Reset the plane detection configuration and restart the detection
        resetTracking()
        //2. Reset the update times
        updateCount = 0
        sceneView.debugOptions = [ARSCNDebugOptions.showFeaturePoints]
        //3. Reset the game data
        resetGameData()
        print("resetAll")}private func resetGameData(a) {
        height = 0
        scoreLabel.text = "\(height)"
        
        direction = true
        perfectMatches = 0
        previousSize = SCNVector3(boxLengthWidth, boxheight, boxLengthWidth)
        previousPosition = SCNVector3(0, boxheight*0.5.0)
        currentSize = SCNVector3(boxLengthWidth, boxheight, boxLengthWidth)
        currentPosition = SCNVector3Zero
        
        offset = SCNVector3Zero
        absoluteOffset = SCNVector3Zero
        newSize = SCNVector3Zero
    }
Copy the code

And add a listener that wakes up from the background. When entering the foreground from the background, also call resetAll:

 NotificationCenter.default.addObserver(forName: NSNotification.Name.UIApplicationWillEnterForeground, object: nil, queue: nil) { (noti) in
            self.resetAll()
        }
Copy the code

So let’s run that, and here we go

There are still a lot of problems, but the basics are done.

Step4: fix the merged bugs and logic errors

There are two main bugs:

  • Pieces cut out of alignment fall out of alignment, some stay in place and float in the air;
  • When the level exceeds 5, it will automatically sink, but the part below the recognition plane is still visible, resulting in visual error;
Bug1: Let’s fix the first bug, where shard drops incorrectly.

This is because the physical shape type of the block, SCNPhysicsBodyType, is incorrect. In the original game, the block would not move after it was placed, so it was set to the static type. This type did not really move when the Action was performed, so it needed to be changed to the kinematic type, which allowed us to move freely and collide with falling debris, but was not affected by the collision itself, and was generally used for elevators , transporter, etc.

Places to change are the base in the gamescene.scn file, the first square in the playButtonClick method, the aligned square in the handleTap method, and the newly generated square method addNewBlock

/ / playButtonClick
boxNode.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: boxNode.geometry! , options:nil))

/ / handleTap
currentBoxNode.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: currentBoxNode.geometry! , options:nil))

/ / addNewBlock
newBoxNode.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: newBoxNode.geometry! , options:nil))
Copy the code

Run it again, debris falls, collisions, everything’s fine.

Bug2: When sinking, blocks below the recognition plane will still be visible

The solution to this problem is simple: we go through the nodes as we sink, find that we are below a certain value, and hide it. After gomeOver, all nodes are displayed.

Hidden nodes are no longer involved in physical effects (collisions, etc.) and look good. It is important to note that the light nodes are not hidden.

In the handleTap method, before executing the Action, add code to hide nodes below a certain height

gameNode? .enumerateChildNodes({ (node, stop)in
         ifnode.light ! =nil {// Light nodes are not hidden
                        return
         }           
         if node.position.y < Float(self.height-5) * Float(boxheight) {
              node.isHidden = true}})Copy the code

At the end of the gameOver method, add the code to display the node

gameNode? .enumerateChildNodes({ (node, stop)in
            
    node.isHidden = false
            
})
Copy the code

Final version effect

The project code for each step is available on Github github.com/XanderXu/AR…