[ARKit]1-如何製作一個AR版Stack的遊戲

蘋果API搬運工發表於2017-12-23

說明

本文程式碼地址

ARKit系列文章目錄

學習ARKit前,需要先學習SceneKit,參考SceneKit系列文章目錄

更多iOS相關知識檢視github上WeekWeekUpProject

在本教程中,你將會學習如何製作一個類似Stack AR這樣的遊戲.

392x696bb.jpg

本教程將包含以下內容:

  • 第1步:利用ARKit識別出平面.
  • 第2步:修改上一篇中Stack遊戲場景.
  • 第3步:將原3D版遊戲移植到AR場景中.
  • 第4步:修復合併後的bug和邏輯錯誤

step1:利用ARKit識別平面

首先,開啟Xcode,新建一個AR專案,選擇swift和SceneKit,建立專案

WX20171015-094806@2x.png
WX20171015-094903@2x.png

對storyboard中進行適當改造,新增: 資訊label---顯示AR場景資訊 Play按鈕---識別到場景後點選進入遊戲 reset按鈕---重置AR場景識別和遊戲

WX20171015-095005@2x.png

此外,還有三個屬性,用來控制場景識別:

  // 識別出平面後,放上游戲的基礎節點,相對固定於真實世界場景中
    weak var baseNode: SCNNode? 
  // 識別出平面錨點後,顯示的平面節點,會不斷重新整理大小和位置
    weak var planeNode: SCNNode?
  // 重新整理次數,超過一定次數才說明這個平面足夠明顯,足夠穩定
    var updateCount: NSInteger = 0
複製程式碼

viewDidLoad方法中,刪除載入預設素材,先用一個空的場景代替,並開啟特徵點顯示(art.scnassets裡面的飛機模型也可以刪除了):

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

    }
複製程式碼

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
        }
        //重置介面,引數,追蹤配置
        resetAll()
    }

    private func resetAll() {
        //0.顯示按鈕
        playButton.isHidden = true
        sessionInfoLabel.isHidden = false
        //1.重置平面檢測配置,重啟檢測
        resetTracking()
        //2.重置更新次數
        updateCount = 0
        sceneView.debugOptions = [ARSCNDebugOptions.showFeaturePoints]
    }
複製程式碼

處理Play按鈕點選和reset按鈕點選:

    @IBAction func playButtonClick(_ sender: UIButton) {
        //0.隱藏按鈕
        playButton.isHidden = true
        sessionInfoLabel.isHidden = true
        //1.停止平面檢測
        stopTracking()
        //2.不顯示輔助點
        sceneView.debugOptions = []
        //3.更改平面的透明度和顏色
        planeNode?.geometry?.firstMaterial?.diffuse.contents = UIColor.clear
        planeNode?.opacity = 1
        //4.載入遊戲場景
        
    }
    @IBAction func restartButtonClick(_ sender: UIButton) {
        resetAll()
    }
複製程式碼

這裡說一下resetAll方法裡的問題,一定要先停止追蹤,再重置updateCount,否則,可能重置為0後,又更新了AR場景, updateCount+=1,造成下一次識別出平面後不能顯示出來.

為了更清晰,我們在單獨的extension中處理ARSCNViewDelegate的代理方法,注意這個協議裡除了自帶的方法外,還有SCNSceneRendererDelegate和ARSessionObserver,如果還不夠用,還可以成為session的代理後,使用ARSessionDelegate中的方法:

extension ViewController:ARSCNViewDelegate {
    // MARK: - ARSCNViewDelegate
    
    // 識別到新的錨點後,新增什麼樣的node.不實現該代理的話,會新增一個預設的空的node
    // ARKit會自動管理這個node的可見性及transform等屬性等,所以一般把自己要顯示的內容新增在這個node下面作為子節點
    //    func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
    //
    //        let node = SCNNode()
    //
    //        return node
    //    }
    
    // node新增到新的錨點上之後(一般在這個方法中新增幾何體節點,作為node的子節點)
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        //1.獲取捕捉到的平地錨點,只識別並新增一個平面
        if let planeAnchor = anchor as? ARPlaneAnchor,node.childNodes.count < 1,updateCount < 1 {
            print("捕捉到平地")
            //2.建立一個平面    (系統捕捉到的平地是一個不規則大小的長方形,這裡筆者將其變成一個長方形)
            let plane = SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
            //3.使用Material渲染3D模型(預設模型是白色的,這裡筆者改成紅色)
            plane.firstMaterial?.diffuse.contents = UIColor.red
            //4.建立一個基於3D物體模型的節點
            planeNode = SCNNode(geometry: plane)
            //5.設定節點的位置為捕捉到的平地的錨點的中心位置  SceneKit框架中節點的位置position是一個基於3D座標系的向量座標SCNVector3Make
            planeNode?.simdPosition = float3(planeAnchor.center.x, 0, planeAnchor.center.z)
            //6.`SCNPlane`預設是豎著的,所以旋轉一下以匹配水平的`ARPlaneAnchor`
            planeNode?.eulerAngles.x = -.pi / 2
            
            //7.更改透明度
            planeNode?.opacity = 0.25
            //8.新增到父節點中
            node.addChildNode(planeNode!)
            
            //9.上面的planeNode節點,大小/位置會隨著檢測到的平面而不斷變化,方便起見,再新增一個相對固定的基準平面,用來放置遊戲場景
            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!)
        }
    }
    
    // 更新錨點和對應的node之前呼叫,ARKit會自動更新anchor和node,使其相匹配
    func renderer(_ renderer: SCNSceneRenderer, willUpdate node: SCNNode, for anchor: ARAnchor) {
        // 只更新在`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 {//平面超過更新20次,捕捉到的特徵點已經足夠多了,可以顯示進入遊戲按鈕
            DispatchQueue.main.async {
                self.playButton.isHidden = false
            }
        }
        
        // 平面的中心點可以會變動.
        planeNode.simdPosition = float3(planeAnchor.center.x, 0, planeAnchor.center.z)
        
        /*
         平面尺寸可能會變大,或者把幾個小平面合併為一個大平面.合併時,`ARSCNView`自動刪除同一個平面上的相應節點,然後呼叫該方法來更新保留的另一個平面的尺寸.(經過測試,合併時,保留第一個檢測到的平面和對應節點)
         */
        plane.width = CGFloat(planeAnchor.extent.x)
        plane.height = CGFloat(planeAnchor.extent.z)
    }
    
    // 更新錨點和對應的node之後呼叫
    func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        
    }
    // 移除錨點和對應node後
    func renderer(_ renderer: SCNSceneRenderer, didRemove node: SCNNode, for anchor: ARAnchor) {
        
    }
    
    // MARK: - ARSessionObserver
    
    func session(_ session: ARSession, didFailWithError error: Error) {
        
        sessionInfoLabel.text = "Session失敗: \(error.localizedDescription)"
        resetTracking()
    }
    
    func sessionWasInterrupted(_ session: ARSession) {
        
        sessionInfoLabel.text = "Session被打斷"
    }
    
    func sessionInterruptionEnded(_ session: ARSession) {
        
        sessionInfoLabel.text = "Session打斷結束"
        resetTracking()
    }
    
    func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
        updateSessionInfoLabel(for: session.currentFrame!, trackingState: camera.trackingState)
    }
}

複製程式碼

執行一下,順利識別到平面

step1-1.gif

點選Play按鈕之後,隱藏不需要的UI內容,並停止識別平面

step1-2.gif

step2:修改3D版Stack遊戲

3D版最終程式碼在這裡koenig-media.raywenderlich.com/uploads/201… 首先,我們要做的是:移除攝像機程式碼,允許cameraControl

3D遊戲中,需要控制攝像機來展現不同場景,包括實現動畫;而AR中,手機就是攝像機,不能再控制攝像機的位置了.因此將原來加在mainCamera上的動作,改為加在scnScene.rootNode上面即可,當然動作方向也需要反轉一下,比如原來gameover方法:

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
  }
複製程式碼

改過之後中的gameOver方法:

func gameOver() {
    
    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
  }
複製程式碼

接著,我們在GameScene.scn中編輯場景:

  • 刪除相機---程式碼中已經刪除了攝像機,這裡也不需要了;
  • 刪除背景圖---AR中不需要背景圖片;
  • 新增白色的環境光---AR中可以移動手機,看到方塊後面,所以需要把後面也照亮
  • 底座改小一些---因為原來的尺寸:(1,0.2,1)意味著長1米,高0.2米,寬1米.這對於AR場景來說實在太大了.
    WX20171015-103425@2x.png

WX20171015-103622@2x.png

WX20171015-125749@2x.png
WX20171015-125825@2x.png

下一步,修改程式碼中的方塊尺寸,運動速度,完美對齊的匹配精度等 在檔案開頭定義一些全域性常量,方便我們修改

let boxheight:CGFloat = 0.05 //原來為0.2
let boxLengthWidth:CGFloat = 0.4 //原來為1
let actionOffet:Float = 0.6 //原來為1.25
let actionSpeed:Float = 0.011 //原來為0.3
複製程式碼

難度不大,但要修改的地方比較多,認真一些就可以了.

最後,發現方塊的顏色不會改變,所以修改一下顏色,將原來各個節點的:

//以brokenBoxNode為例,其餘類似
brokenBoxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
複製程式碼

改為:

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)
複製程式碼

這樣顏色差異更明顯一些.另外新出現的方塊顏色總是和上一個相同,放上去後才改變顏色,是因為建立newNode時利用了原來方塊的geometry導致的. 需要修改addNewBlock方法:

  func addNewBlock(_ currentBoxNode: SCNNode) {
// 此處直接新建一個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)"
// 此處顏色改為height+1層
    newBoxNode.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)
  }
複製程式碼

另外handleTap方法中也需要另外設定顏色,否則放置好的方塊會沒有顏色,會變白色.

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)
複製程式碼

執行一下,可以看到場景中的物體變小了,攝像機也可以隨便移動了,顏色改變了,後面也有光照了...

step2.gif

step3:合併兩個專案,完成AR版Stack堆方塊遊戲

首先,在ARStack中新增ScoreLabel和點選手勢

WX20171015-143008@2x.png

然後第2步專案中複製.scn素材,音訊檔案,還有一個分類到第1步專案.

新增一個屬性,代表遊戲節點:

var gameNode:SCNNode?
複製程式碼

複製進入遊戲的程式碼過來,在playButtonClick方法中4.後面繼續寫:

//4.載入遊戲場景
        
        gameNode?.removeFromParentNode()//移除前一次遊戲的場景節點
        gameNode = SCNNode()
        let gameChildNodes = SCNScene(named: "art.scnassets/Scenes/GameScene.scn")!.rootNode.childNodes
        for node in gameChildNodes {
            gameNode?.addChildNode(node)
        }
        baseNode?.addChildNode(gameNode!)

        resetGameData() //重置遊戲資料
        
// 複製過來的程式碼.....
複製程式碼

複製其他程式碼,注意音訊檔案地址改為art.scnassets. 其餘各處的scnView.rootNode.addChildNode()改為gameNode?.addChildNode(boxNode)

然後,resetAll() 方法中需要重置遊戲的引數,並將resetGameData方法抽出:

private func resetAll() {
        //0.顯示按鈕
        playButton.isHidden = true
        sessionInfoLabel.isHidden = false
        //1.重置平面檢測配置,重啟檢測
        resetTracking()
        //2.重置更新次數
        updateCount = 0
        sceneView.debugOptions = [ARSCNDebugOptions.showFeaturePoints]
        //3.重置遊戲資料
        resetGameData()
        print("resetAll")
    }
    private func resetGameData() {
        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
    }
複製程式碼

並新增從後臺喚醒的監聽,當從後臺進入前臺時,也呼叫resetAll:

 NotificationCenter.default.addObserver(forName: NSNotification.Name.UIApplicationWillEnterForeground, object: nil, queue: nil) { (noti) in
            self.resetAll()
        }
複製程式碼

執行一下,效果出來了

step3.gif

雖然還是有很多問題,不過基本功能已經完成了.

step4:修復合併後的bug和邏輯錯誤

bug主要有兩個:

  • 沒對齊被切下的碎片掉落不正常,有些停留在原來位置,飄在空中;
  • 級數超過5後,自動下沉,但低於識別平面的部分仍然可見,造成視覺錯誤;
bug1:先來修復第一個bug,碎片掉落不正常的問題.

這是因為方塊的物理形體型別SCNPhysicsBodyType不正確導致的.原來的遊戲中,方塊放好後就不動了,所以設定為.static型別,這種型別在執行Action動作時位置並沒有真正移動,所以需要改為.kinematic型別,這種型別可以讓我們隨意移動,並可以與掉落的碎片碰撞,但自身不受碰撞的影響,一般用於電梯,傳送機等.

需要更改的地方包括GameScene.scn檔案中的底座,playButtonClick方法中的第一個方塊,handleTap方法中已對齊方塊,還有新生成的方塊方法addNewBlock

WX20171026-233416@2x.png

//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))
複製程式碼

再執行一次,碎片掉落,碰撞,都已經正常了.

bug2:下沉到時低於識別平面的方塊仍然可見

這個問題解決起來也很簡單:我們在下沉時遍歷各個節點,發現位置低於某個值,就把它隱藏掉;gomeOver後,再把所有節點顯示出來;

被隱藏的節點不再參與物理效果運算(比如碰撞等),看起來效果不錯.需要注意的是,燈光節點就不要隱藏了.

handleTap方法中,執行Action之前,新增程式碼,隱藏低於某個高度的節點

gameNode?.enumerateChildNodes({ (node, stop) in
         if node.light != nil {//燈光節點不隱藏
                        return
         }           
         if node.position.y < Float(self.height-5) * Float(boxheight) {
              node.isHidden = true
        }
 })
複製程式碼

gameOver方法的末尾,新增顯示節點的程式碼

gameNode?.enumerateChildNodes({ (node, stop) in
            
    node.isHidden = false
            
})
複製程式碼

最終版效果

final1.gif

final2.gif

各個步驟的專案程式碼已釋出在github上github.com/XanderXu/AR…

相關文章