本系列文章是对<3D Apple Games by Tutorials>一书的学习记录和体会node
此书对应的代码地址git
SceneKit系列文章目录github
更多iOS相关知识查看github上WeekWeekUpProjectswift
打开Xcode,建立一个新项目,选择iOS/Application/Game模板. 游戏名Breaker,语言选Swift,游戏技术SceneKit,设备支持Universal,取消勾选两个测试选项.app
打开项目,删除art.scnassets文件夹.并将GameViewController.swift中的内容替换为下面:dom
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.将图标拖放到里面去.ide
选中Assets.xcassets,拖放resources/Logo_Diffuse.png到里面.而后打开LaunchScreen.storyboard,将背景颜色改成深蓝色.在右下角的Media Library中找到Logo_Diffuse,拖放到启动屏幕里.设置图片的Content Mode为Aspect Fit,并添加约束,让它处在屏幕中间: 工具
完成后: post
下面还须要添加音效.找到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键所有删除.
打开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
.运行一下:
选中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).
打开属性检查器:
完成后的场景效果:
运行一下,效果以下:
选择Game.scn,点击 + 按钮添加一个空白节点,命名为Barriers. 这将是用来盛放全部的边框节点的:
从对象库中,拖放一个Box,在场景树中,将新的立方体节点拖放到Barriers组节点下面.
打开节点检查器,命名为Top,设置位置为 (x:0,y:0,z:-10.5).开属性检查器,设置Size为width:13, height:2, length:1,设置Chamfer radius为0.3. 打开
下面咱们经过复制的方式来建立底部的边框. 复制方法是:按住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文件夹下,找到最终的完成版项目.
先给小球添加物理效果. 打开Game.scn并选中Ball.打开Physics Inspector物理效果检查器.将Physics Body的Type改成Dynamic. 并按下图设置各个项目:
给边框添加物理效果 一次性选中左右边框的四个部分,能够有两种方法:
保持选中状态,打开物理效果检查器,在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
来判断小球是否是和边框节点碰撞了.再根据名字判断,若是是和底部边框碰撞,则须要扣掉一个生命值.20
度的水平偏转.还要记得成为接触代理.在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")
来播放已加载好的音效.random() % 3
来产生0~2的随机数.最终完成的项目,放在代码中对应章节的projects/challenge/Breaker文件夹里.