SceneKit系列文章目錄node
更多iOS相關知識查看github上WeekWeekUpProjectgit
本教程將包含如下內容:github
開始前,先下載初始項目starter project 打開項目,簡單查看一下里面都有些什麼.你會發現球和罐頭的素材,還有一個GameHelper文件能提供一些有用的函數. 建立並運行,看上去一片黑: 編程
不要難過,這只是一個乾淨的工做臺供你開始.swift
在開始砸罐頭以前,須要給遊戲添加一個菜單選項.打開GameViewController.swift並添加一個新的屬性:數組
// Scene properties
var menuScene = SCNScene(named: "resources.scnassets/Menu.scn")!
複製代碼
這段代碼將加載菜單場景.你將可使用menuScene來實現菜單和等級場景之間的跳轉. 要彈出菜單場景,須要在**viewDidLoad()**裏添加下列代碼:bash
// MARK: - Helpers
func presentMenu() {
let hudNode = menuScene.rootNode.childNode(withName: "hud", recursively: true)!
hudNode.geometry?.materials = [helper.menuHUDMaterial]
hudNode.rotation = SCNVector4(x: 1, y: 0, z: 0, w: Float(M_PI))
helper.state = .tapToPlay
let transition = SKTransition.crossFade(withDuration: 1.0)
scnView.present(
menuScene,
with: transition,
incomingPointOfView: nil,
completionHandler: nil
)
}
複製代碼
這個函數配置了菜單場景中的擡頭顯示節點(HUD),並經過present(scene:with:incomingPointOfView:completionHandler:) 交叉淡出的轉場.app
在viewDidLoad() 底部添加調用presentMenu():dom
override func viewDidLoad() {
super.viewDidLoad()
presentMenu()
}
複製代碼
編譯運行,會看到這樣的菜單場景: 編輯器
打開resources.scnassets/Level.scn場景:
從對象庫中拖入一個Floor節點到場景中:
選擇Material Inspector並設置wood-floor.jpg爲Diffuse紋理.設置Offset爲 (x: 0, y: 0.2),設置Scale爲 (x: 15, y: 15),最後,設置Rotation爲90度:
如今地板已經放置好了,還須要再添加磚牆做爲背景.牆的幾何體已經在Wall.scn場景裏爲你配置好了.用Reference Node引用節點將其添加到等級場景中. 在Level.scn場景中,從媒體庫中拖拽一個Wall引用節點到場景中.
在Node Inspector中設置節點名字爲wall並設置位置爲**(x: 0, y: 0, z: -5)**.
下一步,你須要一個點來堆放罐頭.從Object Library對象庫中拖放一個Box命名爲shelf,並放置到**(x: 0.0, y: 2.25, z: -2.25)**處,正好在牆的前面.
在Attributes Inspector中設置Width爲10,Height爲0.25.最後,在Material Inspector中,設置Diffuse爲wood-table.png,打開附加屬性,設置WrapS和WrapT爲Repeat,設置Scale爲 (x: 2, y: 2).使紋理充滿整個盒子,讓它看起來像是一個真的架子.
爲了完成這個關卡,還須要添加一對燈光和一個攝像機.從對象庫中拖放一個Spot light點光源,設置Position爲 (x: 8.3, y: 13.5, z: 15.0),Euler爲 (x: -40, y: 28, z: 0). 這樣就將點光源放置在空中,朝向場景中的焦點--架子.
在Attributes Inspector中, 設置Inner Angle爲35,Outer Angle爲85.這讓燈光更柔和,也擴展了點光源錐體,擴大了場景中照亮的範圍.
最後,在Shadow下面, 設置Sample radius爲4,Sample count爲1,並設置Color爲黑色,透明度50%.讓會讓點光源投射出柔和的陰影:
爲了淡化黑色的陰影,添加環境光照,拖放一個Ambient light到場景中.默認設置就能夠了.
最後,你必須添加一個攝像機到場景中,來給遊戲一個透視視角.拖放一個Carmera到場景中.Position在 (x: 0.0, y: 2.5, z: 14.0),Rotation爲 (x: -5, y:0 , z:0). 在Attributes Inspector中, 將Y fov改成45.
很好!這樣關卡設計就完成了.看看起來像這樣:
在Level.scn中已經有一關了,那麼怎麼在設備上查看它呢? 在GameViewController中menuScene屬性下面添加一行:
var levelScene = SCNScene(named: "resources.scnassets/Level.scn")!
複製代碼
這段代碼加載了場景,並讓你可以訪問關卡中的全部節點. 如今,爲了呈現這一關的場景,在presentMenu() 後面添加下面的函數:
func presentLevel() {
helper.state = .playing
let transition = SKTransition.crossFade(withDuration: 1.0)
scnView.present(
levelScene,
with: transition,
incomingPointOfView: nil,
completionHandler: nil
)
}
複製代碼
該函數設置遊戲狀態爲 .playing,而後以交叉淡入的轉場效果呈現中關卡場景,相似於在菜單場景中作的那樣. 在touchesBegan(_:with:) 方法最後面添加下面的代碼:
if helper.state == .tapToPlay {
presentLevel()
}
複製代碼
這樣,當你點擊菜單場景時,遊戲就會開始. 編譯運行,而後點擊菜單場景,會看到你設計的關卡淡入:
用SceneKit中建立遊戲的一大好處就是,可以很是簡單就利用內置的物理引擎來實現真實的物理效果. 爲一個節點啓用物理效果,你只須要給它添加physics body物理形體,並配置它的屬性就能夠了.你能夠改變若干參數來模擬一個真實世界的物體;用到的最多見屬性是形狀,質量,摩擦因子,阻尼係數和回彈係數.
在該遊戲中,你會用到物理效果和力來把球扔到罐頭處.罐頭將會有物理形體,來模擬空的鋁罐.你的排球會很重,能猛擊較輕的罐頭,並都掉落在地板上.
在給遊戲添加物理效果以前,你須要訪問場景編輯器中建立的節點.爲此,在GameViewController中場景屬性後面添加下面幾行:
// Node properties
var cameraNode: SCNNode!
var shelfNode: SCNNode!
var baseCanNode: SCNNode!
複製代碼
你須要這些節點來佈局罐頭,配置物理形體,定位場景中的其它節點. 下一步,在scnView計算屬性後面添加如下代碼:
// Node that intercept touches in the scene
lazy var touchCatchingPlaneNode: SCNNode = {
let node = SCNNode(geometry: SCNPlane(width: 40, height: 40))
node.opacity = 0.001
node.castsShadow = false
return node
}()
複製代碼
這是一個懶加載的不可見節點,你將會在處理場景中的觸摸時用到它. 如今,準備開始寫關卡中的物理效果.在presentLevel() 後面,添加如下函數:
// MARK: - Creation
func createScene() {
// 1
cameraNode = levelScene.rootNode.childNode(withName: "camera", recursively: true)!
shelfNode = levelScene.rootNode.childNode(withName: "shelf", recursively: true)!
// 2
guard let canScene = SCNScene(named: "resources.scnassets/Can.scn") else { return }
baseCanNode = canScene.rootNode.childNode(withName: "can", recursively: true)!
// 3
let shelfPhysicsBody = SCNPhysicsBody(
type: .static,
shape: SCNPhysicsShape(geometry: shelfNode.geometry!)
)
shelfPhysicsBody.isAffectedByGravity = false
shelfNode.physicsBody = shelfPhysicsBody
// 4
levelScene.rootNode.addChildNode(touchCatchingPlaneNode)
touchCatchingPlaneNode.position = SCNVector3(x: 0, y: 0, z: shelfNode.position.z)
touchCatchingPlaneNode.eulerAngles = cameraNode.eulerAngles
}
複製代碼
解釋一下上面的代碼:
在viewDidLoad() 裏面的presentMenu() 後面調用它:
createScene()
複製代碼
剛纔添加的新的物理屬性並無任何可見效果,因此還須要繼續添加罐頭到場景中.
在遊戲中,罐頭將會有不少種排列來讓遊戲更難,更有趣.要實現這種效果,你須要一個重用的方法來建立罐頭,配置他們的物理性質,並將它們添加到關卡中.
先從添加下面代碼到presentLevel() 後面開始:
func setupNextLevel() {
// 1
if helper.ballNodes.count > 0 {
helper.ballNodes.removeLast()
}
// 2
let level = helper.levels[helper.currentLevel]
for idx in 0..<level.canPositions.count {
let canNode = baseCanNode.clone()
canNode.geometry = baseCanNode.geometry?.copy() as? SCNGeometry
canNode.geometry?.firstMaterial = baseCanNode.geometry?.firstMaterial?.copy() as? SCNMaterial
// 3
let shouldCreateBaseVariation = GKRandomSource.sharedRandom().nextInt() % 2 == 0
canNode.eulerAngles = SCNVector3(x: 0, y: shouldCreateBaseVariation ? -110 : 55, z: 0)
canNode.name = "Can #\(idx)"
if let materials = canNode.geometry?.materials {
for material in materials where material.multiply.contents != nil {
if shouldCreateBaseVariation {
material.multiply.contents = "resources.scnassets/Can_Diffuse-2.png"
} else {
material.multiply.contents = "resources.scnassets/Can_Diffuse-1.png"
}
}
}
let canPhysicsBody = SCNPhysicsBody(
type: .dynamic,
shape: SCNPhysicsShape(geometry: SCNCylinder(radius: 0.33, height: 1.125), options: nil)
)
canPhysicsBody.mass = 0.75
canPhysicsBody.contactTestBitMask = 1
canNode.physicsBody = canPhysicsBody
// 4
canNode.position = level.canPositions[idx]
levelScene.rootNode.addChildNode(canNode)
helper.canNodes.append(canNode)
}
}
複製代碼
以上代碼含義:
完成這些後,立刻能看到關卡中的罐頭了.在這以前,還須要建立一些關卡.
在GameHelper.swift中,你會發現一個GameLevel結構體,包含了一個簡單的屬性,表明關卡中每一個罐頭的3D座標數組.還有另外一個關卡數組,儲存着你建立的關卡.
爲了構成levels數組,要添加下面代碼到GameViewController中的setupNextLevel() 後面:
func createLevelsFrom(baseNode: SCNNode) {
// Level 1
let levelOneCanOne = SCNVector3(
x: baseNode.position.x - 0.5,
y: baseNode.position.y + 0.62,
z: baseNode.position.z
)
let levelOneCanTwo = SCNVector3(
x: baseNode.position.x + 0.5,
y: baseNode.position.y + 0.62,
z: baseNode.position.z
)
let levelOneCanThree = SCNVector3(
x: baseNode.position.x,
y: baseNode.position.y + 1.75,
z: baseNode.position.z
)
let levelOne = GameLevel(
canPositions: [
levelOneCanOne,
levelOneCanTwo,
levelOneCanThree
]
)
// Level 2
let levelTwoCanOne = SCNVector3(
x: baseNode.position.x - 0.65,
y: baseNode.position.y + 0.62,
z: baseNode.position.z
)
let levelTwoCanTwo = SCNVector3(
x: baseNode.position.x - 0.65,
y: baseNode.position.y + 1.75,
z: baseNode.position.z
)
let levelTwoCanThree = SCNVector3(
x: baseNode.position.x + 0.65,
y: baseNode.position.y + 0.62,
z: baseNode.position.z
)
let levelTwoCanFour = SCNVector3(
x: baseNode.position.x + 0.65,
y: baseNode.position.y + 1.75,
z: baseNode.position.z
)
let levelTwo = GameLevel(
canPositions: [
levelTwoCanOne,
levelTwoCanTwo,
levelTwoCanThree,
levelTwoCanFour
]
)
helper.levels = [levelOne, levelTwo]
}
複製代碼
這個函數只是建立了罐頭的位置,並將其保存在幫助類的levels數組中.
要查看你的進度,在createScene() 的底部添加下面代碼:
createLevelsFrom(baseNode: shelfNode)
複製代碼
最後在presentLevel() 的頂部添加這些代碼:
setupNextLevel()
複製代碼
編譯運行,而後點擊菜單,就能看到罐頭堆放在一塊兒,像這樣:
很好!如今有一個高效的可重用的方法,來加載關卡中的不一樣佈局了.是時候添加一個球,開始投擲出去了.
此時你還不能和遊戲進行交互;你只能盯着看這些罐頭生鏽. 在文件頭部的baseCanNode下面再添加一個節點屬性,以下:
var currentBallNode: SCNNode?
複製代碼
它將用來追蹤當前玩家正在交互的球. 下一步,在createLevelsFrom(baseNode:) 後面添加一個新的函數:
func dispenseNewBall() {
// 1
let ballScene = SCNScene(named: "resources.scnassets/Ball.scn")!
let ballNode = ballScene.rootNode.childNode(withName: "sphere", recursively: true)!
ballNode.name = "ball"
let ballPhysicsBody = SCNPhysicsBody(
type: .dynamic,
shape: SCNPhysicsShape(geometry: SCNSphere(radius: 0.35))
)
ballPhysicsBody.mass = 3
ballPhysicsBody.friction = 2
ballPhysicsBody.contactTestBitMask = 1
ballNode.physicsBody = ballPhysicsBody
ballNode.position = SCNVector3(x: -1.75, y: 1.75, z: 8.0)
ballNode.physicsBody?.applyForce(SCNVector3(x: 0.825, y: 0, z: 0), asImpulse: true)
// 2
currentBallNode = ballNode
levelScene.rootNode.addChildNode(ballNode)
複製代碼
這個函數中:
要調用這個新函數,在setupNextLevel() 末尾添加下面內容:
// Delay the ball creation on level change
let waitAction = SCNAction.wait(duration: 1.0)
let blockAction = SCNAction.run { _ in
self.dispenseNewBall()
}
let sequenceAction = SCNAction.sequence([waitAction, blockAction])
levelScene.rootNode.runAction(sequenceAction)
複製代碼
這段代碼讓第一個球延遲到關卡加載後. 這裏物體效果有一點小問題.編譯運行看看:
點擊菜單;你會看到小球掉落到場景中,而後從屏幕中掉出去了. 因爲地板目前尚未設置物理形體,因此球體並不知道本身應該彈跳落在地板上,而是穿過地板,掉落下去.
除了用代碼給地板添加物理形體處,還能夠在場景編輯器中添加.只需點擊幾下鼠標,就能讓小球正常彈跳落在地板上.
進入resources.scnassets/Level.scn並點擊地板節點.選中Physics Inspector 將Type類型改成Static, 而後將Category mask設置爲5.
這就是用SceneKit編輯器添加物理形體!其它設置項會帶來不一樣行爲,可是這個遊戲中默認設置就行了.
編譯運行,會看到小球彈跳進入並滾動到中間,準備好被扔出去的位置:
重複相同步驟,也給牆壁添加物理形體,畢竟咱們不但願球貫穿牆壁一直飛下去.
如今是時候猛擊罐頭了.添加下面的屬性到GameViewController:
// Ball throwing mechanics
var startTouchTime: TimeInterval!
var endTouchTime: TimeInterval!
var startTouch: UITouch?
var endTouch: UITouch?
複製代碼
根據觸摸開始和結束的時間能夠得出玩家移動手指的速度.從而計算出將小球扔向罐頭的速度.觸摸的位置也很是重要,由於它決定了飛行的方向是否正確.
而後在dispenseNewBall() 後面添加下面的函數:
func throwBall() {
guard let ballNode = currentBallNode else { return }
guard let endingTouch = endTouch else { return }
// 1
let firstTouchResult = scnView.hitTest(
endingTouch.location(in: view),
options: nil
).filter({
$0.node == touchCatchingPlaneNode
}).first
guard let touchResult = firstTouchResult else { return }
// 2
levelScene.rootNode.runAction(
SCNAction.playAudio(
helper.whooshAudioSource,
waitForCompletion: false
)
)
// 3
let timeDifference = endTouchTime - startTouchTime
let velocityComponent = Float(min(max(1 - timeDifference, 0.1), 1.0))
// 4
let impulseVector = SCNVector3(
x: touchResult.localCoordinates.x,
y: touchResult.localCoordinates.y * velocityComponent * 3,
z: shelfNode.position.z * velocityComponent * 15
)
ballNode.physicsBody?.applyForce(impulseVector, asImpulse: true)
helper.ballNodes.append(ballNode)
// 5
currentBallNode = nil
startTouchTime = nil
endTouchTime = nil
startTouch = nil
endTouch = nil
}
複製代碼
在這個函數中:
爲了讓這個函數起做用,你須要遊戲中的觸摸事件處理. 將整個touchesBegan(_:with:) 替換爲:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
if helper.state == .tapToPlay {
presentLevel()
} else {
guard let firstTouch = touches.first else { return }
let point = firstTouch.location(in: scnView)
let hitResults = scnView.hitTest(point, options: [:])
if hitResults.first?.node == currentBallNode {
startTouch = touches.first
startTouchTime = Date().timeIntervalSince1970
}
}
}
複製代碼
在觸摸開始時,若是遊戲是可玩狀態,且觸摸是在當前球上,那麼記錄觸摸起點. 接着,替換touchesEnded(_: with:) 爲:
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
guard startTouch != nil else { return }
endTouch = touches.first
endTouchTime = Date().timeIntervalSince1970
throwBall()
}
複製代碼
當玩家手指離開屏幕,你須要保存觸摸結束點及時間,由於它們決定了投擲方向是否正確. 編譯運行,試着擊倒這些罐頭:
若是你的準頭好的話,你可能把全部罐頭都擊倒在地面上了.可是你尚未完成,當全部罐頭撞擊地面後你應該能夠進入下一關了.
SceneKit處理這種碰撞檢測很是容易.SCNPhysicsContactDelegate協議定義了幾個有用的碰撞處理函數:
它們都頗有用,但這個遊戲中咱們只須要用到physicsWorld(_:didBeginContact:).
當小球與其它節點碰撞時,你確定會想要根據碰撞節點的類型來播放一些碰撞音效.還有罐頭碰撞地面時,須要增長分數.
首先,給GameViewController添加下面的屬性:
var bashedCanNames: [String] = []
複製代碼
你將用這個來記錄已經碰撞過的罐頭.
開始處理碰撞,在GameViewController.swift底部添加下面的擴展:
extension GameViewController: SCNPhysicsContactDelegate {
// MARK: SCNPhysicsContactDelegate
func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
guard let nodeNameA = contact.nodeA.name else { return }
guard let nodeNameB = contact.nodeB.name else { return }
// 1
var ballFloorContactNode: SCNNode?
if nodeNameA == "ball" && nodeNameB == "floor" {
ballFloorContactNode = contact.nodeA
} else if nodeNameB == "ball" && nodeNameA == "floor" {
ballFloorContactNode = contact.nodeB
}
if let ballNode = ballFloorContactNode {
// 2
guard ballNode.action(forKey: GameHelper.ballFloorCollisionAudioKey) == nil else { return }
ballNode.runAction(
SCNAction.playAudio(
helper.ballFloorAudioSource,
waitForCompletion: true
),
forKey: GameHelper.ballFloorCollisionAudioKey
)
return
}
// 3
var ballCanContactNode: SCNNode?
if nodeNameA.contains("Can") && nodeNameB == "ball" {
ballCanContactNode = contact.nodeA
} else if nodeNameB.contains("Can") && nodeNameA == "ball" {
ballCanContactNode = contact.nodeB
}
if let canNode = ballCanContactNode {
guard canNode.action(forKey: GameHelper.ballCanCollisionAudioKey) == nil else {
return
}
canNode.runAction(
SCNAction.playAudio(
helper.ballCanAudioSource,
waitForCompletion: true
),
forKey: GameHelper.ballCanCollisionAudioKey
)
return
}
// 4
if bashedCanNames.contains(nodeNameA) || bashedCanNames.contains(nodeNameB) { return }
// 5
var canNodeWithContact: SCNNode?
if nodeNameA.contains("Can") && nodeNameB == "floor" {
canNodeWithContact = contact.nodeA
} else if nodeNameB.contains("Can") && nodeNameA == "floor" {
canNodeWithContact = contact.nodeB
}
// 6
if let bashedCan = canNodeWithContact {
bashedCan.runAction(
SCNAction.playAudio(
helper.canFloorAudioSource,
waitForCompletion: false
)
)
bashedCanNames.append(bashedCan.name!)
helper.score += 1
}
}
}
複製代碼
這段代碼中:
會有不少碰撞發生---不少須要處理!
在physicsWorld(_:didBegin:) 底單添加下面的代碼:
// 1
if bashedCanNames.count == helper.canNodes.count {
// 2
if levelScene.rootNode.action(forKey: GameHelper.gameEndActionKey) != nil {
levelScene.rootNode.removeAction(forKey: GameHelper.gameEndActionKey)
}
let maxLevelIndex = helper.levels.count - 1
// 3
if helper.currentLevel == maxLevelIndex {
helper.currentLevel = 0
} else {
helper.currentLevel += 1
}
// 4
let waitAction = SCNAction.wait(duration: 1.0)
let blockAction = SCNAction.run { _ in
self.setupNextLevel()
}
let sequenceAction = SCNAction.sequence([waitAction, blockAction])
levelScene.rootNode.runAction(sequenceAction)
}
複製代碼
代碼作的是:
爲了讓接觸代理正常工做,在createScene() 頂部添加下面的代碼:
levelScene.physicsWorld.contactDelegate = self
複製代碼
最後添加下面代碼到presentLevel() 以後:
func resetLevel() {
// 1
currentBallNode?.removeFromParentNode()
// 2
bashedCanNames.removeAll()
// 3
for canNode in helper.canNodes {
canNode.removeFromParentNode()
}
helper.canNodes.removeAll()
// 4
for ballNode in helper.ballNodes {
ballNode.removeFromParentNode()
}
}
複製代碼
這段代碼在玩家晉級一關後,幫助清理記錄狀態.作的是:
你須要在好幾個地方調用這個函數.在presentLevel() 頂部添加下面代碼:
resetLevel()
複製代碼
用下面代碼替換physicsWorld(_:didBegin:) 中移動到下一關的blockAction:
let blockAction = SCNAction.run { _ in
self.resetLevel()
self.setupNextLevel()
}
複製代碼
編譯運行遊戲;終於能夠玩遊戲了!試着只用一個球就打落全部罐頭!
你不能期望每一個玩家都能一擊過關.下個任務是實現一個HUD,這樣玩家就能看到他們的分數和剩餘球數.
在createScene() 末尾添加下面代碼:
levelScene.rootNode.addChildNode(helper.hudNode)
複製代碼
如今玩家就能看到他們的得分,以及剩餘球數.你仍然須要一個方法來判斷是掉落下一個球仍是結束遊戲.
在throwBall() 的末尾添加下面幾行:
if helper.ballNodes.count == GameHelper.maxBallNodes {
let waitAction = SCNAction.wait(duration: 3)
let blockAction = SCNAction.run { _ in
self.resetLevel()
self.helper.ballNodes.removeAll()
self.helper.currentLevel = 0
self.helper.score = 0
self.presentMenu()
}
let sequenceAction = SCNAction.sequence([waitAction, blockAction])
levelScene.rootNode.runAction(sequenceAction, forKey: GameHelper.gameEndActionKey)
} else {
let waitAction = SCNAction.wait(duration: 0.5)
let blockAction = SCNAction.run { _ in
self.dispenseNewBall()
}
let sequenceAction = SCNAction.sequence([waitAction, blockAction])
levelScene.rootNode.runAction(sequenceAction)
}
複製代碼
這個if語句處理玩家投擲完最後一球的狀況.它給了他們三秒的延時,來讓最後一個或兩個罐頭從架子上掉落下來.另外一種狀況,一旦玩家投完一球,你就會在一段延時以後從新掉落一個新的球,讓他們有機會繼續砸其它罐頭!
最後一個改善點是,要顯示玩家的最高分數,以便他們展現給朋友們看
添加下面代碼到presentMenu() 中,放在helper.state = .tapToPlay以後:
helper.menuLabelNode.text = "Highscore: \(helper.highScore)"
複製代碼
這段代碼刷新菜單的HUD,這樣玩家就能看到他們的最高分了!
所有完成!運行試試你能不能戰勝本身的高分?
本教程中的最終完成版項目能夠看這裏here.