[SceneKit專題]25-如何製作一個像Can-Knockdown的遊戲

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

說明

SceneKit系列文章目錄

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

本教程將包含以下內容:

  • 在SceneKit編輯器中建立基本的3D場景.
  • 程式設計載入並呈現3D場景.
  • 建立模擬物理,如何應用力.
  • 通過觸控與3D場景中的物體互動.
  • 設計並實現基本的碰撞檢測.

開始

開始前,先下載初始專案starter project 開啟專案,簡單檢視一下里面都有些什麼.你會發現球和罐頭的素材,還有一個GameHelper檔案能提供一些有用的函式. 建立並執行,看上去一片黑:

bcb_001.png

不要難過,這只是一個乾淨的工作臺供你開始.

建立並彈出選單

在開始砸罐頭之前,需要給遊戲新增一個選單選項.開啟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()
}
複製程式碼

編譯執行,會看到這樣的選單場景:

bcb_002-281x500.png

在場景編輯器中建立等級

開啟resources.scnassets/Level.scn場景:

bcb_003-650x469.png

從物件庫中拖入一個Floor節點到場景中:

bcb_004-1-650x353.png
在右側的Attributes Inspector中將Reflectivity改為0.05,這樣地板就有了輕微反射.

選擇Material Inspector並設定wood-floor.jpgDiffuse紋理.設定Offset(x: 0, y: 0.2),設定Scale(x: 15, y: 15),最後,設定Rotation90度:

bcb_005.png

現在地板已經放置好了,還需要再新增磚牆作為背景.牆的幾何體已經在Wall.scn場景裡為你配置好了.用Reference Node引用節點將其新增到等級場景中. 在Level.scn場景中,從媒體庫中拖拽一個Wall引用節點到場景中.

bcb_006-650x353.png

Node Inspector中設定節點名字為wall並設定位置為**(x: 0, y: 0, z: -5)**.

下一步,你需要一個點來堆放罐頭.從Object Library物件庫中拖放一個Box命名為shelf,並放置到**(x: 0.0, y: 2.25, z: -2.25)**處,正好在牆的前面.

Attributes Inspector中設定Width10,Height0.25.最後,在Material Inspector中,設定Diffusewood-table.png,開啟附加屬性,設定WrapSWrapTRepeat,設定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 Angle35,Outer Angle85.這讓燈光更柔和,也擴充套件了點光源錐體,擴大了場景中照亮的範圍.

最後,在Shadow下面, 設定Sample radius4,Sample count1,並設定Color為黑色,透明度50%.讓會讓點光源投射出柔和的陰影:

bcb_shadow-settings.png

為了淡化黑色的陰影,新增環境光照,拖放一個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.

很好!這樣關卡設計就完成了.看看起來像這樣:

bcb_008-650x420.png

載入關呈現關卡

Level.scn中已經有一關了,那麼怎麼在裝置上檢視它呢? 在GameViewControllermenuScene屬性下面新增一行:

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()
}
複製程式碼

這樣,當你點選選單場景時,遊戲就會開始. 編譯執行,然後點選選單場景,會看到你設計的關卡淡入:

bcb_009.png

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
}
複製程式碼

解釋一下上面的程式碼:

  1. 先找到在場景編輯器中建立的節點,並賦值給camerashelf屬性.
  2. 接著給baseCanNode賦值一個從預先建立的罐頭場景中載入出來的節點.
  3. 建立靜態物理形體給架子,並新增到shelfNode上去.
  4. 最後,放置好這個不可見的觸控捕捉節點,正對場景中的攝像機.

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)
  }
}
複製程式碼

以上程式碼含義:

  1. 如果玩家完成了前一個關卡,意味著他們還有球剩餘,那他們可以再得到一個球做為獎勵.
  2. 你迴圈遍歷每個罐在當前關卡中的位置,通過克隆baseCanNode來建立並配置罐.你會在下一步中明白,什麼是罐頭的定位.
  3. 這裡建立一個隨機布林值,來確定罐頭有什麼紋理和旋轉角度.
  4. 每個罐頭的位置,通過儲存在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()
複製程式碼

編譯執行,然後點選選單,就能看到罐頭堆放在一起,像這樣:

bcb_010.png

很好!現在有一個高效的可重用的方法,來載入關卡中的不同佈局了.是時候新增一個球,開始投擲出去了.

新增球體

此時你還不能和遊戲進行互動;你只能盯著看這些罐頭生鏽. 在檔案頭部的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)
複製程式碼

這個函式中:

  1. 你從Ball.scn中建立一個球,並配置其物理形體來模擬一個棒球.
  2. 在球的位置確定後,使用一個初始的力來使球從左側進入檢視.

要呼叫這個新函式,在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)
複製程式碼

這段程式碼讓第一個球延遲到關卡載入後. 這裡物體效果有一點小問題.編譯執行看看:

bcb_011-281x500.png

點選選單;你會看到小球掉落到場景中,然後從螢幕中掉出去了. 由於地板目前還沒有設定物理形體,所以球體並不知道自己應該彈跳落在地板上,而是穿過地板,掉落下去.

除了用程式碼給地板新增物理形體處,還可以在場景編輯器中新增.只需點選幾下滑鼠,就能讓小球正常彈跳落在地板上.

用SceneKit編輯器新增物體形體

進入resources.scnassets/Level.scn並點選地板節點.選中Physics InspectorType型別改為Static, 然後將Category mask設定為5.

這就是用SceneKit編輯器新增物理形體!其它設定項會帶來不同行為,但是這個遊戲中預設設定就好了.

bcb_012.png

編譯執行,會看到小球彈跳進入並滾動到中間,準備好被扔出去的位置:

bcb_013-281x500.png

重複相同步驟,也給牆壁新增物理形體,畢竟我們不希望球貫穿牆壁一直飛下去.

投擲小球

現在是時候猛擊罐頭了.新增下面的屬性到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
}
複製程式碼

在這個函式中:

  1. 首先,用了點選測試來得到觸控的節點.
  2. 接著,播放嗖的音效作為音訊的反饋.
  3. 根據觸控開始和結束的時間計算速度.
  4. 然後建立一個向量,從被觸控物體的本地座標到架子的位置,用速度大小做為向量長度.
  5. 最後,清理投擲屬性,準備下次投擲.

為了讓這個函式起作用,你需要遊戲中的觸控事件處理. 將整個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()
}
複製程式碼

當玩家手指離開螢幕,你需要儲存觸控結束點及時間,因為它們決定了投擲方向是否正確. 編譯執行,試著擊倒這些罐頭:

bcb_014-281x500.png

碰撞檢測

如果你的準頭好的話,你可能把所有罐頭都擊倒在地面上了.但是你還沒有完成,當所有罐頭撞擊地面後你應該可以進入下一關了.

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
    }
  }
}
複製程式碼

這段程式碼中:

  1. 首先,檢測碰撞是不是發生在球和地板之間.
  2. 如果球碰到了地板,播放音效.
  3. 如果小球沒有與地板接觸,就判斷小球是否與罐頭接觸.如果接觸,播放另一段音效.
  4. 如果當前的罐頭已經與地板碰撞過,不需要處理,因為你已經處理過了.
  5. 檢查罐頭是否與地板碰撞.
  6. 如果罐頭接觸到地板,記錄罐頭的名字,來確保這個罐頭的碰撞只處理了一次.當新的罐頭碰撞到地板時增加分數.

會有很多碰撞發生---很多需要處理!

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)
}
複製程式碼

程式碼做的是:

  1. 如果被撞掉下來的罐頭數量和本關的罐頭數量一致,我們進入下一關.
  2. 移除舊遊戲結束動作.
  3. 一旦最後一關完成,迴圈各個關卡,因為本遊戲是為了獲取最高分.
  4. 在短暫的延遲後載入下一關卡.

為了讓接觸代理正常工作,在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()
  }
}
複製程式碼

這段程式碼在玩家晉級一關後,幫助清理記錄狀態.做的是:

  1. 如果有當前的球,移除它.
  2. 移除所有在接觸代理中用過的掉落罐頭節點名稱.
  3. 迴圈罐頭節點,從它們的父節點移除,然後清理陣列.
  4. 移除每個小球節點

你需要在好幾個地方呼叫這個函式.在presentLevel() 頂部新增下面程式碼:

resetLevel()
複製程式碼

用下面程式碼替換physicsWorld(_:didBegin:) 中移動到下一關的blockAction:

let blockAction = SCNAction.run { _ in
  self.resetLevel()
  self.setupNextLevel()
}
複製程式碼

編譯執行遊戲;終於可以玩遊戲了!試著只用一個球就打落所有罐頭!

bcb_game_loop.gif

你不能指望每個玩家都能一擊過關.下個任務是實現一個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,這樣玩家就能看到他們的最高分了!

全部完成!執行試試你能不能打敗自己的高分?

bcb_015-281x500.png

本教程中的最終完成版專案可以看這裡here.

相關文章