說(shuō)明
更多iOS相關(guān)知識(shí)查看github上WeekWeekUpProject
本教程將包含以下內(nèi)容:
- 在SceneKit編輯器中建立基本的3D場(chǎng)景.
- 編程加載并呈現(xiàn)3D場(chǎng)景.
- 建立仿真物理,如何應(yīng)用力.
- 通過觸摸與3D場(chǎng)景中的物體交互.
- 設(shè)計(jì)并實(shí)現(xiàn)基本的碰撞檢測(cè).
開始
開始前,先下載初始項(xiàng)目starter project
打開項(xiàng)目,簡(jiǎn)單查看一下里面都有些什么.你會(huì)發(fā)現(xiàn)球和罐頭的素材,還有一個(gè)GameHelper文件能提供一些有用的函數(shù).
創(chuàng)建并運(yùn)行,看上去一片黑:
不要難過,這只是一個(gè)干凈的工作臺(tái)供你開始.
建立并彈出菜單
在開始砸罐頭之前,需要給游戲添加一個(gè)菜單選項(xiàng).打開GameViewController.swift并添加一個(gè)新的屬性:
// Scene properties
var menuScene = SCNScene(named: "resources.scnassets/Menu.scn")!
這段代碼將加載菜單場(chǎng)景.你將可以使用menuScene來(lái)實(shí)現(xiàn)菜單和等級(jí)場(chǎng)景之間的跳轉(zhuǎn).
要彈出菜單場(chǎng)景,需要在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
)
}
這個(gè)函數(shù)配置了菜單場(chǎng)景中的抬頭顯示節(jié)點(diǎn)(HUD),并通過present(scene:with:incomingPointOfView:completionHandler:)交叉淡出的轉(zhuǎn)場(chǎng).
在viewDidLoad()底部添加調(diào)用presentMenu():
override func viewDidLoad() {
super.viewDidLoad()
presentMenu()
}
編譯運(yùn)行,會(huì)看到這樣的菜單場(chǎng)景:
在場(chǎng)景編輯器中創(chuàng)建等級(jí)
打開resources.scnassets/Level.scn場(chǎng)景:
從對(duì)象庫(kù)中拖入一個(gè)Floor節(jié)點(diǎn)到場(chǎng)景中:
在右側(cè)的Attributes Inspector中將Reflectivity改為0.05,這樣地板就有了輕微反射.
選擇Material Inspector并設(shè)置wood-floor.jpg為Diffuse紋理.設(shè)置Offset為(x: 0, y: 0.2),設(shè)置Scale為(x: 15, y: 15),最后,設(shè)置Rotation為90度:
現(xiàn)在地板已經(jīng)放置好了,還需要再添加磚墻作為背景.墻的幾何體已經(jīng)在Wall.scn場(chǎng)景里為你配置好了.用Reference Node引用節(jié)點(diǎn)將其添加到等級(jí)場(chǎng)景中.
在Level.scn場(chǎng)景中,從媒體庫(kù)中拖拽一個(gè)Wall引用節(jié)點(diǎn)到場(chǎng)景中.
在Node Inspector中設(shè)置節(jié)點(diǎn)名字為wall并設(shè)置位置為(x: 0, y: 0, z: -5).
下一步,你需要一個(gè)點(diǎn)來(lái)堆放罐頭.從Object Library對(duì)象庫(kù)中拖放一個(gè)Box命名為shelf,并放置到(x: 0.0, y: 2.25, z: -2.25)處,正好在墻的前面.
在Attributes Inspector中設(shè)置Width為10,Height為0.25.最后,在Material Inspector中,設(shè)置Diffuse為wood-table.png,打開附加屬性,設(shè)置WrapS和WrapT為Repeat,設(shè)置Scale為(x: 2, y: 2).使紋理充滿整個(gè)盒子,讓它看起來(lái)像是一個(gè)真的架子.
為了完成這個(gè)關(guān)卡,還需要添加一對(duì)燈光和一個(gè)攝像機(jī).從對(duì)象庫(kù)中拖放一個(gè)Spot light點(diǎn)光源,設(shè)置Position為(x: 8.3, y: 13.5, z: 15.0),Euler為(x: -40, y: 28, z: 0). 這樣就將點(diǎn)光源放置在空中,朝向場(chǎng)景中的焦點(diǎn)--架子.
在Attributes Inspector中, 設(shè)置Inner Angle為35,Outer Angle為85.這讓燈光更柔和,也擴(kuò)展了點(diǎn)光源錐體,擴(kuò)大了場(chǎng)景中照亮的范圍.
最后,在Shadow下面, 設(shè)置Sample radius為4,Sample count為1,并設(shè)置Color為黑色,透明度50%.讓會(huì)讓點(diǎn)光源投射出柔和的陰影:
為了淡化黑色的陰影,添加環(huán)境光照,拖放一個(gè)Ambient light到場(chǎng)景中.默認(rèn)設(shè)置就可以了.
最后,你必須添加一個(gè)攝像機(jī)到場(chǎng)景中,來(lái)給游戲一個(gè)透視視角.拖放一個(gè)Carmera到場(chǎng)景中.Position在(x: 0.0, y: 2.5, z: 14.0),Rotation為(x: -5, y:0 , z:0). 在Attributes Inspector中, 將Y fov改為45.
很好!這樣關(guān)卡設(shè)計(jì)就完成了.看看起來(lái)像這樣:
加載關(guān)呈現(xiàn)關(guān)卡
在Level.scn中已經(jīng)有一關(guān)了,那么怎么在設(shè)備上查看它呢?
在GameViewController中menuScene屬性下面添加一行:
var levelScene = SCNScene(named: "resources.scnassets/Level.scn")!
這段代碼加載了場(chǎng)景,并讓你能夠訪問關(guān)卡中的所有節(jié)點(diǎn).
現(xiàn)在,為了呈現(xiàn)這一關(guān)的場(chǎng)景,在presentMenu()后面添加下面的函數(shù):
func presentLevel() {
helper.state = .playing
let transition = SKTransition.crossFade(withDuration: 1.0)
scnView.present(
levelScene,
with: transition,
incomingPointOfView: nil,
completionHandler: nil
)
}
該函數(shù)設(shè)置游戲狀態(tài)為.playing,然后以交叉淡入的轉(zhuǎn)場(chǎng)效果呈現(xiàn)中關(guān)卡場(chǎng)景,類似于在菜單場(chǎng)景中做的那樣.
在touchesBegan(_:with:)方法最后面添加下面的代碼:
if helper.state == .tapToPlay {
presentLevel()
}
這樣,當(dāng)你點(diǎn)擊菜單場(chǎng)景時(shí),游戲就會(huì)開始.
編譯運(yùn)行,然后點(diǎn)擊菜單場(chǎng)景,會(huì)看到你設(shè)計(jì)的關(guān)卡淡入:
SceneKit中的物理效果
用SceneKit中創(chuàng)建游戲的一大好處就是,能夠非常簡(jiǎn)單就利用內(nèi)置的物理引擎來(lái)實(shí)現(xiàn)真實(shí)的物理效果.
為一個(gè)節(jié)點(diǎn)啟用物理效果,你只需要給它添加physics body物理形體,并配置它的屬性就可以了.你可以改變?nèi)舾蓞?shù)來(lái)模擬一個(gè)真實(shí)世界的物體;用到的最常見屬性是形狀,質(zhì)量,摩擦因子,阻尼系數(shù)和回彈系數(shù).
在該游戲中,你會(huì)用到物理效果和力來(lái)把球扔到罐頭處.罐頭將會(huì)有物理形體,來(lái)模擬空的鋁罐.你的排球會(huì)很重,能猛擊較輕的罐頭,并都掉落在地板上.
動(dòng)態(tài)地給關(guān)卡添加物理效果
在給游戲添加物理效果之前,你需要訪問場(chǎng)景編輯器中創(chuàng)建的節(jié)點(diǎn).為此,在GameViewController中場(chǎng)景屬性后面添加下面幾行:
// Node properties
var cameraNode: SCNNode!
var shelfNode: SCNNode!
var baseCanNode: SCNNode!
你需要這些節(jié)點(diǎn)來(lái)布局罐頭,配置物理形體,定位場(chǎng)景中的其它節(jié)點(diǎn).
下一步,在scnView計(jì)算屬性后面添加以下代碼:
// 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
}()
這是一個(gè)懶加載的不可見節(jié)點(diǎn),你將會(huì)在處理場(chǎng)景中的觸摸時(shí)用到它.
現(xiàn)在,準(zhǔn)備開始寫關(guān)卡中的物理效果.在presentLevel()后面,添加以下函數(shù):
// 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
}
解釋一下上面的代碼:
- 先找到在場(chǎng)景編輯器中創(chuàng)建的節(jié)點(diǎn),并賦值給camera和shelf屬性.
- 接著給baseCanNode賦值一個(gè)從預(yù)先創(chuàng)建的罐頭場(chǎng)景中加載出來(lái)的節(jié)點(diǎn).
- 創(chuàng)建靜態(tài)物理形體給架子,并添加到shelfNode上去.
- 最后,放置好這個(gè)不可見的觸摸捕捉節(jié)點(diǎn),正對(duì)場(chǎng)景中的攝像機(jī).
在viewDidLoad()里面的presentMenu()后面調(diào)用它:
createScene()
剛才添加的新的物理屬性并沒有任何可見效果,所以還需要繼續(xù)添加罐頭到場(chǎng)景中.
創(chuàng)建罐頭
在游戲中,罐頭將會(huì)有很多種排列來(lái)讓游戲更難,更有趣.要實(shí)現(xiàn)這種效果,你需要一個(gè)重用的方法來(lái)創(chuàng)建罐頭,配置他們的物理性質(zhì),并將它們添加到關(guān)卡中.
先從添加下面代碼到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)
}
}
以上代碼含義:
- 如果玩家完成了前一個(gè)關(guān)卡,意味著他們還有球剩余,那他們可以再得到一個(gè)球做為獎(jiǎng)勵(lì).
- 你循環(huán)遍歷每個(gè)罐在當(dāng)前關(guān)卡中的位置,通過克隆baseCanNode來(lái)創(chuàng)建并配置罐.你會(huì)在下一步中明白,什么是罐頭的定位.
- 這里創(chuàng)建一個(gè)隨機(jī)布爾值,來(lái)確定罐頭有什么紋理和旋轉(zhuǎn)角度.
- 每個(gè)罐頭的位置,通過儲(chǔ)存在canPositions中的數(shù)據(jù)來(lái)決定.
完成這些后,馬上能看到關(guān)卡中的罐頭了.在這之前,還需要?jiǎng)?chuàng)建一些關(guān)卡.
在GameHelper.swift中,你會(huì)發(fā)現(xiàn)一個(gè)GameLevel結(jié)構(gòu)體,包含了一個(gè)簡(jiǎn)單的屬性,代表關(guān)卡中每個(gè)罐頭的3D坐標(biāo)數(shù)組.還有另一個(gè)關(guān)卡數(shù)組,儲(chǔ)存著你創(chuàng)建的關(guān)卡.
為了構(gòu)成levels數(shù)組,要添加下面代碼到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]
}
這個(gè)函數(shù)只是創(chuàng)建了罐頭的位置,并將其保存在幫助類的levels數(shù)組中.
要查看你的進(jìn)度,在createScene()的底部添加下面代碼:
createLevelsFrom(baseNode: shelfNode)
最后在presentLevel()的頂部添加這些代碼:
setupNextLevel()
編譯運(yùn)行,然后點(diǎn)擊菜單,就能看到罐頭堆放在一起,像這樣:
很好!現(xiàn)在有一個(gè)高效的可重用的方法,來(lái)加載關(guān)卡中的不同布局了.是時(shí)候添加一個(gè)球,開始投擲出去了.
添加球體
此時(shí)你還不能和游戲進(jìn)行交互;你只能盯著看這些罐頭生銹.
在文件頭部的baseCanNode下面再添加一個(gè)節(jié)點(diǎn)屬性,如下:
var currentBallNode: SCNNode?
它將用來(lái)追蹤當(dāng)前玩家正在交互的球.
下一步,在createLevelsFrom(baseNode:)后面添加一個(gè)新的函數(shù):
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)
這個(gè)函數(shù)中:
- 你從Ball.scn中創(chuàng)建一個(gè)球,并配置其物理形體來(lái)模擬一個(gè)棒球.
- 在球的位置確定后,使用一個(gè)初始的力來(lái)使球從左側(cè)進(jìn)入視圖.
要調(diào)用這個(gè)新函數(shù),在setupNextLevel()末尾添加下面內(nèi)容:
// 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)
這段代碼讓第一個(gè)球延遲到關(guān)卡加載后.
這里物體效果有一點(diǎn)小問題.編譯運(yùn)行看看:
點(diǎn)擊菜單;你會(huì)看到小球掉落到場(chǎng)景中,然后從屏幕中掉出去了.
由于地板目前還沒有設(shè)置物理形體,所以球體并不知道自己應(yīng)該彈跳落在地板上,而是穿過地板,掉落下去.
除了用代碼給地板添加物理形體處,還可以在場(chǎng)景編輯器中添加.只需點(diǎn)擊幾下鼠標(biāo),就能讓小球正常彈跳落在地板上.
用SceneKit編輯器添加物體形體
進(jìn)入resources.scnassets/Level.scn并點(diǎn)擊地板節(jié)點(diǎn).選中Physics Inspector 將Type類型改為Static, 然后將Category mask設(shè)置為5.
這就是用SceneKit編輯器添加物理形體!其它設(shè)置項(xiàng)會(huì)帶來(lái)不同行為,但是這個(gè)游戲中默認(rèn)設(shè)置就好了.
編譯運(yùn)行,會(huì)看到小球彈跳進(jìn)入并滾動(dòng)到中間,準(zhǔn)備好被扔出去的位置:
重復(fù)相同步驟,也給墻壁添加物理形體,畢竟我們不希望球貫穿墻壁一直飛下去.
投擲小球
現(xiàn)在是時(shí)候猛擊罐頭了.添加下面的屬性到GameViewController:
// Ball throwing mechanics
var startTouchTime: TimeInterval!
var endTouchTime: TimeInterval!
var startTouch: UITouch?
var endTouch: UITouch?
根據(jù)觸摸開始和結(jié)束的時(shí)間可以得出玩家移動(dòng)手指的速度.從而計(jì)算出將小球扔向罐頭的速度.觸摸的位置也非常重要,因?yàn)樗鼪Q定了飛行的方向是否正確.
然后在dispenseNewBall()后面添加下面的函數(shù):
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
}
在這個(gè)函數(shù)中:
- 首先,用了點(diǎn)擊測(cè)試來(lái)得到觸摸的節(jié)點(diǎn).
- 接著,播放嗖的音效作為音頻的反饋.
- 根據(jù)觸摸開始和結(jié)束的時(shí)間計(jì)算速度.
- 然后創(chuàng)建一個(gè)矢量,從被觸摸物體的本地坐標(biāo)到架子的位置,用速度大小做為矢量長(zhǎng)度.
- 最后,清理投擲屬性,準(zhǔn)備下次投擲.
為了讓這個(gè)函數(shù)起作用,你需要游戲中的觸摸事件處理.
將整個(gè)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
}
}
}
在觸摸開始時(shí),如果游戲是可玩狀態(tài),且觸摸是在當(dāng)前球上,那么記錄觸摸起點(diǎn).
接著,替換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()
}
當(dāng)玩家手指離開屏幕,你需要保存觸摸結(jié)束點(diǎn)及時(shí)間,因?yàn)樗鼈儧Q定了投擲方向是否正確.
編譯運(yùn)行,試著擊倒這些罐頭:
碰撞檢測(cè)
如果你的準(zhǔn)頭好的話,你可能把所有罐頭都擊倒在地面上了.但是你還沒有完成,當(dāng)所有罐頭撞擊地面后你應(yīng)該可以進(jìn)入下一關(guān)了.
SceneKit處理這種碰撞檢測(cè)非常容易.SCNPhysicsContactDelegate協(xié)議定義了幾個(gè)有用的碰撞處理函數(shù):
- physicsWorld(_:didBegin:):該方法在兩個(gè)物體形體相互接觸時(shí)調(diào)用.
- physicsWorld(_:didUpdate:):該方法在接觸開始后調(diào)用,并提供關(guān)于兩物體碰撞進(jìn)展的附加信息.
- physicsWorld(_:didEnd:):該方法在兩物體接觸停止后調(diào)用.
它們都很有用,但這個(gè)游戲中我們只需要用到physicsWorld(_:didBeginContact:).
添加碰撞檢測(cè)
當(dāng)小球與其它節(jié)點(diǎn)碰撞時(shí),你肯定會(huì)想要根據(jù)碰撞節(jié)點(diǎn)的類型來(lái)播放一些碰撞音效.還有罐頭碰撞地面時(shí),需要增加分?jǐn)?shù).
首先,給GameViewController添加下面的屬性:
var bashedCanNames: [String] = []
你將用這個(gè)來(lái)記錄已經(jīng)碰撞過的罐頭.
開始處理碰撞,在GameViewController.swift底部添加下面的擴(kuò)展:
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
}
}
}
這段代碼中:
- 首先,檢測(cè)碰撞是不是發(fā)生在球和地板之間.
- 如果球碰到了地板,播放音效.
- 如果小球沒有與地板接觸,就判斷小球是否與罐頭接觸.如果接觸,播放另一段音效.
- 如果當(dāng)前的罐頭已經(jīng)與地板碰撞過,不需要處理,因?yàn)槟阋呀?jīng)處理過了.
- 檢查罐頭是否與地板碰撞.
- 如果罐頭接觸到地板,記錄罐頭的名字,來(lái)確保這個(gè)罐頭的碰撞只處理了一次.當(dāng)新的罐頭碰撞到地板時(shí)增加分?jǐn)?shù).
會(huì)有很多碰撞發(fā)生---很多需要處理!
在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)
}
代碼做的是:
- 如果被撞掉下來(lái)的罐頭數(shù)量和本關(guān)的罐頭數(shù)量一致,我們進(jìn)入下一關(guān).
- 移除舊游戲結(jié)束動(dòng)作.
- 一旦最后一關(guān)完成,循環(huán)各個(gè)關(guān)卡,因?yàn)楸居螒蚴菫榱双@取最高分.
- 在短暫的延遲后加載下一關(guān)卡.
為了讓接觸代理正常工作,在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()
}
}
這段代碼在玩家晉級(jí)一關(guān)后,幫助清理記錄狀態(tài).做的是:
- 如果有當(dāng)前的球,移除它.
- 移除所有在接觸代理中用過的掉落罐頭節(jié)點(diǎn)名稱.
- 循環(huán)罐頭節(jié)點(diǎn),從它們的父節(jié)點(diǎn)移除,然后清理數(shù)組.
- 移除每個(gè)小球節(jié)點(diǎn)
你需要在好幾個(gè)地方調(diào)用這個(gè)函數(shù).在presentLevel()頂部添加下面代碼:
resetLevel()
用下面代碼替換physicsWorld(_:didBegin:)中移動(dòng)到下一關(guān)的blockAction:
let blockAction = SCNAction.run { _ in
self.resetLevel()
self.setupNextLevel()
}
編譯運(yùn)行游戲;終于可以玩游戲了!試著只用一個(gè)球就打落所有罐頭!
你不能指望每個(gè)玩家都能一擊過關(guān).下個(gè)任務(wù)是實(shí)現(xiàn)一個(gè)HUD,這樣玩家就能看到他們的分?jǐn)?shù)和剩余球數(shù).
改善游戲性
在createScene()末尾添加下面代碼:
levelScene.rootNode.addChildNode(helper.hudNode)
現(xiàn)在玩家就能看到他們的得分,以及剩余球數(shù).你仍然需要一個(gè)方法來(lái)判斷是掉落下一個(gè)球還是結(jié)束游戲.
在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)
}
這個(gè)if語(yǔ)句處理玩家投擲完最后一球的情況.它給了他們?nèi)氲难訒r(shí),來(lái)讓最后一個(gè)或兩個(gè)罐頭從架子上掉落下來(lái).另一種情況,一旦玩家投完一球,你就會(huì)在一段延時(shí)之后重新掉落一個(gè)新的球,讓他們有機(jī)會(huì)繼續(xù)砸其它罐頭!
最后一個(gè)改善點(diǎn)是,要顯示玩家的最高分?jǐn)?shù),以便他們展示給朋友們看
添加下面代碼到presentMenu()中,放在helper.state = .tapToPlay之后:
helper.menuLabelNode.text = "Highscore: \(helper.highScore)"
這段代碼刷新菜單的HUD,這樣玩家就能看到他們的最高分了!
全部完成!運(yùn)行試試你能不能打敗自己的高分?
本教程中的最終完成版項(xiàng)目可以看這里here.