用 ARKit 做一個仿微信”跳一跳”遊戲

songkuixi發表於2018-01-04

0. 前言

最近微信推出的小程式“跳一跳”真的火爆全國,作為開發者看到以後,不禁想到:能不能把它和 ARKit 結合一下,在 AR 的場景下玩一玩呢?於是就有了這個 idea。藉著之前的經驗,也就有了現在的這個 demo:ARBottleJump。下面就來簡單介紹一下如何做出這樣的一個小遊戲。

1. 預備知識

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

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

2. 整體思路

我把做這個小遊戲的步驟分為以下幾個子步驟:

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

2.1 放置方塊

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

用 ARKit 做一個仿微信”跳一跳”遊戲

其中藍色都代表依次生成的方塊,可以看出它們的生成路徑(紅色箭頭)都是平行於 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<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 則是兩個動作連續進行。所以最終疊加的效果是這樣的:

用 ARKit 做一個仿微信”跳一跳”遊戲

緊接著上面的程式碼,我們對瓶子進行運動,並且在它運動結束之後,進行遊戲有沒有失敗的判斷。
同樣,也就是在這裡,進行下一個方塊的生成。

    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 軸上的差值的絕對值,只要有任何一個大於方塊寬度的一半,就認為瓶子落在了方塊範圍以外,示意圖如下(紅色代表瓶子中心點):

用 ARKit 做一個仿微信”跳一跳”遊戲

當然,如果力求簡潔,那麼可以把方塊都變成圓柱,這樣就只需要判斷兩者中心點的距離和圓柱橫截面半徑大小之間的關係就行了。

於是,大體的遊戲流程就都完成了。首先是生成方塊,然後根據按壓時間長短來讓瓶子進行運動,並且在運動完成後判斷遊戲有沒有失敗,這樣就形成了遊戲邏輯的閉環。

3. 小小的偷懶和可以優化之處

由於時間很倉促,在很多地方都做了一點小小的偷懶。比如:

  • 在 ARKit 初始化時,三維座標系的方向就確定了。所以在整個遊戲中,x 軸和 z 軸的方向不能改變。
  • 生成方塊的形狀單一,不像微信還有圓柱、圓臺等等。
  • 介面有點醜(畢竟用的都是原生 SCNGeometry)

那麼在未來可以有哪些改進的地方呢?

首先,座標軸的方向最好可以改變,比如每次均以使用者當前手機面向的位置為 x 軸。

其次,在動畫效果、美觀程度和聲音效果上可以做一些改進或增強。

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

4. 其他

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

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

GitHub:songkuixi

微博:滑滑雞

2018-01-04

相關文章