用 ARKit 做一個仿微信"跳一跳"游戲
0. 前言
最近微信推出的小程序“跳一跳”真的火爆全國,作為開發者看到以后,不禁想到:能不能把它和 ARKit 結合一下,在 AR 的場景下玩一玩呢?于是就有了這個 idea。借著之前的經驗,也就有了現在的這個demo: ARBottleJump 。下面就來簡單介紹一下如何做出這樣的一個小游戲。
1. 預備知識
首先,我們要對 SceneKit 和 ARKit 有一定的基礎了解。對于 SceneKit,你至少要知道:SCNNode、 SCNGeometry、SCNAction、SCNVector3 等最基礎的類和他們的常用屬性、方法(可以參見 Apple 文檔 )。如果對 ARKit 還不太熟悉,那么可以看看我之前寫的一片文章: ARKit 初探 。
當你準備好了,就讓我們進入正題吧!
2. 整體思路
我把做這個小游戲的步驟分為以下幾個子步驟:
放置方塊
讓瓶子跳
判斷游戲失敗
2.1 放置方塊
我們知道,在 ARKit 中對于現實世界有一個三維坐標系。而通過觀察微信的“跳一跳”,可以發現下一個方塊放置的位置要么是當前方塊的左邊,要么是右邊。出于簡化的目的,我們就讓方塊都放在該坐標系的 XZ 平面上,并且每次隨機決定是往 x 還是 z 軸方向延展。示意圖如下:
其中藍色都代表依次生成的方塊,可以看出它們的生成路徑(紅色箭頭)都是平行于 x 或 z 軸的。
首先,建立一個新枚舉類,列舉下一個方塊可能的方向:
// 隨機方向枚舉 enum NextDirection: Int { case left = 0 case right = 1 }
然后聲明一個數組,記錄所有的已經出現的方塊:
private var boxNodes: [SCNNode] = []
最后是生成方塊的方法:
private func generateBox(at realPosition: SCNVector3) { // 生成一個方塊 let box = SCNBox(width: kBoxWidth, height: kBoxWidth / 2.0, length: kBoxWidth, chamferRadius: 0.0) let node = SCNNode(geometry: box) // 給方塊上色 let material = SCNMaterial() material.diffuse.contents = UIColor.randomColor() box.materials = [material] // 如果方塊數量為空,說明在初始化游戲,直接把方塊位置放在你點擊的位置 if boxNodes.isEmpty { node.position = realPosition } else { // 如果不為空,那么說明游戲正在進行中 // 先隨機生成一個方向 nextDirection = NextDirection(rawValue: Int(arc4random() % 2))! // 根據隨機數算出它和當前方塊有多少距離 let deltaDistance = Double(arc4random() % 25 + 25) / 100.0 // 范圍: 0.25 ~ 0.5 // 根據是左(x 軸)還是右(z 軸),決定下一個方塊的位置 if nextDirection == .left { node.position = SCNVector3(realPosition.x + Float(deltaDistance), realPosition.y, realPosition.z) } else { node.position = SCNVector3(realPosition.x, realPosition.y, realPosition.z + Float(deltaDistance)) } } // 加入子節點,并添加進方塊數組 sceneView.scene.rootNode.addChildNode(node) boxNodes.append(node) }
通過以上方法,就可以在游戲中生成方塊。那么,這個方法何時調用呢?
第一個是在開始游戲時。我們通過點擊的方式,決定在哪里開始游戲。
這里我們 override 了 touchesBegan(_:_:) 這個方法(其實還有 touchesEnd(_:_:) ),具體為什么會在后文解釋。
override func touchesBegan(_ touches: Set, with event: UIEvent?) { ... // 添加瓶子 func addConeNode() { bottleNode.position = SCNVector3(boxNodes.last!.position.x, boxNodes.last!.position.y + Float(kBoxWidth) * 0.75, boxNodes.last!.position.z) sceneView.scene.rootNode.addChildNode(bottleNode) } // 點擊測試,有沒有獲得一個特征點的三維坐標? func anyPositionFrom(location: CGPoint) -> (SCNVector3)? { let results = sceneView.hitTest(location, types: .featurePoint) guard !results.isEmpty else { return nil } return SCNVector3.positionFromTransform(results[0].worldTransform) } let location = touches.first?.location(in: sceneView) if let position = anyPositionFrom(location: location!) { generateBox(at: position) addConeNode() generateBox(at: boxNodes.last!.position) } ... }
其實最大的利用 ARKit 的地方應該就是在這里的 anyPositionFrom(_:) 方法。在這里利用點擊測試 hitTest(_:_:),決定有沒有點觸到屏幕上任意一個特征點。如果有的話,那么就利用一個對 SCNVector3 的擴展,把取得的現實世界的坐標轉換成虛擬世界的坐標。接下來的各種操作,就都轉換成虛擬世界的坐標系啦。
可以看出,當點擊的位置可以成功通過點擊測試方法獲得至少一個位置時,這個位置就是我們要生成/開始游戲的地方。接著先調用一次 generateBox(_:) 在這個位置生成一個方塊,然后在這個方塊上加上棋子 addConeNode(),最后再生成一個瓶子要跳去的方塊。
第二個生成方塊的地方是在棋子成功落在下一個方塊時,具體會在后文說明。
2.2 讓瓶子跳
前面提到,我們要覆寫 touchesBegan(_:_:) 和 touchesEnd(_:_:)。
在“跳一跳”中,決定瓶子能飛多遠的因素是按壓屏幕的時間。通過這兩個方法,一個開始一個結束,就可以獲得開始按壓和結束按壓的時間,再作差就可以輕松獲得一次按壓的時間長度。再通過這個長度進行一些函數計算,就可以獲得下一次要運動的距離。于是,很多關鍵邏輯就都可以放在這兩個方法里。
首先,聲明一個 tuple,記錄按壓屏幕的起始和終止時間:
private var touchTimePair: (begin: TimeInterval, end: TimeInterval) = (0, 0)
然后,聲明一個閉包,用來通過時間差計算運動距離,這里我們簡單地進行一個除法運算:
private let distanceCalculateClosure: (TimeInterval) -> CGFloat = { return CGFloat($0) / 4.0 }
下面是這兩個方法。按壓開始時:
override func touchesBegan(_ touches: Set, with event: UIEvent?) { ... if boxNodes.isEmpty { 同 2.1 中代碼 } else { // 游戲進行中,按壓屏幕,記錄開始時間 touchTimePair.begin = (event?.timestamp)! } }
按壓結束時,不僅記錄了結束時間、計算時間差,也根據時間差來對瓶子進行移動:
override func touchesEnded(_ touches: Set, with event: UIEvent?) { ... // 記錄結束時間 touchTime{Pair.end = (event?.timestamp)! // 計算兩者時間差 let distance = distanceCalculateClosure(touchTimePair.end - touchTimePair.begin) // 根據兩種方向,決定移動的方向 var actions = [SCNAction()] if nextDirection == .left { let moveAction1 = SCNAction.moveBy(x: distance, y: kJumpHeight, z: 0, duration: kMoveDuration) let moveAction2 = SCNAction.moveBy(x: distance, y: -kJumpHeight, z: 0, duration: kMoveDuration) actions = [SCNAction.rotateBy(x: 0, y: 0, z: -.pi * 2, duration: kMoveDuration * 2), SCNAction.sequence([moveAction1, moveAction2])] } else { let moveAction1 = SCNAction.moveBy(x: 0, y: kJumpHeight, z: distance, duration: kMoveDuration) let moveAction2 = SCNAction.moveBy(x: 0, y: -kJumpHeight, z: distance, duration: kMoveDuration) actions = [SCNAction.rotateBy(x: .pi * 2, y: 0, z: 0, duration: kMoveDuration * 2), SCNAction.sequence([moveAction1, moveAction2])] } ...
為了模仿微信跳一跳的動畫效果,利用了 SCNAction 的 group 和 sequence 方法。其中 group 指的是兩個動作并行進行,sequence 則是兩個動作連續進行。所以最終疊加的效果是這樣的:
緊接著上面的代碼,我們對瓶子進行運動,并且在它運動結束之后,進行游戲有沒有失敗的判斷。
同樣,也就是在這里,進行下一個方塊的生成。
bottleNode.runAction(SCNAction.group(actions), completionHandler: { [weak self] // 獲得當前最后一個方塊,也就是這個瓶子要跳過去的方塊 let boxNode = (self?.boxNodes.last!)! // 如果這個方塊沒包含了瓶子,那么游戲失敗 if (self?.bottleNode.isNotContainedXZ(in: boxNode))! { // 記錄高分、提示失敗等 } else { // 如果包含,那么游戲繼續,生成下一個方塊 ... generateBox(at: (self?.boxNodes.last!.position)!) } }) }
2.3 判斷游戲失敗
由于我們的方塊和瓶子都是沿著坐標軸或其平行線運動的,所以 2.2 節中提到的 isNotContainedXZ(in:) 方法可以這樣描述:
func isNotContainedXZ(in boxNode: SCNNode) -> Bool { let box = boxNode.geometry as! SCNBox let width = Float(box.width) if fabs(position.x - boxNode.position.x) > width / 2.0 { return true } if fabs(position.z - boxNode.position.z) > width / 2.0 { return true } return false }
具體含義就是比較方塊和瓶子的中心點在 x 軸和 z 軸上的差值的絕對值,只要有任何一個大于方塊寬度的一半,就認為瓶子落在了方塊范圍以外,示意圖如下(紅色代表瓶子中心點):
當然,如果力求簡潔,那么可以把方塊都變成圓柱,這樣就只需要判斷兩者中心點的距離和圓柱橫截面半徑大小之間的關系就行了。
于是,大體的游戲流程就都完成了。首先是生成方塊,然后根據按壓時間長短來讓瓶子進行運動,并且在運動完成后判斷游戲有沒有失敗,這樣就形成了游戲邏輯的閉環。
3. 小小的偷懶和可以優化之處
由于時間很倉促,在很多地方都做了一點小小的偷懶。比如:
-
在 ARKit 初始化時,三維坐標系的方向就確定了。所以在整個游戲中,x 軸和 z 軸的方向不能改變。
-
生成方塊的形狀單一,不像微信還有圓柱、圓臺等等。
-
界面有點丑(畢竟用的都是原生 SCNGeometry)
那么在未來可以有哪些改進的地方呢?
首先,坐標軸的方向最好可以改變,比如每次均以用戶當前手機面向的位置為 x 軸。
其次,在動畫效果、美觀程度和聲音效果上可以做一些改進或增強。
最后,如果可以打破二維平面上的模式,甚至跟現實世界的物體結合來跳一跳,就更完美啦。
來自:http://www.cocoachina.com/ios/20180104/21756.html