22-3D平衡球游戏Marble-Maze

文章选自掘金苹果API搬运工的文章[SceneKit专题]22-3D平衡球游戏Marble-Maze
主要记录自己在学习ARKit的过程中看到的好的文章,避免到时候链接失效无法找到原文的情况,非常感谢原博主的辛勤付出,也在此分享出来跟大家一起学习。

创建项目
  1. 打开Xcode,创建一个新的iOS版SceneKit游戏项目,命名为MarbleMaze.
  2. 删除art.scnassets文件夹.
  3. resources文件夹中拖拽一个新的art.scnassets到项目中.
  4. 我们只使用竖屏模式,所以取消Landscape LeftLandscape Right来禁用旋转:

替换GameViewController.swift中的内容:

import UIKit
import SceneKit
class GameViewController: UIViewController {
  var scnView:SCNView!
  override func viewDidLoad() {
    super.viewDidLoad()
// 1
    setupScene()
    setupNodes()
    setupSounds()
}
// 2
  func setupScene() {
      scnView = self.view as! SCNView
      scnView.delegate = self
      scnView.allowsCameraControl = true
      scnView.showsStatistics = true
}
  func setupNodes() {
  }
  func setupSounds() {
  }
  override var shouldAutorotate : Bool { return false }
  override var prefersStatusBarHidden : Bool { return true }
}
// 3
extension GameViewController: SCNSceneRendererDelegate {
  func renderer(_ renderer: SCNSceneRenderer,
    updateAtTime time: TimeInterval) {
  }
}

代码含义:

  1. viewDidLoad()中调用这些空的方法;稍后会向其中添加代码.
  2. self.view转换为SCNView并保存下来.并设置self为渲染循环的代理.
  3. 实现SCNSceneRendererDelegate协议中的方法.
天空盒子,加载场景

art.scnassets中找到空的game.scn场景文件.打开并选中默认的camera node,然后选中右上方的Scene Inspector.从右下方的媒体库中找到img_skybox.jpg拖拽到场景的背景属性上.:


GameViewController类中添加下面属性:

var scnScene:SCNScene!

setupScene()中添加下面代码:

// 1
scnScene = SCNScene(named: "art.scnassets/game.scn")
// 2
scnView.scene = scnScene

运行一下,看看神圣的天空景象:


主角--小球

拖拽一个空的SceneKit场景文件到你的项目,放到art.scnassets中,命名为obj_ball.scn:

选中art.scnassets/obj_ball.scn,展开场景树,选中默认的摄像机节点.所有的新建场景都包含一个默认的摄像机节点,但作为引用节点被使用时就很不爽,所以我们删除它:

下面开始创建木质小球.从对象库中拖拽一个球体到场景中:


打开节点检查器.将小球命名为ball,放置位置为(x:0, y:0, z:0):

现在的小球太大了.打开属性检查器,更改半径为0.45,提升分段数为36来让它显得更圆一些:

材质设置

漫反射设置


法线设置


高光设置


反射设置


发光设置


随着各个贴图的添加,效果渐变如下:


然后需要做的是将小球作为引用节点添加到场景中去. 选中art.scnassets/game.scn,然后拖拽art.scnassets/obj_ball.scn到场景中.设置位置为(x:0, y:0, z:0) 并命名为ball:

这样,小球就作为一个引用节点被添加到场景中了.

运行一下:


挑战--创建木箱,小石块,大石块,柱子的引用节点

这是一个小小的挑战:

  1. 为每个对象创建一个空的场景.
  2. 删除默认的摄像机. 试着创建下面的对象:


  • obj_crate1x1:命名为crate并设置尺寸为 (x:1, y:1, z:1).使用img_crate_diffuse纹理作为漫反射贴图,img_crate_normal作为法线贴图.高光颜色设为中灰色;如果设为纯白色,木箱会看起来像塑料的.
  • obj_stone1x1:命名为stone并设置尺寸为 (x:1, y:1, z:1).使用img_stone_diffuseimg_stone_normal纹理作为贴图,将法线intensity改为0.5. 设置高光色为White.
  • obj_stone3x3:命名为stone并设置尺寸为 (x:3, y:3, z:3).纹理设置同上,高光仍为White.但是需要使用纹理缩放设置,及WrapT和WrapS来使其生效.
  • obj_pillar1x3:命名为pillar并设置尺寸为 (x:1, y:3, z:1).使用img_pillar_ 纹理;还有高光纹理也要用上.还有应用缩放及wrap设置.

当设置3x3方块时,可参照下面步骤:


设置过程中,会看到如下的依次变化:


最终完成版在12-Reference Nodes中的projects/ challenge/MarbleMaze/ 文件夹.

组织场景

选中art.scnassets/game.scn.组织一下场景树如下:

创建一个空节点命名为follow_camera:

camera节点放到follow_camera下,成为它的子节点,并设置位置为 (x:0, y:0, z:5),旋转为 (x:0, y:0, z:0):

创建另一个空节点命名为follow_light:

添加几个空节点作为占位节点,设置位置为零;

  • pearls:待收集的珍珠分组.
  • section1, section2, section3, section4:这些分组用来盛放本关卡的不同章节.

创建最后一个空节点,命名为static_light:

灯光

首先是固定灯光 拖拽一个泛光灯和一个环境光到场景中,并按顺序放置在static_lights组节点中:

选中omni light,打开节点检查器,命名为omni,位置,角度设为零:

打开属性检查器,设置颜色为深灰色:


选中ambient light,打开节点检查器:

打开属性检查器,设置颜色为深灰:


查看一下场景中的小球:


接着添加跟随灯光 拖拽一个聚光灯到场景中,放置在follow_light组节点下面:

选中聚光灯,打开它的节点检查器,设置位置如下:


这个灯光是follow_light的子节点, follow_light的位置是 (x:0, y:0, z:0),旋转角度 (x:-25, y:-45, z:0);

然后选中聚光灯,打开属性检查器,设置金黄色模拟环境中的阳光:


完成后的效果:


重用集合体

将游戏中重复出现的结构做成重用集合体,方便在需要的时候直接调用. 此处我们制作的是休息点,它由一块3x3的石块和上面的4根柱子组成.

拖拽一个空的SceneKit场景文件到项目的根目录中,然后在弹出框中选择art.scnassets,点击Create按钮.

拖拽一个obj_stone3x3.scn的引用节点到空场景的,放置在(x: 0, y: 0, z:0).

拖拽一个obj_pillar1x3.scn引用节点到时大石块的顶部.设置位置在(x: -1, y: 3, z: 1),即右上角位置.

使用⌥⌘ (Option +Command) +点击拖拽,复制三个柱子,位置如下:

  • Top-Left. Positioned at (x: -1, y: 3, z: -1).
  • Top-Right. Positioned at (x: 1, y: 3, z: -1).
  • Bottom-Right. Positioned at (x: 1, y: 3, z: 1).


记得删除场景中默认的摄像机.

选中game.scn,然后拖放新创建的set_restpoint.scn到场景下方.位置设为 (x: 0, y: -2, z: 0)

运行一下,会看到漂亮的阴影:


创建其它部件

现在还需要创建几个其他的集合体,以便在主场景中直接引用. 比如straight_bridge,用了7个stone1x1组成:

zigzag_bridge,用了stone1x1crate1x1方块.共9格宽7格长.

然后就可以用这些来组成大场景:


从左下角开始,放置一个restpoint休息点在地平面下,(x:0, y:0, z:0) 处.然后将其他引用集合体拖拽到场景中. 注意将这些都放在section1下面,这是个游戏切换场景的小技巧:通过更改visible标记就能控制整个场景的显示与隐藏.

运行一下,移动摄像机看看,还可以旋转视角,查看更漂亮的美景:


拖拽一个空的SceneKit文件到项目中,命名为obj_pearl.scn,保存到art.scnassets文件夹:

接着从对象库中拖放一个球体节点到新场景中:


节点检查器中命名改为pearl,位置,角度为零. 属性检查器中,设置半径为0.2,分段数为16:

接下来打开材料检查器,设置漫反射颜色为黑色,高光为白色.反射贴图使用img_skybox.jpg,但将强度降为0.75:

完成后的效果图:


还需要添加游戏工具类

resources/ GameUtils/ 中拖拽GameUtils文件夹到项目中,如下图,点击Finish:

位掩码(包括分类掩码,碰撞掩码,接触掩码)

我们将采用如下的分类位掩码设置:


打开GameViewController.swift,在开头添加分类码:

let CollisionCategoryBall = 1
let CollisionCategoryStone = 2
let CollisionCategoryPillar = 4
let CollisionCategoryCrate = 8
let CollisionCategoryPearl = 16

游戏中,我们想让小球与除了能量珍珠外的所有物体碰撞,所以需要定义碰撞掩码,来决定和哪些物体碰撞:


Stone石头, Pillar柱子, Crate木箱和Pearl能量珍珠和碰撞掩码都是1,就是说它们能和分类掩码为1的物体碰撞,也就是都能和小球碰撞.而小球的碰撞掩码是14: CollisionMask = Stone + Pillar + Crate = 2 + 4 + 8 = 14

接触掩码决定了哪些物体碰撞时,代理方法会被调用.


我们只关心小球和能量珍珠,柱子及木箱的碰撞,所以: ContactMask = Pearl + Pillar + Crate = 16 + 8 + 4 = 28

GameViewController.swift中,添加一个属性:

var ballNode:SCNNode!

添加下列代码到setupNodes()中:

ballNode = scnScene.rootNode.childNode(withName: "ball", recursively:
true)!
ballNode.physicsBody?.contactTestBitMask = CollisionCategoryPillar |
CollisionCategoryCrate | CollisionCategoryPearl

启用物理效果

选中obj_ball.scn,然后选中ball节点,打开物理效果检查器来将Physics Body类型设置为Dynamic:

确保重力影响是打开的,不然小球可能会漂在空中:


设置Category mask1,Collision mask14:

ShapeDefault shape,TypeConvex:

除了小球,其它物体都是不动的,是静态物理形体.设置如下:


  • obj_stone1x1.scnCategory mask2, Collision mask1;
  • obj_stone3x3.scn: Category mask2, Collision mask1**.
  • obj_pillar1x3.scn: Category mask4,Collision mask1.
  • obj_crate1x1.scn: Category mask8, Collision mask1.
  • obj_pearl.scn: Category mask16, Collision mask-1.

对能量珍珠Physics shape设为Default shape, TypeConvex:

其余的Physics shape设为Default shape, TypeBounding Box:

添加碰撞检测处理

现在终于设置好了各个物体,要处理相互的碰撞了.在GameViewController.swift底部:

extension GameViewController : SCNPhysicsContactDelegate {
  func physicsWorld(_ world: SCNPhysicsWorld,
    didBegin contact: SCNPhysicsContact) {
    // 1
    var contactNode:SCNNode!
    if contact.nodeA.name == "ball" {
      contactNode = contact.nodeB
    } else {
      contactNode = contact.nodeA
    }
// 2
    if contactNode.physicsBody?.categoryBitMask ==
      CollisionCategoryPearl {
    contactNode.isHidden = true
      contactNode.runAction(
        SCNAction.waitForDurationThenRunBlock(
          duration: 30) { (node:SCNNode!) -> Void in
        node.isHidden = false
      })
}
// 3
    if contactNode.physicsBody?.categoryBitMask ==
      CollisionCategoryPillar ||
        contactNode.physicsBody?.categoryBitMask ==
          CollisionCategoryCrate {
} }
}

代码含义:

  1. 和前面一样,用来判断碰撞双方哪一个是小球.
  2. 如果碰撞到的是参量珍珠,则消失30秒,然后重新出现.
  3. 判断小球是碰撞到了柱子还是木箱,可以添加音效.

并在setupScene()底部添加成为代理:

scnScene.physicsWorld.contactDelegate = self

还需要再添加一些小效果让游戏更生动. 打开obj_ball.scn,选中ball,设置y轴位置为10让小球出现时有个掉落效果:

运行一下,可以看到掉落下来:


选中游戏场景,然后拖拽obj_pearl.scn到场景中.放置在(x: 0, y: 0, z: 0)处.放到pearls组下面:

运行一下,小球掉落并吸收了能量珍珠:


还可以给场景中添加更多的能量珍珠,如下:


辅助类和音效

在前面我们已经添加了GameUtils类,现在还需要再添加一些东西以便使用它.

GameViewController中添加下面的属性:

var game = GameHelper.sharedInstance
var motion = CoreMotionHelper()
var motionForce = SCNVector3(x:0 , y:0, z:0)

再从resources拖放Sounds文件夹到项目中:

setupSounds()中添加下面代码:

game.loadSound(name: "GameOver", fileNamed: "GameOver.wav")
game.loadSound(name: "Powerup", fileNamed: "Powerup.wav")
game.loadSound(name: "Reset", fileNamed: "Reset.wav")
game.loadSound(name: "Bump", fileNamed: "Bump.wav")

节点绑定和状态管理

GameViewController类中添加下面的属性:

var cameraNode:SCNNode!

setupNodes()的末尾,添加下列代码:

// 1
cameraNode = scnScene.rootNode.childNode(withName: "camera",
  recursively: true)!
// 2
let constraint = SCNLookAtConstraint(target: ballNode)
cameraNode.constraints = [constraint]

代码含义:

  1. 将游戏场景中的camera绑定到cameraNode.
  2. 给摄像机添加一个SCNLookAtConstraint约束,使其朝向ballNode.

当摄像机有SCNLookAtConstraint约束时,小球到处滚动,可能会导致摄像机向左或向右倾斜,所以我们需要在setupNodes()末尾打开万向节锁:

constraint.isGimbalLockEnabled = true

其它节点也需要同样处理.在GameViewController类中添加下列属性:

 var cameraFollowNode:SCNNode!
var lightFollowNode:SCNNode!

setupNodes()末尾添加下列代码:

// 1
cameraFollowNode = scnScene.rootNode.childNode(
  withName: "follow_camera", recursively: true)!
// 2
cameraNode.addChildNode(game.hudNode)
// 3
lightFollowNode = scnScene.rootNode.childNode(
  withName: "follow_light", recursively: true)!

游戏节点绑定完成,还需要处理游戏的状态.游戏需要三种基本状态:

  • waitForTap:游戏开始前的状态
  • playing:点击屏幕开始游戏的状态
  • gameOver:能量用光或者掉落下平台的状态.

GameViewController类中添加下列代码:

// 1
func playGame() {
  game.state = GameStateType.playing
  cameraFollowNode.eulerAngles.y = 0
  cameraFollowNode.position = SCNVector3Zero
}
// 2
func resetGame() {
  game.state = GameStateType.tapToPlay
  game.playSound(node: ballNode, name: "Reset")
  ballNode.physicsBody!.velocity = SCNVector3Zero
  ballNode.position = SCNVector3(x:0, y:10, z:0)
  cameraFollowNode.position = ballNode.position
  lightFollowNode.position = ballNode.position
  scnView.isPlaying = true
  game.reset()
}
// 3
func testForGameOver() {
  if ballNode.presentation.position.y < -5 {
    game.state = GameStateType.gameOver
    game.playSound(node: ballNode, name: "GameOver")
    ballNode.run(SCNAction.waitForDurationThenRunBlock(
      duration: 5) { (node:SCNNode!) -> Void in
        self.resetGame()
      })
} }

代码含义:

  1. 切换到.playing状态,开始游戏.以及基本的清理和重置.
  2. 切换到.waitForTap状态,播放音效,以及各种清理和重置工作.
  3. 检查小球的位置,y值小于-5,则切换到.gameOver状态,播放音效.5秒后自动调用resetGame(),并切换到.waitForTap状态.

还要在viewDidLoad()末尾添加调用:

resetGame()

游戏开始时,玩家需要点击屏幕.因此在GameViewController类中,添加下面的触摸代码:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
{
  if game.state == GameStateType.tapToPlay {
playGame() }
}

GameViewController类中,添加下面的代码:

func updateMotionControl() {
  // 1
  if game.state == GameStateType.playing {
    motion.getAccelerometerData(interval: 0.1) { (x,y,z) in
     self.motionForce = SCNVector3(x: Float(x) * 0.05, y:0,
       z: Float(y+0.8) * -0.05)
    }
// 2
    ballNode.physicsBody!.velocity += motionForce
  }
}

代码含义:

  1. 根据当前的运动数据更新motionForce向量.
  2. motionForce向量赋值给小球的velocity.

还需要在renderer(_, updateAtTime)方法中调用updateMotionControl()方法:

updateMotionControl()

运行游戏,看到小球从空中落下,点击屏幕开始游戏:


小球身上的发光效果实际就是生命值,小球的发光强度将随着时间不断减弱直到降为0.0.如果收集到一个能量珍珠,则生命值恢复到1.0.我们需要一个方法来补充生命值.在GameViewController类中,添加下面的代码:

func replenishLife() {
  // 1
  let material = ballNode.geometry!.firstMaterial!
  // 2
  SCNTransaction.begin()
  SCNTransaction.animationDuration = 1.0
// 3
  material.emission.intensity = 1.0
// 4
  SCNTransaction.commit()
  // 5
  game.score += 1
  game.playSound(node: ballNode, name: "Powerup")
}
  1. 要获取发光贴图,就需要先获取ballNode的firstMaterial.
  2. 通过SCNTransaction.begin() 来开始动画.此处我们设置时长为1秒animationDuration = 1.0.
  3. 设置发光强度为1.0.
  4. 提交动画事务.提交后SceneKit将开始执行动画,将发光强度从当前值改为1.0
  5. 增加分数,播放音效.

该方法需要在刚变成.playing状态时调用.在playGame()方法的末尾调用:

  replenishLife()

有了恢复生命值的方法,还需要逐渐减少的方法.在GameViewController类中,添加下面的代码:

func diminishLife() {
  // 1
  let material = ballNode.geometry!.firstMaterial!
  // 2
  if material.emission.intensity > 0 {
    material.emission.intensity -= 0.001
  } else {
    resetGame()
  }
}

我们需要在每次检查.gameOver状态时调用这个方法.

摄像机和灯光

GameViewController类中,添加下面的代码:

func updateCameraAndLights() {
  // 1
  let lerpX = (ballNode.presentation.position.x -
    cameraFollowNode.position.x) * 0.01
  let lerpY = (ballNode.presentation.position.y -
    cameraFollowNode.position.y) * 0.01
  let lerpZ = (ballNode.presentation.position.z -
    cameraFollowNode.position.z) * 0.01
  cameraFollowNode.position.x += lerpX
  cameraFollowNode.position.y += lerpY
  cameraFollowNode.position.z += lerpZ
  // 2
  lightFollowNode.position = cameraFollowNode.position
// 3
  if game.state == GameStateType.tapToPlay {
      cameraFollowNode.eulerAngles.y += 0.005
  }
}

代码含义:

  1. 用线性插值法计算要移动的位置.创造出一种特殊的减速移动效果.
  2. lightFollowNode节点跟随摄像机节点.
  3. 当进入.tapToPlay状态时,将摄像机抬起一些.

这个函数需要在renderer(_, updateAtTime)的末尾调用,这样才能在每帧都能实时更新摄像机和灯光:

updateCameraAndLights()

运行一下,如下:


点击屏幕,开始游戏:


游戏已经基本完成,还需要处理一下HUD的显示问题,以及生命值耗尽的问题.

GameViewController类中,添加下面的代码:

func updateHUD() {
  switch game.state {
  case .playing:
    game.updateHUD()
  case .gameOver:
    game.updateHUD(s: "-GAME OVER-")
  case .tapToPlay:
    game.updateHUD(s: "-TAP TO PLAY-")
  }
}

renderer(_, updateAtTime)方法的末尾,添加调用:

updateHUD()

现在生命值耗尽,游戏也不会结束,只有掉落下去才会死.我们需要处理耗尽问题.在renderer(_, updateAtTime)方法的末尾,添加代码:

if game.state == GameStateType.playing {
  testForGameOver()
  diminishLife()
}

还需要处理小球与能量珍珠碰撞时,珍珠消失但小球的生命值没有增加的问题.只需要在physicsWorld(_, didBeginContact)里处理与珍珠的碰撞代码块中,调用replenishLife()就行了:

replenishLife()

添加碰撞音效,在physicsWorld(_, didBeginContact)里处理与柱子/木箱的碰撞代码块中,调用播放音效就行了:

game.playSound(node: ballNode, name: "Bump")

最后一步,移除setupScene()中的调试代码:

 //scnView.allowsCameraControl = true
 //scnView.showsStatistics = true

最终的完成版代码,在15-Motion Control中的projects/final/ MarbleMaze/ 文件夹下.

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268