[SceneKit專題]26-如何製作一個像Stack的遊戲

蘋果API搬運工發表於2017-12-23

說明

SceneKit系列文章目錄

更多iOS相關知識檢視github上WeekWeekUpProject

在本教程中,你將會學習如何製作一個類似Stack這樣的遊戲.

392x696bb.jpg

本教程將包含以下內容:

  • 視覺化建立3D場景.
  • 程式設計載入並呈現3D場景.
  • 使用節點的物理形體.
  • 結合使用UIKit與SceneKit.
  • 在SceneKit遊戲中播放音訊.

開始

下載初始專案starter project. 在初始專案裡面,你會發現SceneKit目錄檔案中帶有音訊和場景檔案.另外,還有一些SCNVector3類擴充套件來執行簡單的向量算術運算及生成漸變圖片.還有App Icon也已經新增進去了!花點時間熟悉一下專案吧.

你將會建立一個類似於Stack的遊戲.這個遊戲的目標是在一塊方塊上疊放另一個方塊.需要小心的是:方塊疊放時稍微偏一點,多餘部分就會被切掉.完全沒對齊,那就game over了!

建立場景

你將從建立你的遊戲場景開始.開啟GameScene.scn.

game_scene-1.png
拖拽一個新的camera到場景中,然後選擇Node Inspector並重新命名節點為Main Camera.設定位置為X: 3, Y: 4.5, Z: 3,旋轉為X: -40, Y: 45, Z:0:
camera_node_inspector.png

切換到Attributes Inspector並切換相機的Projection typeOrthographic. 下一步,新增燈光到場景中. 從物件庫中拖拽一個新的方向光到場景中,命名為Directional Light.因為相機只看到了場景的一側,你不必去照亮看不見的令一側.回到Attributes Inspector,設定位置為X: 0, Y: 0, Z: 0,旋轉為X: -65, Y: 20, Z:-30:

directional_light.png

神奇,亮起來了!

現在回到塔的頂部.你需要一個基礎方塊來支承這個塔,來讓玩家在上面建造.拖拽一個盒子到場景中,設定屬性:

  • 在Node Inspector中,更改名字為Base Block,並設定位置為X:0,Y:-4,Z:0.
  • 在Attributes Inspector中,更改尺寸為Width: 1, Height: 8, Length: 1.
  • 在Material Inspector中,更改漫反射顏色為 #434343.
    base_block_diffuse_color-e1486870178228.png

你需要新增一個動態形體到基礎方塊,切換到Physics Inspector中,並將物理形體改為Static.

base_block_physics.png

現在讓我們配上漂亮的背景顏色!在選中基礎方塊的同時,切換到Scene Inspector,並拖拽檔案Gradient.png到背景選擇框中:

scene_background-650x307.png

你需要一個方法來顯示給玩家,他們的塔已經堆放了多高.開啟Main.storyboard;看到它已經有一個SCNView.新增一個label在SCNView頂部並設定文字為0.然後新增一個約束將label對齊到中心,像這樣:

center_constraint.png

新增另一個約束將label頂部與螢幕頂部對齊.

top_constraint.png

然後切換到Attributes Inspector中,切換字型為Custom, Thonburi, Regular, 50.

label_text_settings.png

然後使用assistant editor來新增一個從label到控制器的引用,命名為scoreLabel:

score_label.gif

編譯執行,看看現在有什麼了.

build_and_run_1-1.png

新增你的第一塊方塊

知道怎麼讓塔越來越高麼?對,建立更多方塊. 建立一些屬性來幫你追蹤正在使用的方塊.為此,開啟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
複製程式碼

這段程式碼含義:

  1. direction用來表示方塊的位置是上升還是下降,height變數表示塔有多高.
  2. previousSizepreviousPosition變數表示當前層的尺寸和位置.
  3. 你需要使用offset,absoluteOffset,newSize變數來計算新層的尺寸.
  4. 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)
複製程式碼

程式碼含義:

  1. 你用SCNNode的立方體建立一個新的方塊,放置在Z軸和Y軸,並根據放置在塔上的height屬性對其命名.
  2. 根據不斷增長的高度,計算得出漫反射顏色的紅色分量.最後,將節點新增到場景上.

建立並執行,會看到你的新方塊出現在螢幕上!

build_and_run_2.png

移動方塊

現在已經有一條新的方塊用來放置.但是,我想如果方塊是移動的會更好玩. 要實現這個移動,需要設定控制器作為場景渲染代理,並實現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
        }
      }
    }
複製程式碼

程式碼含義:

  1. 根據名字找到場景中的方塊.
  2. 根據層的位置,沿X軸或Z軸移動方塊.奇數層沿Z軸運動,偶數層沿X軸運動.用求餘操作符(%)來得到餘數,判斷奇偶.
  3. 如果方塊的位置到了1.25或者-1.25,改變其方向,向另一方向運動.
  4. 根據方向,沿Z軸前後移動.
  5. 重複相同程式碼,只改為沿X軸.

預設情況下,SceneKit會暫停場景.為了看到場景中物體的移動,在viewDidLoad的底部新增下面程式碼:

scnView.isPlaying = true
scnView.delegate = self
複製程式碼

這段程式碼中,將這個控制器設定為場景的渲染代理,這樣就能執行剛才寫的代理方法了. 建立執行,檢視運動!

032.png

處理點選

現在,我們已經讓方塊移動了,還需要在玩家點選螢幕時新增一個新方塊並重設老方塊的尺寸.切換到Main.storyboard並新增一個tap gesture recognizerSCNView,像這樣:

Screen-Shot-2017-04-20-at-5.29.44-PM-650x357.png

現在在控制器裡面用輔助編輯器建立一個動作並命名為handleTap.

tap_action-650x374.png

切換到標準編輯區,並開啟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.然後計算偏移及新方塊的尺寸.從而改變方塊的尺寸和位置,並給它一個靜態物理形體.

resize_current_block.png

偏移等於上一層和當前層位置的差值.通過從當前尺寸上減去差值的絕對值,就得到了新尺寸. 注意到,把當前節點設定位置到偏移處,方塊的邊緣完美對齊了上一個層的邊緣.這創造出一種切掉方塊的錯覺.

下一步,你需要一個方法來建立塔上的下一個方塊.在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.然後增加高度.

建立並執行.一切看起來堆垛地很完美!

build_and_run_4.png

實現物理效果

遊戲正確地重設了方塊尺寸,但是如果被砍掉的部分能從塔上掉落,遊戲會看起來更酷.

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,因為等於時不會產生一個方塊碎片.

  1. 剛才,你減去偏移量來設定新尺寸.這裡,你無需計算,所需尺寸正是偏移量.
  2. 改變碎片部分的位置.
  3. 新增物理形體到該碎片上來讓它掉落.還需要改變顏色並新增到場景中.
  4. 在X軸上重複同樣操作.

你根據當前位置偏移量的一半來得到碎片的位置.然後,根據方塊位置的正負,新增或扣除當前尺寸減去偏移的一半.

create_broken_block.png

handleTap(_:) 裡面的addNewBlock(_:) 之前呼叫該方法:

addBrokenBlock(currentBoxNode)
複製程式碼

當碎片節點掉落出視線時,還在不停掉落,並沒有銷燬.在renderer(_:updateAtTime:) 裡面最上方新增程式碼:

for node in scnScene.rootNode.childNodes {
  if node.presentation.position.y <= -20 {
    node.removeFromParentNode()
  }
}
複製程式碼

這段程式碼會刪除Y值小於-20的所有節點. 執行看看切下的方塊!

build_and_run_5.png

結束觸控

現在遊戲機制的核心部分已經完成了,還有一些收尾工作.當玩家完美對齊上一層時應該有獎勵.還有,現在還沒有輸贏判斷,當你失敗後也無法開始一個新遊戲! 遊戲還沒有聲音,需要新增一些聲音.

處理完美對齊情況

在處理完美對齊的情況,在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.

play_button_attribute.png

下一步,設定對齊方式align為中心對齊center,並固定底邊constant100.

play_button_pin.png

拖拽引線到控制器命名為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並拖拽進一個影像檢視.四周對齊螢幕:

img_view_pin.png

更改圖片為Gradient.png

img_view_image.png

現在我們已經將白屏替換為了漂亮的漸變圖!

恭喜你,你已經完成了!你可以從這裡下載最終完成版final project

end

相關文章