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

說明

SceneKit系列文章目錄node

更多iOS相關知識查看github上WeekWeekUpProjectgit

本教程將包含如下內容:github

  • 在SceneKit編輯器中創建基本的3D場景.
  • 編程加載並呈現3D場景.
  • 創建仿真物理,如何應用力.
  • 經過觸摸與3D場景中的物體交互.
  • 設計並實現基本的碰撞檢測.

開始

開始前,先下載初始項目starter project 打開項目,簡單查看一下里面都有些什麼.你會發現球和罐頭的素材,還有一個GameHelper文件能提供一些有用的函數. 建立並運行,看上去一片黑: 編程

bcb_001.png

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

創建並彈出菜單

在開始砸罐頭以前,須要給遊戲添加一個菜單選項.打開GameViewController.swift並添加一個新的屬性:數組

// Scene properties
var menuScene = SCNScene(named: "resources.scnassets/Menu.scn")!
複製代碼

這段代碼將加載菜單場景.你將可使用menuScene來實現菜單和等級場景之間的跳轉. 要彈出菜單場景,須要在**viewDidLoad()**裏添加下列代碼:bash

// 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:) 交叉淡出的轉場.app

viewDidLoad() 底部添加調用presentMenu():dom

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.

相關文章
相關標籤/搜索