說明
更多iOS相關知識檢視github上WeekWeekUpProject
本教程將包含以下內容:
- 在SceneKit編輯器中建立基本的3D場景.
- 程式設計載入並呈現3D場景.
- 建立模擬物理,如何應用力.
- 通過觸控與3D場景中的物體互動.
- 設計並實現基本的碰撞檢測.
開始
開始前,先下載初始專案starter project 開啟專案,簡單檢視一下里面都有些什麼.你會發現球和罐頭的素材,還有一個GameHelper檔案能提供一些有用的函式. 建立並執行,看上去一片黑:
不要難過,這只是一個乾淨的工作臺供你開始.
建立並彈出選單
在開始砸罐頭之前,需要給遊戲新增一個選單選項.開啟GameViewController.swift並新增一個新的屬性:
// Scene properties
var menuScene = SCNScene(named: "resources.scnassets/Menu.scn")!
複製程式碼
這段程式碼將載入選單場景.你將可以使用menuScene來實現選單和等級場景之間的跳轉. 要彈出選單場景,需要在**viewDidLoad()**裡新增下列程式碼:
// 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:) 交叉淡出的轉場.
在viewDidLoad() 底部新增呼叫presentMenu():
override func viewDidLoad() {
super.viewDidLoad()
presentMenu()
}
複製程式碼
編譯執行,會看到這樣的選單場景:
在場景編輯器中建立等級
開啟resources.scnassets/Level.scn場景:
從物件庫中拖入一個Floor節點到場景中:
在右側的Attributes Inspector中將Reflectivity改為0.05,這樣地板就有了輕微反射.選擇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中的物理效果
用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
}
複製程式碼
解釋一下上面的程式碼:
- 先找到在場景編輯器中建立的節點,並賦值給camera和shelf屬性.
- 接著給baseCanNode賦值一個從預先建立的罐頭場景中載入出來的節點.
- 建立靜態物理形體給架子,並新增到shelfNode上去.
- 最後,放置好這個不可見的觸控捕捉節點,正對場景中的攝像機.
在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)
}
}
複製程式碼
以上程式碼含義:
- 如果玩家完成了前一個關卡,意味著他們還有球剩餘,那他們可以再得到一個球做為獎勵.
- 你迴圈遍歷每個罐在當前關卡中的位置,通過克隆baseCanNode來建立並配置罐.你會在下一步中明白,什麼是罐頭的定位.
- 這裡建立一個隨機布林值,來確定罐頭有什麼紋理和旋轉角度.
- 每個罐頭的位置,通過儲存在canPositions中的資料來決定.
完成這些後,馬上能看到關卡中的罐頭了.在這之前,還需要建立一些關卡.
在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)
複製程式碼
這個函式中:
- 你從Ball.scn中建立一個球,並配置其物理形體來模擬一個棒球.
- 在球的位置確定後,使用一個初始的力來使球從左側進入檢視.
要呼叫這個新函式,在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)
複製程式碼
這段程式碼讓第一個球延遲到關卡載入後. 這裡物體效果有一點小問題.編譯執行看看:
點選選單;你會看到小球掉落到場景中,然後從螢幕中掉出去了. 由於地板目前還沒有設定物理形體,所以球體並不知道自己應該彈跳落在地板上,而是穿過地板,掉落下去.
除了用程式碼給地板新增物理形體處,還可以在場景編輯器中新增.只需點選幾下滑鼠,就能讓小球正常彈跳落在地板上.
用SceneKit編輯器新增物體形體
進入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(_:didBegin:):該方法在兩個物體形體相互接觸時呼叫.
- physicsWorld(_:didUpdate:):該方法在接觸開始後呼叫,並提供關於兩物體碰撞進展的附加資訊.
- physicsWorld(_:didEnd:):該方法在兩物體接觸停止後呼叫.
它們都很有用,但這個遊戲中我們只需要用到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.