用 ARKit 作一個仿微信"跳一跳"遊戲

0. 前言

最近微信推出的小程序「跳一跳」真的火爆全國,做爲開發者看到之後,不由想到:能不能把它和 ARKit 結合一下,在 AR 的場景下玩一玩呢?因而就有了這個 idea。藉着以前的經驗,也就有了如今的這個 demo:ARBottleJump。下面就來簡單介紹一下如何作出這樣的一個小遊戲。node

1. 預備知識

首先,咱們要對 SceneKit 和 ARKit 有必定的基礎瞭解。對於 SceneKit,你至少要知道:SCNNode、 SCNGeometry、SCNAction、SCNVector3 等最基礎的類和他們的經常使用屬性、方法(能夠參見 Apple 文檔)。若是對 ARKit 還不太熟悉,那麼能夠看看我以前寫的一片文章:ARKit 初探git

當你準備好了,就讓咱們進入正題吧!github

2. 總體思路

我把作這個小遊戲的步驟分爲如下幾個子步驟:小程序

  1. 放置方塊
  2. 讓瓶子跳
  3. 判斷遊戲失敗

2.1 放置方塊

咱們知道,在 ARKit 中對於現實世界有一個三維座標系。而經過觀察微信的「跳一跳」,能夠發現下一個方塊放置的位置要麼是當前方塊的左邊,要麼是右邊。出於簡化的目的,咱們就讓方塊都放在該座標系的 XZ 平面上,而且每次隨機決定是往 x 仍是 z 軸方向延展。示意圖以下:數組

其中藍色都表明依次生成的方塊,能夠看出它們的生成路徑(紅色箭頭)都是平行於 x 或 z 軸的。bash

首先,創建一個新枚舉類,列舉下一個方塊可能的方向:微信

// 隨機方向枚舉
enum NextDirection: Int {
    case left       = 0
    case right      = 1
}
複製代碼

而後聲明一個數組,記錄全部的已經出現的方塊:閉包

private var boxNodes: [SCNNode] = []
複製代碼

最後是生成方塊的方法:app

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)
}
複製代碼

經過以上方法,就能夠在遊戲中生成方塊。那麼,這個方法什麼時候調用呢?dom

第一個是在開始遊戲時。咱們經過點擊的方式,決定在哪裏開始遊戲。 這裏咱們 override 了 touchesBegan(_:_:) 這個方法(其實還有 touchesEnd(_:_:) ),具體爲何會在後文解釋。

override func touchesBegan(_ touches: Set<UITouch>, 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<UITouch>, with event: UIEvent?) {
    ...
    if boxNodes.isEmpty  {
        同 2.1 中代碼
    } else {
        // 遊戲進行中,按壓屏幕,記錄開始時間
        touchTimePair.begin = (event?.timestamp)!
    }
}
複製代碼

按壓結束時,不只記錄告終束時間、計算時間差,也根據時間差來對瓶子進行移動:

override func touchesEnded(_ touches: Set<UITouch>, 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 軸。

其次,在動畫效果、美觀程度和聲音效果上能夠作一些改進或加強。

最後,若是能夠打破二維平面上的模式,甚至跟現實世界的物體結合來跳一跳,就更完美啦。

4. 其餘

項目以 GPL v3.0 開源在 GitHub 下:ARBottleJump,歡迎 Star / PR / Issue!

另外感謝該遊戲的原始版本:歡樂跳瓶,他們家 Ketchapp 真的開發了不少有趣的小遊戲。

GitHub:songkuixi

微博:滑滑雞

2018-01-04

相關文章
相關標籤/搜索