用 ARKit 做一个仿微信"跳一跳"游戏

6,355 阅读7分钟

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 轴方向延展。示意图如下:

其中蓝色都代表依次生成的方块,可以看出它们的生成路径(红色箭头)都是平行于 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 则是两个动作连续进行。所以最终叠加的效果是这样的:

紧接着上面的代码,我们对瓶子进行运动,并且在它运动结束之后,进行游戏有没有失败的判断。 同样,也就是在这里,进行下一个方块的生成。

    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