本系列文章是對<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. 打開
材料檢查器,將 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文件夾下,找到最終的完成版項目.
先給小球添加物理效果. 打開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文件夾裏.