[SceneKit專題]如何制作一個(gè)像Can Knockdown的游戲

說(shuō)明

SceneKit系列文章目錄

更多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)行,看上去一片黑:

bcb_001.png

不要難過,這只是一個(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)景:


bcb_002-281x500.png

在場(chǎng)景編輯器中創(chuàng)建等級(jí)

打開resources.scnassets/Level.scn場(chǎng)景:

bcb_003-650x469.png

從對(duì)象庫(kù)中拖入一個(gè)Floor節(jié)點(diǎn)到場(chǎng)景中:

bcb_004-1-650x353.png

在右側(cè)的Attributes Inspector中將Reflectivity改為0.05,這樣地板就有了輕微反射.

選擇Material Inspector并設(shè)置wood-floor.jpgDiffuse紋理.設(shè)置Offset(x: 0, y: 0.2),設(shè)置Scale(x: 15, y: 15),最后,設(shè)置Rotation90度:

bcb_005.png

現(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)景中.

bcb_006-650x353.png

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è)置Width10,Height0.25.最后,在Material Inspector中,設(shè)置Diffusewood-table.png,打開附加屬性,設(shè)置WrapSWrapTRepeat,設(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 Angle35,Outer Angle85.這讓燈光更柔和,也擴(kuò)展了點(diǎn)光源錐體,擴(kuò)大了場(chǎng)景中照亮的范圍.

最后,在Shadow下面, 設(shè)置Sample radius4,Sample count1,并設(shè)置Color為黑色,透明度50%.讓會(huì)讓點(diǎn)光源投射出柔和的陰影:

bcb_shadow-settings.png

為了淡化黑色的陰影,添加環(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)像這樣:


bcb_008-650x420.png

加載關(guān)呈現(xiàn)關(guān)卡

Level.scn中已經(jīng)有一關(guān)了,那么怎么在設(shè)備上查看它呢?
GameViewControllermenuScene屬性下面添加一行:

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)卡淡入:


bcb_009.png

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
}

解釋一下上面的代碼:

  1. 先找到在場(chǎng)景編輯器中創(chuàng)建的節(jié)點(diǎn),并賦值給camerashelf屬性.
  2. 接著給baseCanNode賦值一個(gè)從預(yù)先創(chuàng)建的罐頭場(chǎng)景中加載出來(lái)的節(jié)點(diǎn).
  3. 創(chuàng)建靜態(tài)物理形體給架子,并添加到shelfNode上去.
  4. 最后,放置好這個(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)
  }
}

以上代碼含義:

  1. 如果玩家完成了前一個(gè)關(guān)卡,意味著他們還有球剩余,那他們可以再得到一個(gè)球做為獎(jiǎng)勵(lì).
  2. 你循環(huán)遍歷每個(gè)罐在當(dāng)前關(guān)卡中的位置,通過克隆baseCanNode來(lái)創(chuàng)建并配置罐.你會(huì)在下一步中明白,什么是罐頭的定位.
  3. 這里創(chuàng)建一個(gè)隨機(jī)布爾值,來(lái)確定罐頭有什么紋理和旋轉(zhuǎn)角度.
  4. 每個(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)擊菜單,就能看到罐頭堆放在一起,像這樣:


bcb_010.png

很好!現(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ù)中:

  1. 你從Ball.scn中創(chuàng)建一個(gè)球,并配置其物理形體來(lái)模擬一個(gè)棒球.
  2. 在球的位置確定后,使用一個(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)行看看:


bcb_011-281x500.png

點(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 InspectorType類型改為Static, 然后將Category mask設(shè)置為5.

這就是用SceneKit編輯器添加物理形體!其它設(shè)置項(xiàng)會(huì)帶來(lái)不同行為,但是這個(gè)游戲中默認(rèn)設(shè)置就好了.


bcb_012.png

編譯運(yùn)行,會(huì)看到小球彈跳進(jìn)入并滾動(dòng)到中間,準(zhǔn)備好被扔出去的位置:


bcb_013-281x500.png

重復(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ù)中:

  1. 首先,用了點(diǎn)擊測(cè)試來(lái)得到觸摸的節(jié)點(diǎn).
  2. 接著,播放嗖的音效作為音頻的反饋.
  3. 根據(jù)觸摸開始和結(jié)束的時(shí)間計(jì)算速度.
  4. 然后創(chuàng)建一個(gè)矢量,從被觸摸物體的本地坐標(biāo)到架子的位置,用速度大小做為矢量長(zhǎng)度.
  5. 最后,清理投擲屬性,準(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)行,試著擊倒這些罐頭:


bcb_014-281x500.png

碰撞檢測(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
    }
  }
}

這段代碼中:

  1. 首先,檢測(cè)碰撞是不是發(fā)生在球和地板之間.
  2. 如果球碰到了地板,播放音效.
  3. 如果小球沒有與地板接觸,就判斷小球是否與罐頭接觸.如果接觸,播放另一段音效.
  4. 如果當(dāng)前的罐頭已經(jīng)與地板碰撞過,不需要處理,因?yàn)槟阋呀?jīng)處理過了.
  5. 檢查罐頭是否與地板碰撞.
  6. 如果罐頭接觸到地板,記錄罐頭的名字,來(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)
}

代碼做的是:

  1. 如果被撞掉下來(lái)的罐頭數(shù)量和本關(guān)的罐頭數(shù)量一致,我們進(jìn)入下一關(guān).
  2. 移除舊游戲結(jié)束動(dòng)作.
  3. 一旦最后一關(guān)完成,循環(huán)各個(gè)關(guān)卡,因?yàn)楸居螒蚴菫榱双@取最高分.
  4. 在短暫的延遲后加載下一關(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).做的是:

  1. 如果有當(dāng)前的球,移除它.
  2. 移除所有在接觸代理中用過的掉落罐頭節(jié)點(diǎn)名稱.
  3. 循環(huán)罐頭節(jié)點(diǎn),從它們的父節(jié)點(diǎn)移除,然后清理數(shù)組.
  4. 移除每個(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è)球就打落所有罐頭!


bcb_game_loop.gif

你不能指望每個(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)行試試你能不能打敗自己的高分?


bcb_015-281x500.png

本教程中的最終完成版項(xiàng)目可以看這里here.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末扣典,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子窑眯,更是在濱河造成了極大的恐慌领迈,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,214評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件拆魏,死亡現(xiàn)場(chǎng)離奇詭異搓谆,居然都是意外死亡薯嗤,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門撮竿,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)吮便,“玉大人,你說(shuō)我怎么就攤上這事幢踏∷栊瑁” “怎么了?”我有些...
    開封第一講書人閱讀 152,543評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵惑折,是天一觀的道長(zhǎng)授账。 經(jīng)常有香客問我,道長(zhǎng)惨驶,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,221評(píng)論 1 279
  • 正文 為了忘掉前任敛助,我火速辦了婚禮粗卜,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘纳击。我一直安慰自己续扔,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,224評(píng)論 5 371
  • 文/花漫 我一把揭開白布焕数。 她就那樣靜靜地躺著纱昧,像睡著了一般。 火紅的嫁衣襯著肌膚如雪堡赔。 梳的紋絲不亂的頭發(fā)上识脆,一...
    開封第一講書人閱讀 49,007評(píng)論 1 284
  • 那天,我揣著相機(jī)與錄音善已,去河邊找鬼灼捂。 笑死,一個(gè)胖子當(dāng)著我的面吹牛换团,可吹牛的內(nèi)容都是我干的悉稠。 我是一名探鬼主播,決...
    沈念sama閱讀 38,313評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼艘包,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼的猛!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起想虎,我...
    開封第一講書人閱讀 36,956評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤卦尊,失蹤者是張志新(化名)和其女友劉穎磷醋,沒想到半個(gè)月后猫牡,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,441評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡煌恢,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,925評(píng)論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了肴颊。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,018評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出官研,到底是詐尸還是另有隱情,我是刑警寧澤酷宵,帶...
    沈念sama閱讀 33,685評(píng)論 4 322
  • 正文 年R本政府宣布默垄,位于F島的核電站鹃操,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏乱陡。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,234評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望衩婚。 院中可真熱鬧,春花似錦概行、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至腹尖,卻和暖如春柳恐,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背热幔。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評(píng)論 1 261
  • 我被黑心中介騙來(lái)泰國(guó)打工乐设, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人绎巨。 一個(gè)月前我還...
    沈念sama閱讀 45,467評(píng)論 2 352
  • 正文 我出身青樓近尚,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親场勤。 傳聞我的和親對(duì)象是個(gè)殘疾皇子戈锻,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,762評(píng)論 2 345