說明
更多iOS相關知識檢視github上WeekWeekUpProject
在本教程中,你將會學習如何製作一個類似Stack這樣的遊戲.
本教程將包含以下內容:
- 視覺化建立3D場景.
- 程式設計載入並呈現3D場景.
- 使用節點的物理形體.
- 結合使用UIKit與SceneKit.
- 在SceneKit遊戲中播放音訊.
開始
下載初始專案starter project. 在初始專案裡面,你會發現SceneKit目錄檔案中帶有音訊和場景檔案.另外,還有一些SCNVector3類擴充套件來執行簡單的向量算術運算及生成漸變圖片.還有App Icon也已經新增進去了!花點時間熟悉一下專案吧.
你將會建立一個類似於Stack的遊戲.這個遊戲的目標是在一塊方塊上疊放另一個方塊.需要小心的是:方塊疊放時稍微偏一點,多餘部分就會被切掉.完全沒對齊,那就game over了!
建立場景
你將從建立你的遊戲場景開始.開啟GameScene.scn.
拖拽一個新的camera到場景中,然後選擇Node Inspector並重新命名節點為Main Camera.設定位置為X: 3, Y: 4.5, Z: 3,旋轉為X: -40, Y: 45, Z:0:切換到Attributes Inspector並切換相機的Projection type為Orthographic. 下一步,新增燈光到場景中. 從物件庫中拖拽一個新的方向光到場景中,命名為Directional Light.因為相機只看到了場景的一側,你不必去照亮看不見的令一側.回到Attributes Inspector,設定位置為X: 0, Y: 0, Z: 0,旋轉為X: -65, Y: 20, Z:-30:
神奇,亮起來了!
現在回到塔的頂部.你需要一個基礎方塊來支承這個塔,來讓玩家在上面建造.拖拽一個盒子到場景中,設定屬性:
- 在Node Inspector中,更改名字為Base Block,並設定位置為X:0,Y:-4,Z:0.
- 在Attributes Inspector中,更改尺寸為Width: 1, Height: 8, Length: 1.
- 在Material Inspector中,更改漫反射顏色為 #434343.
你需要新增一個動態形體到基礎方塊,切換到Physics Inspector中,並將物理形體改為Static.
現在讓我們配上漂亮的背景顏色!在選中基礎方塊的同時,切換到Scene Inspector,並拖拽檔案Gradient.png到背景選擇框中:
你需要一個方法來顯示給玩家,他們的塔已經堆放了多高.開啟Main.storyboard;看到它已經有一個SCNView.新增一個label在SCNView頂部並設定文字為0.然後新增一個約束將label對齊到中心,像這樣:
新增另一個約束將label頂部與螢幕頂部對齊.
然後切換到Attributes Inspector中,切換字型為Custom, Thonburi, Regular, 50.
然後使用assistant editor來新增一個從label到控制器的引用,命名為scoreLabel:
編譯執行,看看現在有什麼了.
新增你的第一塊方塊
知道怎麼讓塔越來越高麼?對,建立更多方塊. 建立一些屬性來幫你追蹤正在使用的方塊.為此,開啟ViewController.swift() 並在viewDidLoad() 之前新增下面變數:
//1
var direction = true
var height = 0
//2
var previousSize = SCNVector3(1, 0.2, 1)
var previousPosition = SCNVector3(0, 0.1, 0)
var currentSize = SCNVector3(1, 0.2, 1)
var currentPosition = SCNVector3Zero
//3
var offset = SCNVector3Zero
var absoluteOffset = SCNVector3Zero
var newSize = SCNVector3Zero
//4
var perfectMatches = 0
複製程式碼
這段程式碼含義:
- direction用來表示方塊的位置是上升還是下降,height變數表示塔有多高.
- previousSize和previousPosition變數表示當前層的尺寸和位置.
- 你需要使用offset,absoluteOffset,newSize變數來計算新層的尺寸.
- perfectMatches變數表示玩家完美對齊上一層的次數.
現在,是時間新增方塊到場景中了.在viewDidLoad() 底部新增下面程式碼:
//1
let blockNode = SCNNode(geometry: SCNBox(width: 1, height: 0.2, length: 1, chamferRadius: 0))
blockNode.position.z = -1.25
blockNode.position.y = 0.1
blockNode.name = "Block\(height)"
//2
blockNode.geometry?.firstMaterial?.diffuse.contents =
UIColor(colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
scnScene.rootNode.addChildNode(blockNode)
複製程式碼
程式碼含義:
- 你用SCNNode的立方體建立一個新的方塊,放置在Z軸和Y軸,並根據放置在塔上的height屬性對其命名.
- 根據不斷增長的高度,計算得出漫反射顏色的紅色分量.最後,將節點新增到場景上.
建立並執行,會看到你的新方塊出現在螢幕上!
移動方塊
現在已經有一條新的方塊用來放置.但是,我想如果方塊是移動的會更好玩. 要實現這個移動,需要設定控制器作為場景渲染代理,並實現SCNSceneRendererDelegate協議中的方法. 在類的底部新增這個擴充套件:
extension ViewController: SCNSceneRendererDelegate {
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
}
}
複製程式碼
這裡我們需要實現SCNSceneRendererDelegate協議,新增renderer(_:updateAtTime:). 在renderer(_:updateAtTime:) 裡面新增下面程式碼:
// 1
if let currentNode = scnScene.rootNode.childNode(withName: "Block\(height)", recursively: false) {
// 2
if height % 2 == 0 {
// 3
if currentNode.position.z >= 1.25 {
direction = false
} else if currentNode.position.z <= -1.25 {
direction = true
}
// 4
switch direction {
case true:
currentNode.position.z += 0.03
case false:
currentNode.position.z -= 0.03
}
// 5
} else {
if currentNode.position.x >= 1.25 {
direction = false
} else if currentNode.position.x <= -1.25 {
direction = true
}
switch direction {
case true:
currentNode.position.x += 0.03
case false:
currentNode.position.x -= 0.03
}
}
}
複製程式碼
程式碼含義:
- 根據名字找到場景中的方塊.
- 根據層的位置,沿X軸或Z軸移動方塊.奇數層沿Z軸運動,偶數層沿X軸運動.用求餘操作符(%)來得到餘數,判斷奇偶.
- 如果方塊的位置到了1.25或者-1.25,改變其方向,向另一方向運動.
- 根據方向,沿Z軸前後移動.
- 重複相同程式碼,只改為沿X軸.
預設情況下,SceneKit會暫停場景.為了看到場景中物體的移動,在viewDidLoad的底部新增下面程式碼:
scnView.isPlaying = true
scnView.delegate = self
複製程式碼
這段程式碼中,將這個控制器設定為場景的渲染代理,這樣就能執行剛才寫的代理方法了. 建立執行,檢視運動!
處理點選
現在,我們已經讓方塊移動了,還需要在玩家點選螢幕時新增一個新方塊並重設老方塊的尺寸.切換到Main.storyboard並新增一個tap gesture recognizer到SCNView,像這樣:
現在在控制器裡面用輔助編輯器建立一個動作並命名為handleTap.
切換到標準編輯區,並開啟ViewController.swift,然後在handlTap(_:) 內部新增程式碼:
if let currentBoxNode = scnScene.rootNode.childNode(
withName: "Block\(height)", recursively: false) {
currentPosition = currentBoxNode.presentation.position
let boundsMin = currentBoxNode.boundingBox.min
let boundsMax = currentBoxNode.boundingBox.max
currentSize = boundsMax - boundsMin
offset = previousPosition - currentPosition
absoluteOffset = offset.absoluteValue()
newSize = currentSize - absoluteOffset
currentBoxNode.geometry = SCNBox(width: CGFloat(newSize.x), height: 0.2,
length: CGFloat(newSize.z), chamferRadius: 0)
currentBoxNode.position = SCNVector3Make(currentPosition.x + (offset.x/2),
currentPosition.y, currentPosition.z + (offset.z/2))
currentBoxNode.physicsBody = SCNPhysicsBody(type: .static,
shape: SCNPhysicsShape(geometry: currentBoxNode.geometry!, options: nil))
}
複製程式碼
這裡我們從場景中得到currentBoxNode.然後計算偏移及新方塊的尺寸.從而改變方塊的尺寸和位置,並給它一個靜態物理形體.
偏移等於上一層和當前層位置的差值.通過從當前尺寸上減去差值的絕對值,就得到了新尺寸. 注意到,把當前節點設定位置到偏移處,方塊的邊緣完美對齊了上一個層的邊緣.這創造出一種切掉方塊的錯覺.
下一步,你需要一個方法來建立塔上的下一個方塊.在handleTap(_:) 下面新增程式碼:
func addNewBlock(_ currentBoxNode: SCNNode) {
let newBoxNode = SCNNode(geometry: currentBoxNode.geometry)
newBoxNode.position = SCNVector3Make(currentBoxNode.position.x,
currentPosition.y + 0.2, currentBoxNode.position.z)
newBoxNode.name = "Block\(height+1)"
newBoxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(
colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
if height % 2 == 0 {
newBoxNode.position.x = -1.25
} else {
newBoxNode.position.z = -1.25
}
scnScene.rootNode.addChildNode(newBoxNode)
}
複製程式碼
這裡我們建立了一個和上一個方塊相同尺寸的新節點.放置在當前方塊上方,並根據層高改變X或Z軸的位置.最後,改變漫反射顏色並將其新增到場景中.
你需要使用handleTap(_:) 來保持屬性為最新.在handleTap(_:) 裡的if else語句中新增程式碼:
addNewBlock(currentBoxNode)
if height >= 5 {
let moveUpAction = SCNAction.move(by: SCNVector3Make(0.0, 0.2, 0.0), duration: 0.2)
let mainCamera = scnScene.rootNode.childNode(withName: "Main Camera", recursively: false)!
mainCamera.runAction(moveUpAction)
}
scoreLabel.text = "\(height+1)"
previousSize = SCNVector3Make(newSize.x, 0.2, newSize.z)
previousPosition = currentBoxNode.position
height += 1
複製程式碼
要做的第一件事就是呼叫addNewBlock(_:).如果塔的尺寸大於或等於5,將相機上移.
還需要更新分數,設定前一個尺寸和位置等於當前尺寸和位置.你可以使用newSize因為你設定當前節點的尺寸為newSize.然後增加高度.
建立並執行.一切看起來堆垛地很完美!
實現物理效果
遊戲正確地重設了方塊尺寸,但是如果被砍掉的部分能從塔上掉落,遊戲會看起來更酷.
在addNewBlock(_:) 下面定義新方法:
func addBrokenBlock(_ currentBoxNode: SCNNode) {
let brokenBoxNode = SCNNode()
brokenBoxNode.name = "Broken \(height)"
if height % 2 == 0 && absoluteOffset.z > 0 {
// 1
brokenBoxNode.geometry = SCNBox(width: CGFloat(currentSize.x),
height: 0.2, length: CGFloat(absoluteOffset.z), chamferRadius: 0)
// 2
if offset.z > 0 {
brokenBoxNode.position.z = currentBoxNode.position.z -
(offset.z/2) - ((currentSize - offset).z/2)
} else {
brokenBoxNode.position.z = currentBoxNode.position.z -
(offset.z/2) + ((currentSize + offset).z/2)
}
brokenBoxNode.position.x = currentBoxNode.position.x
brokenBoxNode.position.y = currentPosition.y
// 3
brokenBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic,
shape: SCNPhysicsShape(geometry: brokenBoxNode.geometry!, options: nil))
brokenBoxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(colorLiteralRed: 0.01 *
Float(height), green: 0, blue: 1, alpha: 1)
scnScene.rootNode.addChildNode(brokenBoxNode)
// 4
} else if height % 2 != 0 && absoluteOffset.x > 0 {
brokenBoxNode.geometry = SCNBox(width: CGFloat(absoluteOffset.x), height: 0.2,
length: CGFloat(currentSize.z), chamferRadius: 0)
if offset.x > 0 {
brokenBoxNode.position.x = currentBoxNode.position.x - (offset.x/2) -
((currentSize - offset).x/2)
} else {
brokenBoxNode.position.x = currentBoxNode.position.x - (offset.x/2) +
((currentSize + offset).x/2)
}
brokenBoxNode.position.y = currentPosition.y
brokenBoxNode.position.z = currentBoxNode.position.z
brokenBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic,
shape: SCNPhysicsShape(geometry: brokenBoxNode.geometry!, options: nil))
brokenBoxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(
colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
scnScene.rootNode.addChildNode(brokenBoxNode)
}
}
複製程式碼
這裡你建立了一個新節點並用height來命名.你使用了if語句來確定座標軸,並確保偏移大於0,因為等於時不會產生一個方塊碎片.
- 剛才,你減去偏移量來設定新尺寸.這裡,你無需計算,所需尺寸正是偏移量.
- 改變碎片部分的位置.
- 新增物理形體到該碎片上來讓它掉落.還需要改變顏色並新增到場景中.
- 在X軸上重複同樣操作.
你根據當前位置偏移量的一半來得到碎片的位置.然後,根據方塊位置的正負,新增或扣除當前尺寸減去偏移的一半.
在handleTap(_:) 裡面的addNewBlock(_:) 之前呼叫該方法:
addBrokenBlock(currentBoxNode)
複製程式碼
當碎片節點掉落出視線時,還在不停掉落,並沒有銷燬.在renderer(_:updateAtTime:) 裡面最上方新增程式碼:
for node in scnScene.rootNode.childNodes {
if node.presentation.position.y <= -20 {
node.removeFromParentNode()
}
}
複製程式碼
這段程式碼會刪除Y值小於-20的所有節點. 執行看看切下的方塊!
結束觸控
現在遊戲機制的核心部分已經完成了,還有一些收尾工作.當玩家完美對齊上一層時應該有獎勵.還有,現在還沒有輸贏判斷,當你失敗後也無法開始一個新遊戲! 遊戲還沒有聲音,需要新增一些聲音.
處理完美對齊情況
在處理完美對齊的情況,在addBrokenBlock(_:) 中新增下面方法:
func checkPerfectMatch(_ currentBoxNode: SCNNode) {
if height % 2 == 0 && absoluteOffset.z <= 0.03 {
currentBoxNode.position.z = previousPosition.z
currentPosition.z = previousPosition.z
perfectMatches += 1
if perfectMatches >= 7 && currentSize.z < 1 {
newSize.z += 0.05
}
offset = previousPosition - currentPosition
absoluteOffset = offset.absoluteValue()
newSize = currentSize - absoluteOffset
} else if height % 2 != 0 && absoluteOffset.x <= 0.03 {
currentBoxNode.position.x = previousPosition.x
currentPosition.x = previousPosition.x
perfectMatches += 1
if perfectMatches >= 7 && currentSize.x < 1 {
newSize.x += 0.05
}
offset = previousPosition - currentPosition
absoluteOffset = offset.absoluteValue()
newSize = currentSize - absoluteOffset
} else {
perfectMatches = 0
}
}
複製程式碼
如果玩家放置位置與上一塊在0.03之內,就認為是完美匹配.只要誤差足夠近,就設定當前方塊的位置等於上一個方塊的位置.
通過設定當前和上一次位置相等,讓它們在數值上完全匹配並重新計算偏移和新尺寸.在handleTap(_:) 裡面計算偏移和新尺寸之後,呼叫這個方法:
checkPerfectMatch(currentBoxNode)
複製程式碼
處理完全錯位情況
現在已經處理了完美對齊的情況和部分對齊的情況,但你還需要處理完全錯失的情況.
在handleTap(_:) 內checkPerfectMatch(_:) 之前,新增下面程式碼:
if height % 2 == 0 && newSize.z <= 0 {
height += 1
currentBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic,
shape: SCNPhysicsShape(geometry: currentBoxNode.geometry!, options: nil))
return
} else if height % 2 != 0 && newSize.x <= 0 {
height += 1
currentBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic,
shape: SCNPhysicsShape(geometry: currentBoxNode.geometry!, options: nil))
return
}
複製程式碼
如果玩家錯失了方塊,計算出的新尺寸應該是負的,檢查這個值就知道玩家是否錯失了方塊.如果玩家錯失了,你將高度增加一,這樣移動的程式碼就不再移動移動當前方塊了.然後你再新增一個動態物理形體讓方塊掉落.
最後,return,這樣程式碼就不再執行了,如checkPerfectMatch(_:),和addBrokenBlock(_:).
新增音效
因為音訊檔案很短,可以預先載入進來.在屬性宣告中新增一個新的字典屬性,命名為sounds:
var sounds = [String: SCNAudioSource]()
複製程式碼
下一步,在viewDidLoad下面新增兩個方法:
func loadSound(name: String, path: String) {
if let sound = SCNAudioSource(fileNamed: path) {
sound.isPositional = false
sound.volume = 1
sound.load()
sounds[name] = sound
}
}
func playSound(sound: String, node: SCNNode) {
node.runAction(SCNAction.playAudio(sounds[sound]!, waitForCompletion: false))
}
複製程式碼
第一個方法從指定目錄載入音訊檔案並儲存到sounds字典中.第二個方法播放儲存在sounds字典中的方法.
在viewDidload() 中間新增下面程式碼:
loadSound(name: "GameOver", path: "HighRise.scnassets/Audio/GameOver.wav")
loadSound(name: "PerfectFit", path: "HighRise.scnassets/Audio/PerfectFit.wav")
loadSound(name: "SliceBlock", path: "HighRise.scnassets/Audio/SliceBlock.wav")
複製程式碼
有好幾個地方需要播放音效.在handleTap(_:) 中,在每一個檢查玩家是否錯失方塊的if語句中,新增下面的程式碼:
playSound(sound: "GameOver", node: currentBoxNode)
複製程式碼
在呼叫addNewBlock之後,新增一行:
playSound(sound: "SliceBlock", node: currentBoxNode)
複製程式碼
滾動到checkPerfectMatch(_:),在兩個if語句中分支中新增一行:
playSound(sound: "PerfectFit", node: currentBoxNode)
複製程式碼
建立並執行---有音效的遊戲更有意思了,對吧?
處理輸贏條件
遊戲如何結束呢?現在我們來處理這個問題!
進入Main.storyboard,拖拽一個新的按鈕到檢視中.改變文字的顏色為 #FF0000,文字內容Play.然後改變字型為Custom, Helvetica Neue, 66.
下一步,設定對齊方式align為中心對齊center,並固定底邊constant為100.
拖拽引線到控制器命名為playButton.然後建立一個動作命名為playGame並寫入以下程式碼:
playButton.isHidden = true
let gameScene = SCNScene(named: "HighRise.scnassets/Scenes/GameScene.scn")!
let transition = SKTransition.fade(withDuration: 1.0)
scnScene = gameScene
let mainCamera = scnScene.rootNode.childNode(withName: "Main Camera", recursively: false)!
scnView.present(scnScene, with: transition, incomingPointOfView: mainCamera, completionHandler: nil)
height = 0
scoreLabel.text = "\(height)"
direction = true
perfectMatches = 0
previousSize = SCNVector3(1, 0.2, 1)
previousPosition = SCNVector3(0, 0.1, 0)
currentSize = SCNVector3(1, 0.2, 1)
currentPosition = SCNVector3Zero
let boxNode = SCNNode(geometry: SCNBox(width: 1, height: 0.2, length: 1, chamferRadius: 0))
boxNode.position.z = -1.25
boxNode.position.y = 0.1
boxNode.name = "Block\(height)"
boxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(colorLiteralRed: 0.01 * Float(height),
green: 0, blue: 1, alpha: 1)
scnScene.rootNode.addChildNode(boxNode)
複製程式碼
你注意到,你已經重置了遊戲中的所有變數為預設值,並新增了第一個方塊.
因為已經新增了第一塊方塊,移除viewDidLoad(_:) 中下面的程式碼,從宣告blockNode到新增到場景中.
//1
let blockNode = SCNNode(geometry: SCNBox(width: 1, height: 0.2, length: 1, chamferRadius: 0))
blockNode.position.z = -1.25
blockNode.position.y = 0.1
blockNode.name = "Block\(height)"
//2
blockNode.geometry?.firstMaterial?.diffuse.contents =
UIColor(colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
scnScene.rootNode.addChildNode(blockNode)
複製程式碼
在剛才建立的方法下面定義一個新方法:
func gameOver() {
let mainCamera = scnScene.rootNode.childNode(
withName: "Main Camera", recursively: false)!
let fullAction = SCNAction.customAction(duration: 0.3) { _,_ in
let moveAction = SCNAction.move(to: SCNVector3Make(mainCamera.position.x,
mainCamera.position.y * (3/4), mainCamera.position.z), duration: 0.3)
mainCamera.runAction(moveAction)
if self.height <= 15 {
mainCamera.camera?.orthographicScale = 1
} else {
mainCamera.camera?.orthographicScale = Double(Float(self.height/2) /
mainCamera.position.y)
}
}
mainCamera.runAction(fullAction)
playButton.isHidden = false
}
複製程式碼
這裡,你縮放攝像機鏡頭來露出整個塔.最後,設定play按鈕為可見,這樣玩家就可以開始一個新遊戲.
在handleTap(_:) 內部,在完全錯失方塊的if語句中,呼叫gameover(),放在return語句之前,兩個if語句裡面都放:
gameOver()
複製程式碼
編譯執行.當你失敗時,就能重新開始一個新遊戲了.
啟動圖片
遊戲啟動時會有難看的白屏.開啟LaunchScreen.storyboard並拖拽進一個影像檢視.四周對齊螢幕:
更改圖片為Gradient.png
現在我們已經將白屏替換為了漂亮的漸變圖!
恭喜你,你已經完成了!你可以從這裡下載最終完成版final project
end