[SceneKit专题]25-如何制作一个像Can-Knockdown的游戏

874 阅读15分钟

说明

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.