21-3D打砖块游戏Breaker

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

创建游戏

打开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.将图标拖放到里面去.

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

完成后:


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

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

var game = GameHelper.sharedInstance

加载场景

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

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

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

GameViewController中添加一个新属性:

var scnScene: SCNScene!

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

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

运行一下:


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

添加摄像机

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

scnScene.rootNode.addChildNode(game.hudNode)

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

game.updateHUD()

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

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

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

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

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

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

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

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


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",倒不如使用两个摄像机,每一个都可以最大化利用显示范围.


为了追踪设备方向,需要重写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.

运行一下:


添加小球

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

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

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

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

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

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

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

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

var ballNode: SCNNode!

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

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

三点光照

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


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

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

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

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

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

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


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

打开属性检查器:


完成后的场景效果:


运行一下,效果如下:


创建边框

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

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


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

材料检查器,将Diffuse改为暗灰色Hex Color333333,并将Specular改为White:

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


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

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

最终效果,如图:

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

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

左侧边框的建立

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

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


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

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

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



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



最终效果如图:



建立右侧边框

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




最终效果如图:

创建球拍挡板

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



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


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

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

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



创建左侧部分

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


设置Position(x:-1, y:0, z:0), Euler(x:0, y:0, z:90).
打开属性检查器,设置Radius0.25, Height0.5.
打开材料检查器,设置DiffuseHex Color #666666, SpecularWhite.



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


绑定球拍挡板,以便操作

打开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):


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

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

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

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

  • 复制填满整个区域.

最终效果如图:



运行程序


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

物理效果

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

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

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

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

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


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


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


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

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


碰撞检测

碰撞检测用到的是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

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


触摸控制球拍

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. 限制球拍的移动,确保在边框之间.

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


摄像机追踪

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约束,能让摄像机始终对准目标节点,也就是游戏区域的中央.

可以运行试玩一下了:


粒子效果

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


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

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



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


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


该部分最终完成的项目,放在代码中对应章节的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文件夹里.

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