×

[SceneKit专题]3D平衡球游戏Marble Maze

96
史前图腾
2017.09.25 23:34* 字数 3787

说明

本系列文章是对<3D Apple Games by Tutorials>一书的学习记录和体会此书对应的代码地址

SceneKit系列文章目录

更多iOS相关知识查看github上WeekWeekUpProject

11-Materials材质

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

替换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拖拽到场景的背景属性上.:

WX20171113-213854@2x.png

GameViewController类中添加下面属性:

var scnScene:SCNScene!

setupScene()中添加下面代码:

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

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


WX20171113-213918@2x.png

12-Reference Nodes引用节点

主角--小球

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

WX20171113-220906@2x.png

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

WX20171113-220953@2x.png

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


WX20171113-221006@2x.png

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

WX20171113-221018@2x.png

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

WX20171113-221030@2x.png

材质设置

漫反射设置


WX20171113-222847@2x.png

法线设置


WX20171113-222907@2x.png

高光设置


WX20171113-222929@2x.png

反射设置


WX20171113-222944@2x.png

发光设置


WX20171113-222959@2x.png

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


WX20171113-223012@2x.png

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

WX20171113-223051@2x.png

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

运行一下:


WX20171113-223103@2x.png
挑战--创建木箱,小石块,大石块,柱子的引用节点

这是一个小小的挑战:

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


    WX20171114-205653@2x.png
  • 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方块时,可参照下面步骤:


WX20171114-205708@2x.png

WX20171114-205724@2x.png

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


WX20171114-205738@2x.png

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

13-Shadows阴影

组织场景

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

WX20171114-221728@2x.png

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

WX20171114-221750@2x.png

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

WX20171114-221800@2x.png

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

WX20171114-221816@2x.png

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

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

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

WX20171114-221830@2x.png

灯光

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

WX20171114-221948@2x.png

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

WX20171114-222002@2x.png

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


WX20171114-222017@2x.png

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

WX20171114-225122@2x.png

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


WX20171114-225145@2x.png

查看一下场景中的小球:


WX20171114-225209@2x.png

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

WX20171114-225230@2x.png

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


WX20171114-225241@2x.png

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

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


WX20171114-225308@2x.png

完成后的效果:


WX20171114-225335@2x.png
重用集合体

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

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

WX20171115-142246@2x.png

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

WX20171115-142301@2x.png

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

WX20171115-142314@2x.png

使用⌥⌘ (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).


    WX20171115-142328@2x.png

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

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

WX20171115-142341@2x.png

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


WX20171115-142402@2x.png
创建其它部件

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

WX20171115-142418@2x.png

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

WX20171115-142432@2x.png

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


WX20171115-142450@2x.png

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

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


WX20171115-142516@2x.png

WX20171115-142531@2x.png

14-Intermediate Collision Detection中级碰撞检测

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

WX20171115-154050@2x.png

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


WX20171115-154104@2x.png

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

WX20171115-154119@2x.png

WX20171115-161805@2x.png

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

WX20171115-161827@2x.png

完成后的效果图:


WX20171115-161839@2x.png

还需要添加游戏工具类

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

WX20171115-161854@2x.png

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

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


WX20171115-161946@2x.png

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

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

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


WX20171115-162001@2x.png

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

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


WX20171115-162017@2x.png

我们只关心小球和能量珍珠,柱子及木箱的碰撞,所以:
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:

WX20171115-162043@2x.png

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


WX20171115-162056@2x.png

设置Category mask1,Collision mask14:

WX20171115-162112@2x.png

ShapeDefault shape,TypeConvex:

WX20171115-162906@2x.png

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


WX20171115-162924@2x.png
  • obj_stone1x1.scnCategory mask2, Collision mask1;
    WX20171115-162938@2x.png
  • 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:

WX20171115-162959@2x.png

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

WX20171115-163025@2x.png

添加碰撞检测处理

现在终于设置好了各个物体,要处理相互的碰撞了.在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让小球出现时有个掉落效果:

WX20171115-163047@2x.png

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


WX20171115-163106@2x.png

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

WX20171115-163127@2x.png

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


WX20171115-163158@2x.png

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


WX20171115-163217@2x.png

15-Motion Control运动控制

辅助类和音效

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

GameViewController中添加下面的属性:

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

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

WX20171118-214115@2x.png

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()

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


WX20171118-214158@2x.png

WX20171118-214223@2x.png

小球身上的发光效果实际就是生命值,小球的发光强度将随着时间不断减弱直到降为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()

运行一下,如下:


WX20171118-214308@2x.png

点击屏幕,开始游戏:


WX20171118-214324@2x.png

游戏已经基本完成,还需要处理一下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()
WX20171118-214346@2x.png

现在生命值耗尽,游戏也不会结束,只有掉落下去才会死.我们需要处理耗尽问题.在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/文件夹下.

SceneKit+ARKit
Web note ad 1