说明
本系列文章是对<3D Apple Games by Tutorials>一书的学习记录和体会此书对应的代码地址
更多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) {
}
}
代码含义:
- 在
viewDidLoad()
里调用一些空的占位方法.稍后,我们会向这些方法里添加代码. - 在创建场景方法里将
self.view
转换为SCNView
对象并储存起来以便访问,记self
成为渲染循环的代理. -
GameViewController
遵守SCNSceneRendererDelegate
协议,并实现renderer(_: updateAtTime:)
方法.
找到resources/AppIcon文件夹,里面有各种尺寸的应用图标.打开项目的Assets.xcassets并选择AppIcon.将图标拖放到里面去.
选中Assets.xcassets,拖放resources/Logo_Diffuse.png到里面.然后打开LaunchScreen.storyboard,将背景颜色改为深蓝色.在右下角的Media Library中找到Logo_Diffuse,拖放到启动屏幕里.设置图片的Content Mode为Aspect Fit,并添加约束,让它处在屏幕中间:
完成后:
下面还需要添加音效.找到resources/Breaker.scnassets文件夹,拖放到时项目中.注意选中Copy items if needed, Create groups及目标项目Breaker.这里面有子文件夹,Sounds和Textures分别是音频和纹理图片.
还需要一些游戏工具类.拖放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键全部删除.
07-Cameras摄像机
添加摄像机
打开GameViewController.swift,在setupNodes()
中添加下面一行:
scnScene.rootNode.addChildNode(game.hudNode)
然后,在renderer(_,updateAtTime)
中添加一行:
game.updateHUD()
选中Game.scn,以显示编辑器.
在左下角点击+按钮,创建一个空的节点默认命名为untitled.将其改名为Cameras.
从右下角的对象库中拖放两个Camera节点到场景中.
分别命名为VerticalCamera和HorizontalCamera.稍后会讲为什么需要两个摄像机.
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
}
}
代码含义:
- 重写
viewWillTransition(to:with:)
来运行切换方向的代码. - 根据从
UIDevice.current().orientation
中获取到的deviceOrientation
来切换方向.如果将要切换到.portrait
,则设置视点为verticalCameraNode
.否则,切换视点到horizontalCameraNode
.
运行一下:
08-Lights灯光
添加小球
选中Game.scn.在对象库中,拖放一个Sphere到场景中.
确保球体节点仍处于选中状态,然后选择节点检查器.将Name命名为Ball,将position设置为0,这样球就在正中间了.
接着打开属性检查器.将Radius改为0.25, Segment count为17.
两种球体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).
打开属性检查器:
完成后的场景效果:
运行一下,效果如下:
09-Geometric Shapes几何形状
创建边框
选择Game.scn,点击+按钮添加一个空白节点,命名为Barriers.
这将是用来盛放所有的边框节点的:
从对象库中,拖放一个Box,在场景树中,将新的立方体节点拖放到Barriers组节点下面.
打开节点检查器,命名为Top,设置位置为(x:0,y:0,z:-10.5).开属性检查器,设置Size为width:13, height:2, length:1,设置Chamfer radius为0.3.
打开
材料检查器,将Diffuse改为暗灰色Hex Color为333333,并将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).
属性检查器中,设置Radius为0.3,Height为22.5.
材料检查器中,设置Diffuse为Hex Color #的B3B3B3 ,Specular为White:
建立左边框的下半部分
选中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组节点下面.
打开节点检查器,设置Position为0,设置Euler为(x:0, y:0, z:90).
打开属性检查器,设置Radius为0.25, Height为1.5.
打开材料检查器,设置Diffuse为Hex Color #的333333, Specular为White.
创建左侧部分
拖放一个圆柱体,命名为Left,放在Paddle组节点下面.
设置Position为(x:-1, y:0, z:0), Euler为(x:0, y:0, z:90).
打开属性检查器,设置Radius为0.25, Height为0.5.
打开材料检查器,设置Diffuse为Hex Color #的666666, Specular为White.
复制右侧部分
选中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).
将砖块用自己的颜色命名.
复制更多列出来.(按住Option和Command)
复制时,记得使用材料检查器下面的Unshare按钮,以免改变了原始节点的颜色.
复制填满整个区域.
最终效果如图:
运行程序
你可以在本章对应代码的projects/challenge/Breaker文件夹下,找到最终的完成版项目.
10-Basic Collision Detection碰撞检测基础
物理效果
先给小球添加物理效果.
打开Game.scn并选中Ball.打开Physics Inspector物理效果检查器.将Physics Body的Type改为Dynamic.
并按下图设置各个项目:
给边框添加物理效果
一次性选中左右边框的四个部分,可以有两种方法:
- 按住Command在场景树中点击每个节点.
- 类似于文件夹多选操作,先选中Top节点,按住Shift,点击Right,两者之间的节点会被全部选中.
保持选中状态,打开物理效果检查器,在Physics Body区域,将Type改为Static,在新展开的设置项里按下图设置:
点击工具条上的播放按钮,就可以预览物理效果:
接着给砖块添加物理效果
全选砖块节点:
设置为Static形体,其余如下图:
给球拍挡板添加物理效果
选中球拍三个节点,打开物理效果检查器,设置Type为Kinematic,其余项目设置如下:
运行一下,小球会疯狂地到处碰撞,包括与球拍的碰撞:
碰撞检测
碰撞检测用到的是SCNPhysicsContactDelegate协议.
打开GameViewController.swift,添加一个新属性:
var lastContactNode: SCNNode!
它的作用有两个:
- 当两个节点发生互相滑动时,就相当于和同一个节点不停发生碰撞,而我们只关心第一次碰撞.
- 在这个游戏中,尽管碰撞可能会持续,但小球不能和同一个节点两次发生接触事件,直到小球碰到了其它节点.所以我们需要确保只处理一次碰撞.
在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
}
}
代码含义:
- 扩展
GameViewController
类以实现SCNPhysicsContactDelegate
协议,方便组织代码. - 实现
physicsWorld(_:didBegin:)
.默认不触发,需要设置接触掩码. - 传入一个
SCNPhysicsContact
参数,可以判断并找到哪个是小球. - 防止和同一个节点多次碰撞.
使用位掩码来检测接触事件.
我们已经给游戏中的不同元素设置了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
代码含义:
- 检查
categoryBitMask
来判断小球是不是和边框节点碰撞了.再根据名字判断,如果是和底部边框碰撞,则需要扣掉一个生命值. - 检查并判断小球是不是和砖块碰撞了.让对应砖块消失120秒,再皇亲出现,这样游戏就能一直玩下去.
- 判断小球是不是和球拍碰撞了.如果遇到了中间部分,不改变物理效果,由引擎自动控制反弹.如果是碰到了左边或右边,则给小球增加一个
20
度的水平偏转. - 将小球速度强制限制在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
}
}
}
代码含义:
- 当触摸位置移动时,根据相对初始触摸位置的偏移
touchX
来更新球拍的位置. - 限制球拍的移动,确保在边框之间.
运行一下,可以来回移动球拍了:
摄像机追踪
在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")
可以在碰撞的时候,播放对应的音效:
- 使用
game.playSound(node: scnScene.rootNode, name: "SoundToPlay")
来播放已加载好的音效. - 给Block添加音效时使用随机值,用
random() % 3
来产生0~2的随机数.
最终完成的项目,放在代码中对应章节的projects/challenge/Breaker文件夹里.