[SceneKit专题]21-3D打砖块游戏Breaker

1,852 阅读14分钟

说明

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

此书对应的代码地址

SceneKit系列文章目录

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

06-SceneKit Editor场景编辑器

创建游戏

打开Xcode,创建一个新项目,选择iOS/Application/Game模板. 游戏名Breaker,语言选Swift,游戏技术SceneKit,设备支持Universal,取消勾选两个测试选项.

打开项目,删除art.scnassets文件夹.并将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
  }
  func setupNodes() {
}
  func setupSounds() {
  }
  override var shouldAutorotate: Bool { return true }
  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. GameViewController遵守SCNSceneRendererDelegate协议,并实现renderer(_: updateAtTime:)方法.

找到resources/AppIcon文件夹,里面有各种尺寸的应用图标.打开项目的Assets.xcassets并选择AppIcon.将图标拖放到里面去.

WX20171106-215541@2x.png

选中Assets.xcassets,拖放resources/Logo_Diffuse.png到里面.然后打开LaunchScreen.storyboard,将背景颜色改为深蓝色.在右下角的Media Library中找到Logo_Diffuse,拖放到启动屏幕里.设置图片的Content ModeAspect Fit,并添加约束,让它处在屏幕中间:

WX20171106-221817@2x.png

完成后:

WX20171106-221905@2x.png

下面还需要添加音效.找到resources/Breaker.scnassets文件夹,拖放到时项目中.注意选中Copy items if needed, Create groups及目标项目Breaker.这里面有子文件夹,SoundsTextures分别是音频和纹理图片.

还需要一些游戏工具类.拖放resources/GameUtil到项目中. 打开GameViewController.swift,在scnView下面添加属性:

var game = GameHelper.sharedInstance
加载场景

右击Breaker.scnassets,创建一个新文件夹命名为Scenes,用来盛放所有场景.

WX20171106-222712@2x.png

选中Breaker项目,创建新文件,选择iOS/Resource/ SceneKit Scene模板,命名为Game.scn.注意位置选择在Breaker.scnassets下面的Scenes文件夹下面.

WX20171106-222942@2x.png

从右下角的物体对象库中拖拽一个Box出来,随便放在场景中:

WX20171106-225141@2x.png

GameViewController中添加一个新属性:

var scnScene: SCNScene!

接下来,在setupScene()方法的底部,添加下面代码:

 scnScene = SCNScene(named: "Breaker.scnassets/Scenes/Game.scn")
scnView.scene = scnScene

运行一下:

WX20171106-225545@2x.png

测试完成后,就可以删除立方体了.在左侧的场景树中,按Command-A选择所有节点,按Delete键全部删除.

WX20171106-225812@2x.png

07-Cameras摄像机

添加摄像机

打开GameViewController.swift,在setupNodes()中添加下面一行:

scnScene.rootNode.addChildNode(game.hudNode)

然后,在renderer(_,updateAtTime)中添加一行:

game.updateHUD()

选中Game.scn,以显示编辑器. 在左下角点击 + 按钮,创建一个空的节点默认命名为untitled.将其改名为Cameras.

WX20171108-215639@2x.png

从右下角的对象库中拖放两个Camera节点到场景中.

WX20171108-215828@2x.png

分别命名为VerticalCameraHorizontalCamera.稍后会讲为什么需要两个摄像机.

TL/DR:双摄像机能让你更好地处理横屏与竖屏状态下的视角.

让两个摄像机都成为Cameras的子节点:

WX20171108-221039@2x.png

选中VerticalCamera,在节点检查器中设置Position(x:0, y:22, z:9),Euler(x:-70, y:0, z:0)

WX20171108-221410@2x.png

选中HorizontalCamera,在节点检查器中设置Position(x:0, y:8.5, z:15),Euler(x:-40, y:0, z:0)

WX20171108-221819@2x.png

对比来看,水平摄像机比竖直摄像机离得更近,角度也更小.

WX20171108-221912@2x.png

GameViewController.swift中添加两个属性:

 var horizontalCameraNode: SCNNode!
  var verticalCameraNode: SCNNode!

setupNodes()方法的开头添加下面代码:

horizontalCameraNode = scnScene.rootNode.childNode(withName:
"HorizontalCamera", recursively: true)!
verticalCameraNode = scnScene.rootNode.childNode(withName:
"VerticalCamera", recursively: true)!

因为场景已经加载进来了,所以我们只需要用childNode(withName:recursively:)方法来找到摄像机节点就可以了.recursively设置为true会递归遍历其中的子文件夹.

处理旋转

设置在旋转时,屏幕的显示范围也在跟着变.与其在两个方向中找到"sweet-spot",倒不如使用两个摄像机,每一个都可以最大化利用显示范围.

WX20171108-223028@2x.png

为了追踪设备方向,需要重写viewWillTransition(to size:, with coordinator:)方法:

// 1
override func viewWillTransition(to size: CGSize, with coordinator:
UIViewControllerTransitionCoordinator) {
// 2
  let deviceOrientation = UIDevice.current.orientation
  switch(deviceOrientation) {
  case .portrait:
    scnView.pointOfView = verticalCameraNode
  default:
    scnView.pointOfView = horizontalCameraNode
  }
}

代码含义:

  1. 重写viewWillTransition(to:with:)来运行切换方向的代码.
  2. 根据从UIDevice.current().orientation中获取到的deviceOrientation来切换方向.如果将要切换到.portrait,则设置视点为verticalCameraNode.否则,切换视点到horizontalCameraNode.

运行一下:

WX20171108-223615@2x.png

08-Lights灯光

添加小球

选中Game.scn.在对象库中,拖放一个Sphere到场景中.

WX20171108-223931@2x.png

确保球体节点仍处于选中状态,然后选择节点检查器.将Name命名为Ball,将position设置为0,这样球就在正中间了.

WX20171108-230307@2x.png

接着打开属性检查器.将Radius改为0.25, Segment count17.

WX20171108-230522@2x.png

两种球体sphere和geosphere本质上是同样的.不同的是下面的geodesic复选框,决定了渲染引擎如何构建球体.一种是四边形,一种是三角形.

下一步,选中材料检查器.将Diffuse改为7F7F7F.将Specular改为White.

WX20171108-230913@2x.png

继续向下,找到Setting区域,将Shininess改为0.3.

WX20171108-231032@2x.png

完成后,选中HorizontalCamera,场景看起来是这样:

WX20171108-231153@2x.png

下面,打开GameViewController.swift,添加一个属性:

var ballNode: SCNNode!

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

 ballNode = scnScene.rootNode.childNode(withName: "Ball", recursively:true)!
三点光照

首先,打开Game.scn,点击 + 创建一个空节点,命名为Lights.它将用来盛放场景中的所有灯光.

WX20171109-212629@2x.png

从对象库中,拖放一个Omni light到场景中,放到灯光节点下面.

WX20171109-213018@2x.png

选中灯光节点,打开节点检查器,重命名节点为Back.设置Position(x:-15, y:-2, z:15)

WX20171109-213223@2x.png

选择Attributes Inspector,设置泛光灯属性.

WX20171109-213336@2x.png

再从对象库中拖放一个Omni light光源到场景中.还是移动到Lights组节点下.

命名新节点为Front,设置Position(x:6, y:10, z:15).

WX20171109-213612@2x.png

再从对象库中拖放一个Ambient light光源到场景中.还是移动到Lights组节点下.

WX20171109-220913@2x.png

命名新节点为Ambient,设置Position(x:0, y:0, z:0).

WX20171109-221045@2x.png

打开属性检查器:

WX20171109-221205@2x.png

完成后的场景效果:

WX20171109-221251@2x.png

运行一下,效果如下:

WX20171109-221341@2x.png

09-Geometric Shapes几何形状

创建边框

选择Game.scn,点击 + 按钮添加一个空白节点,命名为Barriers. 这将是用来盛放所有的边框节点的:

WX20171109-224809@2x.png

从对象库中,拖放一个Box,在场景树中,将新的立方体节点拖放到Barriers组节点下面.

WX20171109-224937@2x.png

打开节点检查器,命名为Top,设置位置为 (x:0,y:0,z:-10.5).开属性检查器,设置Sizewidth:13, height:2, length:1,设置Chamfer radius0.3. 打开

WX20171111-110146@2x.png
材料检查器,将Diffuse改为暗灰色Hex Color333333,并将Specular改为White:
WX20171109-231133@2x.png

WX20171111-105642@2x.png

下面我们通过复制的方式来创建底部的边框. 复制方法是:按住Option键,点击要复制的节点并沿着蓝色坐标轴拖动:

WX20171111-110434@2x.png

复制成功后,重命名为Bottom,将设置为Barriers组的子节点.

WX20171111-110514@2x.png

更改一下位置,Position(x:0, y:0, z:10.5).

WX20171111-113425@2x.png

最终效果,如图:

WX20171111-113510@2x.png

还有一个重要的事:注意场景树的结构,组节点是如何包含顶边框/底边框的. 选中新复制出的节点的Attributes Inspector属性检查器,在Geometry Sharing区下面,点击Unshare按钮.

因为创建复本时,复制出的节点仍然会共享原始节点的几何体(Geometry).这个默认设置是为了减少总的绘制调用(draw call)数.

左侧边框的建立

左右两侧的边框分别由两根圆柱组成.先在Barriers组下面建立一个Left节点,并放置到合适的位置.里面的子节点也会跟着发生位置变动.

WX20171111-115817@2x.png

WX20171111-115849@2x.png

建立左边框的上半部分 拖放一个Cylinder,重命名为Top,放置到Barriers/Left下面:

WX20171111-120053@2x.png
WX20171111-120123@2x.png

在节点检查器中,设置Position(x:0, y:0.5, z:0),Euler(x:90, y:0, z:0).

属性检查器中,设置Radius0.3,Height22.5.

材料检查器中,设置DiffuseHex Color #B3B3B3 ,SpecularWhite:

WX20171111-120335@2x.png
WX20171111-120655@2x.png
WX20171111-120713@2x.png

建立左边框的下半部分 选中Barrier/Left/Top节点,按住Option键,沿蓝色坐标轴,点击拖动.重命名为Bottom,放在Barriers/Left组下面.在节点检查器中,设置Position(x:0,y:-0.5,z:0):

WX20171111-125653@2x.png
WX20171111-125915@2x.png
WX20171111-125939@2x.png

最终效果如图:

WX20171111-125954@2x.png

建立右侧边框

选中Barriers/Left组,按住Command+Option并沿红色坐标轴点击拖动,这样就复制了一组节点.重命名为Right,并设置位置为 (x:6, y:0, z:0)

WX20171111-130404@2x.png
WX20171111-130443@2x.png
WX20171111-130454@2x.png

最终效果如图:

WX20171111-130609@2x.png

创建球拍挡板

点击 + 按钮创建新的节点,命名为Paddle.打开节点检查器,设置Position(x:0, y:0, z:8).

WX20171111-132831@2x.png
WX20171111-132841@2x.png

球拍挡板共有三个部分:左,中,右. 我们先创建中间部分,拖放一个圆柱体,命名为Center,放在Paddle组节点下面.

WX20171111-133129@2x.png
WX20171111-133141@2x.png

打开节点检查器,设置Position0,设置Euler(x:0, y:0, z:90).

打开属性检查器,设置Radius0.25, Height1.5.

打开材料检查器,设置DiffuseHex Color #333333, SpecularWhite.

WX20171111-133213@2x.png
WX20171111-133225@2x.png
WX20171111-133239@2x.png

创建左侧部分

拖放一个圆柱体,命名为Left,放在Paddle组节点下面.

WX20171111-133904@2x.png

设置Position为**(x:-1, y:0, z:0)**, Euler(x:0, y:0, z:90).

打开属性检查器,设置Radius0.25, Height0.5.

打开材料检查器,设置DiffuseHex Color #666666, SpecularWhite.

WX20171111-134208@2x.png
WX20171111-134218@2x.png
WX20171111-134235@2x.png

复制右侧部分 选中Paddle/Left节点,按住Command+Option并沿绿色坐标轴点击拖动,这样就复制了一组节点.重命名为Right,并设置位置为**(x:1, y:0, z:0)**.还是要注意取消几何体共享.

WX20171111-141015@2x.png
WX20171111-141028@2x.png

绑定球拍挡板,以便操作

打开GameViewController.swift,添加属性:

var paddleNode: SCNNode!

setupNodes()方法的末尾,添加绑定球拍的代码:

 paddleNode =
  scnScene.rootNode.childNode(withName: "Paddle", recursively: true)!

你可以在本章对应代码的projects/final/Breaker文件夹下,找到最终的完成版项目.

添加砖块,挑战项目
  • 首先,创建一个组节点命名为Bricks,用来放置所有的砖块.

  • 设置Bricks节点的位置为 (x:0, y:0, z:-3.0).

  • 每个砖块都是使用一个Box,尺寸为width:1, height:0.5, length: 0.5,Chamfer Radius:0.05.

  • 先创建一列各种颜色的砖块,颜色分别使用white (#FFFFFF), red (#FF0000), yellow (#FFFF00), blue (#0000FF), purple (#8000FF), green (#00FF80):

    WX20171111-142007@2x.png

  • 为了方便定位,白色砖块可以放置在(x: 0, y:0, z:-2.5),绿色砖块应该在(x:0, y:0, z:0).

  • 将砖块用自己的颜色命名.

  • 复制更多列出来.(按住OptionCommand)

  • 复制时,记得使用材料检查器下面的Unshare按钮,以免改变了原始节点的颜色.

  • 复制填满整个区域.

最终效果如图:

WX20171111-142642@2x.png

运行程序

WX20171111-142655@2x.png

你可以在本章对应代码的projects/challenge/Breaker文件夹下,找到最终的完成版项目.

10-Basic Collision Detection碰撞检测基础

物理效果

先给小球添加物理效果. 打开Game.scn并选中Ball.打开Physics Inspector物理效果检查器.将Physics BodyType改为Dynamic. 并按下图设置各个项目:

WX20171111-143239@2x.png

给边框添加物理效果 一次性选中左右边框的四个部分,可以有两种方法:

  1. 按住Command在场景树中点击每个节点.
  2. 类似于文件夹多选操作,先选中Top节点,按住Shift,点击Right,两者之间的节点会被全部选中.
    WX20171111-143739@2x.png

保持选中状态,打开物理效果检查器,在Physics Body区域,将Type改为Static,在新展开的设置项里按下图设置:

WX20171111-143930@2x.png

点击工具条上的播放按钮,就可以预览物理效果:

WX20171111-144621@2x.png

接着给砖块添加物理效果 全选砖块节点:

WX20171111-144805@2x.png

设置为Static形体,其余如下图:

WX20171111-144821@2x.png

给球拍挡板添加物理效果 选中球拍三个节点,打开物理效果检查器,设置TypeKinematic,其余项目设置如下:

WX20171111-150415@2x.png
WX20171111-150430@2x.png

运行一下,小球会疯狂地到处碰撞,包括与球拍的碰撞:

WX20171111-151240@2x.png

碰撞检测

碰撞检测用到的是SCNPhysicsContactDelegate协议. 打开GameViewController.swift,添加一个新属性:

var lastContactNode: SCNNode!

它的作用有两个:

  1. 当两个节点发生互相滑动时,就相当于和同一个节点不停发生碰撞,而我们只关心第一次碰撞.
  2. 在这个游戏中,尽管碰撞可能会持续,但小球不能和同一个节点两次发生接触事件,直到小球碰到了其它节点.所以我们需要确保只处理一次碰撞.

GameViewController.swift底部添加类扩展:

// 1
extension GameViewController: SCNPhysicsContactDelegate {
  // 2
  func physicsWorld(_ world: SCNPhysicsWorld,
    didBegin contact: SCNPhysicsContact) {
    // 3
    var contactNode: SCNNode!
    if contact.nodeA.name == "Ball" {
      contactNode = contact.nodeB
} else {
      contactNode = contact.nodeA
    }
// 4
    if lastContactNode != nil &&
        lastContactNode == contactNode {
return
}
    lastContactNode = contactNode
  }
}

代码含义:

  1. 扩展GameViewController类以实现SCNPhysicsContactDelegate协议,方便组织代码.
  2. 实现physicsWorld(_:didBegin:).默认不触发,需要设置接触掩码.
  3. 传入一个SCNPhysicsContact参数,可以判断并找到哪个是小球.
  4. 防止和同一个节点多次碰撞.

使用位掩码来检测接触事件. 我们已经给游戏中的不同元素设置了Category bitmask分类掩码,这个值是二进制的,各分类如下:

Ball:     1 (Decimal) = 00000001 (Binary)
Barrier:  2 (Decimal) = 00000010 (Binary)
Brick:    4 (Decimal) = 00000100 (Binary)
Paddle:   8 (Decimal) = 00001000 (Binary)

GameViewController顶部定义一个枚举:

enum ColliderType: Int {
  case ball     = 0b0001
  case barrier  = 0b0010
  case brick    = 0b0100
  case paddle   = 0b1000
}

setupNodes()方法的末尾添加下面代码来处理碰撞:

ballNode.physicsBody?.contactTestBitMask =
  ColliderType.barrier.rawValue |
    ColliderType.brick.rawValue |
      ColliderType.paddle.rawValue

这样,你就告诉了物理引擎,当小球和分类掩码为2, 4, 8的节点碰撞时,调用physicsWorld(_:didBegin:)方法通知我. 2,4,8也就是指barrier边框, brick砖块和paddle球拍.

physicsWorld(_:didBegin:)方法的末尾继续写:

// 1
if contactNode.physicsBody?.categoryBitMask ==
ColliderType.barrier.rawValue {
  if contactNode.name == "Bottom" {
    game.lives -= 1
    if game.lives == 0 {
      game.saveState()
      game.reset()
    }
} }
// 2
if contactNode.physicsBody?.categoryBitMask ==
ColliderType.brick.rawValue {
  game.score += 1
  contactNode.isHidden = true
  contactNode.runAction(
    SCNAction.waitForDurationThenRunBlock(duration: 120) {
    (node:SCNNode!) -> Void in
       node.isHidden = false
  })
}
// 3
if contactNode.physicsBody?.categoryBitMask ==
ColliderType.paddle.rawValue {
  if contactNode.name == "Left" {
    ballNode.physicsBody!.velocity.xzAngle -=
      (convertToRadians(angle: 20))
  }
  if contactNode.name == "Right" {
    ballNode.physicsBody!.velocity.xzAngle +=
      (convertToRadians(angle: 20))
  }
}
// 4
ballNode.physicsBody?.velocity.length = 5.0

代码含义:

  1. 检查categoryBitMask来判断小球是不是和边框节点碰撞了.再根据名字判断,如果是和底部边框碰撞,则需要扣掉一个生命值.
  2. 检查并判断小球是不是和砖块碰撞了.让对应砖块消失120秒,再皇亲出现,这样游戏就能一直玩下去.
  3. 判断小球是不是和球拍碰撞了.如果遇到了中间部分,不改变物理效果,由引擎自动控制反弹.如果是碰到了左边或右边,则给小球增加一个20度的水平偏转.
  4. 将小球速度强制限制在5,以防物理引擎出现偏差而失控.

还要记得成为接触代理.在setupScene()底部添加一行:

scnScene.physicsWorld.contactDelegate = self

运行一下,可以打掉砖块了!

WX20171111-160202@2x.png

触摸控制球拍

GameViewController添加两个属性:

 var touchX: CGFloat = 0
 var paddleX: Float = 0

下一步,给GameViewController添加下面的方法:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
{
  for touch in touches {
    let location = touch.location(in: scnView)
    touchX = location.x
    paddleX = paddleNode.position.x
  } 
}

记录下触摸的初始位置,球拍的初始位置

 override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
{
  for touch in touches {
    // 1
    let location = touch.location(in: scnView)
    paddleNode.position.x = paddleX +
      (Float(location.x - touchX) * 0.1)
    // 2
    if paddleNode.position.x > 4.5 {
      paddleNode.position.x = 4.5
    } else if paddleNode.position.x < -4.5 {
      paddleNode.position.x = -4.5
    }
  }
}

代码含义:

  1. 当触摸位置移动时,根据相对初始触摸位置的偏移touchX来更新球拍的位置.
  2. 限制球拍的移动,确保在边框之间.

运行一下,可以来回移动球拍了:

WX20171111-163506@2x.png

摄像机追踪

touchesMoved(_:with:)方法的底部,添加下面代码,让摄像机水平位置和球拍一致:

 verticalCameraNode.position.x = paddleNode.position.x
 horizontalCameraNode.position.x = paddleNode.position.x

GameViewController中添加一个新属性来依旧在地板节点:

var floorNode: SCNNode!

setupNodes()底部添加代码:

floorNode =
  scnScene.rootNode.childNode(withName: "Floor",
    recursively: true)!
verticalCameraNode.constraints =
  [SCNLookAtConstraint(target: floorNode)]
horizontalCameraNode.constraints =
  [SCNLookAtConstraint(target: floorNode)]

这段代码含义:找到名为Floor的节点,绑定到floorNode.给场景中的两个摄像机添加SCNLookAtConstraint约束,能让摄像机始终对准目标节点,也就是游戏区域的中央.

可以运行试玩一下了:

WX20171111-164815@2x.png

粒子效果

选中场景Game.scn.从对象库中拖放一个Particle System粒子系统到场景中,命名为Trail,并放在Ball节点中

WX20171111-165921@2x.png
:
WX20171111-165743@2x.png

打开节点检查器,设置position(x:0, y:0, z:0).

WX20171111-170628@2x.png

打开属性检查器,配置粒子系统的属性:

WX20171111-171334@2x.png

完成后,点击播放按钮预览一下:

WX20171111-171439@2x.png

正式运行一下,可以玩起来了!

WX20171111-171501@2x.png

该部分最终完成的项目,放在代码中对应章节的projects/final/Breaker文件夹里.

添加声音效果

添加setupSounds()方法,并添加代码:

game.loadSound(name: "Paddle",
  fileNamed: "Breaker.scnassets/Sounds/Paddle.wav")
game.loadSound(name: "Block0",
  fileNamed: "Breaker.scnassets/Sounds/Block0.wav")
game.loadSound(name: "Block1",
  fileNamed: "Breaker.scnassets/Sounds/Block1.wav")
game.loadSound(name: "Block2",
  fileNamed: "Breaker.scnassets/Sounds/Block2.wav")
game.loadSound(name: "Barrier",
  fileNamed: "Breaker.scnassets/Sounds/Barrier.wav")

可以在碰撞的时候,播放对应的音效:

  1. 使用game.playSound(node: scnScene.rootNode, name: "SoundToPlay")来播放已加载好的音效.
  2. Block添加音效时使用随机值,用random() % 3来产生0~2的随机数.

最终完成的项目,放在代码中对应章节的projects/challenge/Breaker文件夹里.