admin 发表于 2018-1-9 23:13:34

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

<div><div><h1 class="heading" data-id="heading-0">0. 前言</h1>
<p>最近微信推出的小程序“跳一跳”真的火爆全国,作为开发者看到以后,不禁想到:能不能把它和 ARKit 结合一下,在 AR 的场景下玩一玩呢?于是就有了这个 idea。借着之前的经验,也就有了现在的这个demo:<a target="_blank" href="https://link.juejin.im?target=https%3A%2F%2Fgithub.com%2Fsongkuixi%2FARBottleJump" rel="nofollow noopener noreferrer">ARBottleJump</a>。下面就来简单介绍一下如何做出这样的一个小游戏。</p>
<h1 class="heading" data-id="heading-1">1. 预备知识</h1>
<p>首先,我们要对 SceneKit 和 ARKit 有一定的基础了解。对于 SceneKit,你至少要知道:SCNNode、 SCNGeometry、SCNAction、SCNVector3 等最基础的类和他们的常用属性、方法(可以参见 <a target="_blank" href="https://link.juejin.im?target=https%3A%2F%2Fdeveloper.apple.com%2Fdocumentation%2Fscenekit" rel="nofollow noopener noreferrer">Apple 文档</a>)。如果对 ARKit 还不太熟悉,那么可以看看我之前写的一片文章:<a target="_blank" href="https://link.juejin.im?target=https%3A%2F%2Fjuejin.im%2Fpost%2F5a308ba96fb9a0450167f097" rel="nofollow noopener noreferrer">ARKit 初探</a>。</p>
<p>当你准备好了,就让我们进入正题吧!</p>
<h1 class="heading" data-id="heading-2">2. 整体思路</h1>
<p>我把做这个小游戏的步骤分为以下几个子步骤:</p>
<ol>
<li>放置方块</li>
<li>让瓶子跳</li>
<li>判断游戏失败</li>
</ol>
<h2 class="heading" data-id="heading-3">2.1 放置方块</h2>
<p>我们知道,在 ARKit 中对于现实世界有一个三维坐标系。而通过观察微信的“跳一跳”,可以发现下一个方块放置的位置要么是当前方块的左边,要么是右边。出于简化的目的,我们就让方块都放在该坐标系的 XZ 平面上,并且每次随机决定是往 x 还是 z 轴方向延展。示意图如下:</p>
<p></p><figure><img class="lazyload inited loaded" data-src="https://user-gold-cdn.xitu.io/2018/1/4/160c029921c1d753?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" data-width="1160" data-height="954" src="https://user-gold-cdn.xitu.io/2018/1/4/160c029921c1d753?imageView2/0/w/1280/h/960/format/webp/ignore-error/1"><figcaption></figcaption></figure><p></p>
<p>其中蓝色都代表依次生成的方块,可以看出它们的生成路径(红色箭头)都是平行于 x 或 z 轴的。</p>
<p>首先,建立一个新枚举类,列举下一个方块可能的方向:</p>
<pre><code class="hljs bash" lang="bash">// 随机方向枚举
enum NextDirection: Int {
    <span class="hljs-keyword">case</span> left       = 0
    <span class="hljs-keyword">case</span> right      = 1
}
</code></pre><p>然后声明一个数组,记录所有的已经出现的方块:</p>
<pre><code class="hljs bash" lang="bash">private var boxNodes: = []
</code></pre><p>最后是生成方块的方法:</p>
<pre><code class="hljs bash" lang="bash">private func generateBox(at realPosition: SCNVector3) {
    // 生成一个方块
    <span class="hljs-built_in">let</span> box = SCNBox(width: kBoxWidth, height: kBoxWidth / 2.0, length: kBoxWidth, chamferRadius: 0.0)
    <span class="hljs-built_in">let</span> node = SCNNode(geometry: box)
    // 给方块上色
    <span class="hljs-built_in">let</span> material = SCNMaterial()
    material.diffuse.contents = UIColor.randomColor()
    box.materials =
   
    // 如果方块数量为空,说明在初始化游戏,直接把方块位置放在你点击的位置
    <span class="hljs-keyword">if</span> boxNodes.isEmpty {
      node.position = realPosition
    } <span class="hljs-keyword">else</span> {
      // 如果不为空,那么说明游戏正在进行中
      // 先随机生成一个方向
      nextDirection = NextDirection(rawValue: Int(arc4random() % 2))!
      
      // 根据随机数算出它和当前方块有多少距离
      <span class="hljs-built_in">let</span> deltaDistance = Double(arc4random() % 25 + 25) / 100.0// 范围: 0.25 ~ 0.5
      
      // 根据是左(x 轴)还是右(z 轴),决定下一个方块的位置
      <span class="hljs-keyword">if</span> nextDirection == .left {
            node.position = SCNVector3(realPosition.x + Float(deltaDistance), realPosition.y, realPosition.z)
      } <span class="hljs-keyword">else</span> {
            node.position = SCNVector3(realPosition.x, realPosition.y, realPosition.z + Float(deltaDistance))
      }
    }
   
    // 加入子节点,并添加进方块数组
    sceneView.scene.rootNode.addChildNode(node)
    boxNodes.append(node)
}
</code></pre><p>通过以上方法,就可以在游戏中生成方块。那么,这个方法何时调用呢?</p>
<p>第一个是在开始游戏时。我们通过点击的方式,决定在哪里开始游戏。
这里我们 override 了 <code>touchesBegan(_:_:)</code> 这个方法(其实还有 <code>touchesEnd(_:_:)</code> ),具体为什么会在后文解释。</p>
<pre><code class="hljs bash" lang="bash">override func touchesBegan(_ touches: Set&lt;UITouch&gt;, with event: UIEvent?) {
    ...
    // 添加瓶子
    func <span class="hljs-function"><span class="hljs-title">addConeNode</span></span>() {
      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) -&gt; (SCNVector3)? {
      <span class="hljs-built_in">let</span> results = sceneView.hitTest(location, types: .featurePoint)
      guard !results.isEmpty <span class="hljs-keyword">else</span> {
            <span class="hljs-built_in">return</span> nil
      }
      <span class="hljs-built_in">return</span> SCNVector3.positionFromTransform(results.worldTransform)
    }
   
    <span class="hljs-built_in">let</span> location = touches.first?.location(<span class="hljs-keyword">in</span>: sceneView)
    <span class="hljs-keyword">if</span> <span class="hljs-built_in">let</span> position = anyPositionFrom(location: location!) {
      generateBox(at: position)
      addConeNode()
      generateBox(at: boxNodes.last!.position)
    }
    ...
}
</code></pre><p>其实最大的利用 ARKit 的地方应该就是在这里的 <code>anyPositionFrom(_:)</code> 方法。在这里利用点击测试 <code>hitTest(_:_:)</code>,决定有没有点触到屏幕上任意一个特征点。如果有的话,那么就利用一个对 SCNVector3 的扩展,把取得的现实世界的坐标转换成虚拟世界的坐标。接下来的各种操作,就都转换成虚拟世界的坐标系啦。</p>
<p>可以看出,当点击的位置可以成功通过点击测试方法获得至少一个位置时,这个位置就是我们要生成/开始游戏的地方。接着先调用一次 <code>generateBox(_:)</code> 在这个位置生成一个方块,然后在这个方块上加上棋子 <code>addConeNode()</code>,最后再生成一个瓶子要跳去的方块。</p>
<p>第二个生成方块的地方是在棋子成功落在下一个方块时,具体会在后文说明。</p>
<h1 class="heading" data-id="heading-4">2.2 让瓶子跳</h1>
<p>前面提到,我们要覆写 <code>touchesBegan(_:_:)</code> 和 <code>touchesEnd(_:_:)</code>。
在“跳一跳”中,决定瓶子能飞多远的因素是按压屏幕的时间。通过这两个方法,一个开始一个结束,就可以获得开始按压和结束按压的时间,再作差就可以轻松获得一次按压的时间长度。再通过这个长度进行一些函数计算,就可以获得下一次要运动的距离。于是,很多关键逻辑就都可以放在这两个方法里。</p>
<p>首先,声明一个 tuple,记录按压屏幕的起始和终止时间:</p>
<pre><code class="hljs bash" lang="bash">private var touchTimePair: (begin: TimeInterval, end: TimeInterval) = (0, 0)
</code></pre><p>然后,声明一个闭包,用来通过时间差计算运动距离,这里我们简单地进行一个除法运算:</p>
<pre><code class="hljs bash" lang="bash">private <span class="hljs-built_in">let</span> distanceCalculateClosure: (TimeInterval) -&gt; CGFloat = {
    <span class="hljs-built_in">return</span> CGFloat(<span class="hljs-variable">$0</span>) / 4.0
}
</code></pre><p>下面是这两个方法。按压开始时:</p>
<pre><code class="hljs bash" lang="bash">override func touchesBegan(_ touches: Set&lt;UITouch&gt;, with event: UIEvent?) {
    ...
    <span class="hljs-keyword">if</span> boxNodes.isEmpty{
      同 2.1 中代码
    } <span class="hljs-keyword">else</span> {
      // 游戏进行中,按压屏幕,记录开始时间
      touchTimePair.begin = (event?.timestamp)!
    }
}
</code></pre><p>按压结束时,不仅记录了结束时间、计算时间差,也根据时间差来对瓶子进行移动:</p>
<pre><code class="hljs bash" lang="bash">override func touchesEnded(_ touches: Set&lt;UITouch&gt;, with event: UIEvent?) {
    ...
    // 记录结束时间
    touchTime{Pair.end = (event?.timestamp)!
   
    // 计算两者时间差
    <span class="hljs-built_in">let</span> distance = distanceCalculateClosure(touchTimePair.end - touchTimePair.begin)
   
    // 根据两种方向,决定移动的方向
    var actions =
    <span class="hljs-keyword">if</span> nextDirection == .left {
      <span class="hljs-built_in">let</span> moveAction1 = SCNAction.moveBy(x: distance, y: kJumpHeight, z: 0, duration: kMoveDuration)
      <span class="hljs-built_in">let</span> 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()]
    } <span class="hljs-keyword">else</span> {
      <span class="hljs-built_in">let</span> moveAction1 = SCNAction.moveBy(x: 0, y: kJumpHeight, z: distance, duration: kMoveDuration)
      <span class="hljs-built_in">let</span> 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()]
    }
    ...
</code></pre><p>为了模仿微信跳一跳的动画效果,利用了 SCNAction 的 group 和 sequence 方法。其中 group 指的是两个动作并行进行,sequence 则是两个动作连续进行。所以最终叠加的效果是这样的:
</p><figure><img class="lazyload inited loaded" data-src="https://user-gold-cdn.xitu.io/2018/1/4/160c00a34d73401b?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" data-width="792" data-height="888" src="https://user-gold-cdn.xitu.io/2018/1/4/160c00a34d73401b?imageView2/0/w/1280/h/960/format/webp/ignore-error/1"><figcaption></figcaption></figure><p></p>
<p>紧接着上面的代码,我们对瓶子进行运动,并且在它运动结束之后,进行游戏有没有失败的判断。
同样,也就是在这里,进行下一个方块的生成。</p>
<pre><code class="hljs bash" lang="bash">    bottleNode.runAction(SCNAction.group(actions), completionHandler: {
      // 获得当前最后一个方块,也就是这个瓶子要跳过去的方块
      <span class="hljs-built_in">let</span> boxNode = (self?.boxNodes.last!)!
      
      // 如果这个方块没包含了瓶子,那么游戏失败
      <span class="hljs-keyword">if</span> (self?.bottleNode.isNotContainedXZ(<span class="hljs-keyword">in</span>: boxNode))! {
            // 记录高分、提示失败等
      } <span class="hljs-keyword">else</span> {
            // 如果包含,那么游戏继续,生成下一个方块
            ...
            generateBox(at: (self?.boxNodes.last!.position)!)
      }
    })
}
</code></pre><h1 class="heading" data-id="heading-5">2.3 判断游戏失败</h1>
<p>由于我们的方块和瓶子都是沿着坐标轴或其平行线运动的,所以 2.2 节中提到的 <code>isNotContainedXZ(in:)</code> 方法可以这样描述:</p>
<pre><code class="hljs bash" lang="bash">func isNotContainedXZ(<span class="hljs-keyword">in</span> boxNode: SCNNode) -&gt; Bool {
    <span class="hljs-built_in">let</span> box = boxNode.geometry as! SCNBox
    <span class="hljs-built_in">let</span> width = Float(box.width)
    <span class="hljs-keyword">if</span> fabs(position.x - boxNode.position.x) &gt; width / 2.0 {
      <span class="hljs-built_in">return</span> <span class="hljs-literal">true</span>
    }
    <span class="hljs-keyword">if</span> fabs(position.z - boxNode.position.z) &gt; width / 2.0 {
      <span class="hljs-built_in">return</span> <span class="hljs-literal">true</span>
    }
    <span class="hljs-built_in">return</span> <span class="hljs-literal">false</span>
}
</code></pre><p>具体含义就是比较方块和瓶子的中心点在 x 轴和 z 轴上的差值的绝对值,只要有任何一个大于方块宽度的一半,就认为瓶子落在了方块范围以外,示意图如下(红色代表瓶子中心点):</p>
<p></p><figure><img class="lazyload inited loaded" data-src="https://user-gold-cdn.xitu.io/2018/1/4/160c019fc917d546?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" data-width="846" data-height="846" src="https://user-gold-cdn.xitu.io/2018/1/4/160c019fc917d546?imageView2/0/w/1280/h/960/format/webp/ignore-error/1"><figcaption></figcaption></figure><p></p>
<p>当然,如果力求简洁,那么可以把方块都变成圆柱,这样就只需要判断两者中心点的距离和圆柱横截面半径大小之间的关系就行了。</p>
<p>于是,大体的游戏流程就都完成了。首先是生成方块,然后根据按压时间长短来让瓶子进行运动,并且在运动完成后判断游戏有没有失败,这样就形成了游戏逻辑的闭环。</p>
<h1 class="heading" data-id="heading-6">3. 小小的偷懒和可以优化之处</h1>
<p>由于时间很仓促,在很多地方都做了一点小小的偷懒。比如:</p>
<ul>
<li>在 ARKit 初始化时,三维坐标系的方向就确定了。所以在整个游戏中,x 轴和 z 轴的方向不能改变。</li>
<li>生成方块的形状单一,不像微信还有圆柱、圆台等等。</li>
<li>界面有点丑(毕竟用的都是原生 SCNGeometry)</li>
</ul>
<p>那么在未来可以有哪些改进的地方呢?</p>
<p>首先,坐标轴的方向最好可以改变,比如每次均以用户当前手机面向的位置为 x 轴。</p>
<p>其次,在动画效果、美观程度和声音效果上可以做一些改进或增强。</p>
<p>最后,如果可以打破二维平面上的模式,甚至跟现实世界的物体结合来跳一跳,就更完美啦。</p>
<h1 class="heading" data-id="heading-7">4. 其他</h1>
<p>项目以 GPL v3.0 开源在 GitHub 下:<a target="_blank" href="https://link.juejin.im?target=https%3A%2F%2Fgithub.com%2Fsongkuixi%2FARBottleJump" rel="nofollow noopener noreferrer">ARBottleJump</a>,欢迎 Star / PR / Issue!</p>
<p>另外感谢该游戏的原始版本:<a target="_blank" href="https://link.juejin.im?target=https%3A%2F%2Fitunes.apple.com%2Fcn%2Fapp%2F%25E6%25AC%25A2%25E4%25B9%2590%25E8%25B7%25B3%25E7%2593%25B6%2Fid1178454068%3Fmt%3D8" rel="nofollow noopener noreferrer">欢乐跳瓶</a>,他们家 <a target="_blank" href="https://link.juejin.im?target=https%3A%2F%2Fitunes.apple.com%2Fus%2Fdeveloper%2Fketchapp%2Fid528065807" rel="nofollow noopener noreferrer">Ketchapp</a> 真的开发了很多有趣的小游戏。</p>
<h1 class="heading" data-id="heading-8">GitHub:<a target="_blank" href="https://link.juejin.im?target=https%3A%2F%2Fgithub.com%2Fsongkuixi" rel="nofollow noopener noreferrer">songkuixi</a></h1>
<h1 class="heading" data-id="heading-9">微博:<a target="_blank" href="https://link.juejin.im?target=https%3A%2F%2Fweibo.com%2Fkrayc4" rel="nofollow noopener noreferrer">滑滑鸡</a></h1>
<p>2018-01-04</p></div><br>作者:songkuixi<br>链接:https://juejin.im/post/5a4dcb8d51882533f01efb61<br>来源:掘金<br>著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。</div><p></p>
页: [1]
查看完整版本: 用 ARKit 做一个仿微信"跳一跳"游戏