說明
本系列文章是對<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. 開啟



下面我們通過複製的方式來建立底部的邊框. 複製方法是:按住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資料夾裡.