最近微信推出的小程序“跳一跳”真的火爆全国,做为开发者看到之后,不由想到:能不能把它和 ARKit 结合一下,在 AR 的场景下玩一玩呢?因而就有了这个 idea。借着以前的经验,也就有了如今的这个 demo:ARBottleJump。下面就来简单介绍一下如何作出这样的一个小游戏。node
首先,咱们要对 SceneKit 和 ARKit 有必定的基础了解。对于 SceneKit,你至少要知道:SCNNode、 SCNGeometry、SCNAction、SCNVector3 等最基础的类和他们的经常使用属性、方法(能够参见 Apple 文档)。若是对 ARKit 还不太熟悉,那么能够看看我以前写的一片文章:ARKit 初探。git
当你准备好了,就让咱们进入正题吧!github
我把作这个小游戏的步骤分为如下几个子步骤:小程序
咱们知道,在 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()
,最后再生成一个瓶子要跳去的方块。
第二个生成方块的地方是在棋子成功落在下一个方块时,具体会在后文说明。
前面提到,咱们要覆写 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.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 轴上的差值的绝对值,只要有任何一个大于方块宽度的一半,就认为瓶子落在了方块范围之外,示意图以下(红色表明瓶子中心点):
固然,若是力求简洁,那么能够把方块都变成圆柱,这样就只须要判断二者中心点的距离和圆柱横截面半径大小之间的关系就好了。
因而,大致的游戏流程就都完成了。首先是生成方块,而后根据按压时间长短来让瓶子进行运动,而且在运动完成后判断游戏有没有失败,这样就造成了游戏逻辑的闭环。
因为时间很仓促,在不少地方都作了一点小小的偷懒。好比:
那么在将来能够有哪些改进的地方呢?
首先,坐标轴的方向最好能够改变,好比每次均以用户当前手机面向的位置为 x 轴。
其次,在动画效果、美观程度和声音效果上能够作一些改进或加强。
最后,若是能够打破二维平面上的模式,甚至跟现实世界的物体结合来跳一跳,就更完美啦。
项目以 GPL v3.0 开源在 GitHub 下:ARBottleJump,欢迎 Star / PR / Issue!
另外感谢该游戏的原始版本:欢乐跳瓶,他们家 Ketchapp 真的开发了不少有趣的小游戏。
2018-01-04