[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分

原文: How To Make a Breakout Game with SpriteKit and Swift: Part 2

作者: Michael Briscoe

译者:kmyhy

更新说明:本教程由 Michael Briscoe升级为 Xcode 8 和 Swift 3。原文作者是 Barbara Reichart。

欢迎回到本教程!

在第一部分,你创建了一个会动的木板和小球到游戏中。

在第二部分,你将添加一些砖块和其它游戏逻辑到游戏中。

这部分内容从第一部分教程继续。如果你没有完成第一部分,可以从 这里 下载示例项目并继续。

竹砖

你已经让小球四处乱蹦并能够制造碰撞,接下来添加一些竹砖用来击碎。毕竟这是一个逃逸游戏嘛!

回到 GameScene.swift,在 didMove(to:) 方法中添加砖块:

// 1
let numberOfBlocks = 8
let blockWidth = SKSpriteNode(imageNamed: "block").size.width
let totalBlocksWidth = blockWidth * CGFloat(numberOfBlocks)
// 2
let xOffset = (frame.width - totalBlocksWidth) / 2
// 3
for i in 0..<numberOfBlocks {
  let block = SKSpriteNode(imageNamed: "block.png")
  block.position = CGPoint(x: xOffset + CGFloat(CGFloat(i) + 0.5) * blockWidth, 
    y: frame.height * 0.8)

  block.physicsBody = SKPhysicsBody(rectangleOf: block.frame.size)
  block.physicsBody!.allowsRotation = false
  block.physicsBody!.friction = 0.0
  block.physicsBody!.affectedByGravity = false
  block.physicsBody!.isDynamic = false
  block.name = BlockCategoryName
  block.physicsBody!.categoryBitMask = BlockCategory
  block.zPosition = 2
  addChild(block)
}

这段代码创建了 8 块砖,并放在屏幕中央。

  1. 一些常量,比如砖块的数目以及它们的宽。
  2. 计算 x 偏移。这是第一块砖和屏幕左边沿的距离。用屏幕宽度减去8块砖的总宽度再除以 2。
  3. 创建砖块,设置每块砖的物理属性,并根据 blockWidth 和 xOffset 设置每块砖的位置。

Build & run,看看效果。

[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分
[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分

砖块准备好了。但为了监听球和砖之间的碰撞,你必须修改小球的 contactTestBitMask。仍然在 GameScene.swift 中,在 didMove(to:) 方法中添加一个新的 category:

ball.physicsBody!.contactTestBitMask = BottomCategory | BlockCategory

这句在 BottomCategory 和 BlockCategory 中间使用了一个 OR 位运算符。这会导致这两个 category 的对应位被设为 1 而其它位设为 0。现在,球和地板、砖发生碰撞都会通知委托对象。

断开竹砖

你已经能够检测到球和砖块之间的碰撞了,接下来为 GameScene.swift 添加一个助手方法,从场景中删除砖块:

func breakBlock(node: SKNode) {
  let particles = SKEmitterNode(fileNamed: "BrokenPlatform")!
  particles.position = node.position
  particles.zPosition = 3
  addChild(particles)
  particles.run(SKAction.sequence([SKAction.wait(forDuration: 1.0), 
    SKAction.removeFromParent()]))
  node.removeFromParent()
}

这个方法有一个 SKNode 参数。首先,它会用 BrokenPlatform.sks 创建一个 SKEmitterNode 实例,将它的位置设置为该节点所在的位置。emitter 节点的 zPosition 是 3,这样粒子会显示在其它砖块的上层。当粒子发射器被添加到场景之后,node(竹砖)被移除。

注意:发射器节点是一种特殊的节点,用于显示用场景编辑器创建的粒子效果。要查看它是什么样子,请打开 BrokenPlatform.sks,这是我为本教程专门创建的粒子系统。更多关于粒子系统的内容,请阅读我们的 2D iOS & tvOS 游戏教程 一书,它对这部分内容有详细介绍。

接下来的事情就是处理委托通知。在 didBegin(_:) 方法最后添加:

if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == BlockCategory {
  breakBlock(node: secondBody.node!)
  //TODO: check if the game has been won
}

这段代码检查球和砖是否发生碰撞。如果发生,将 node 传递给 breakBlock(node:)方法,这样竹砖就会从屏幕上移除并显示粒子效果!

Build & run。当小球击中竹砖,竹砖会四分五裂。

[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分
[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分

添加玩法

现在所有游戏元素都就绪了,是时候让玩家体验输赢的感觉了。

理解状态机

大部分游戏逻辑都是受游戏状态控制的。例如,如果游戏处于“主菜单”状态,玩家将无法移动,如果游戏处于 play 状态,玩家才可以动。

大量简单的游戏通过在 update 循环中用布尔值来管理游戏状态。通过状态机,当游戏变得复杂时你可以更好地组织代码。

一个状态机通过单个的当前状态和一系列状态间转换规则来管理一组状态。当游戏状态发生改变,状态机会执行上一状态的退出方法和下一状态的进入方法。这些方法用于控制每个状态下的玩法。当状态成功改变,状态机会执行当前状态的 update 循环。

[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分
[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分

苹果从 iOS 9 开始引入 GameplayKit 框架,它内置了状态机支持,让我们的工作变得更加容易。GameplayKit 不是本教程讨论的范围,但现在,你将用到其中的两个类:GKStateMachine 和 GKState 类。

添加状态

这个游戏有 3 个状态:

  • WaitingForTap:游戏已经加载,等待玩家去玩。
  • Playing: 游戏正在玩的过程中。
  • GameOver: 游戏已经结束,要么赢要么输。

为了节省时间,这 3 个状态已经被添加到项目中(你可以查看 Game States 文档组)。要创建状态机,首先添加必要的 import 语句到 GameScene.swift 中:

import GameplayKit

然后,在 isFingerOnPaddle = false 声明变量:

lazy var gameState: GKStateMachine = GKStateMachine(states: [
  WaitingForTap(scene: self),
  Playing(scene: self),
  GameOver(scene: self)])

通过定义这个变量,你为游戏创建了一个状态机。注意,创建 GKStateMachine 时使用了一个 GKState 的数组。

等待点击状态:WaitingForTap

WaitingForTap 状态是游戏刚加载等待开始的状态。玩家会看到一个 Tap to Play 的提示,游戏等待触摸事件一发生就会进入 play 状态。

在 didMove(to:) 方法中添加代码:

let gameMessage = SKSpriteNode(imageNamed: "TapToPlay")
gameMessage.name = GameMessageName
gameMessage.position = CGPoint(x: frame.midX, y: frame.midY)
gameMessage.zPosition = 4
gameMessage.setScale(0.0)
addChild(gameMessage)

gameState.enter(WaitingForTap.self)

这里创建了一个 sprite 用来显示 Tap to Play 文本,后面则会用来显示 Game Over。然后告诉状态机进入 WaitingForTap 状态。

同时在 didMove(to:) 方法中删除这句:

ball.physicsBody!.applyImpulse(CGVector(dx: 2.0, dy: -2.0)) // REMOVE

你会将这句完后挪一些地方以便进入 play 状态。

打开 Game States 文档夹下的 WaitingForTap.swift 文档。将 didEnter(from:) 和 willExit(to:) 方法修改为:

override func didEnter(from previousState: GKState?) {
  let scale = SKAction.scale(to: 1.0, duration: 0.25)
  scene.childNode(withName: GameMessageName)!.run(scale)
}

override func willExit(to nextState: GKState) {
  if nextState is Playing {
    let scale = SKAction.scale(to: 0, duration: 0.4)
    scene.childNode(withName: GameMessageName)!.run(scale)
  }
}

当游戏进入 WaitingForTap state 状态, didEnter(from:) 方法被调用。这个方法简单将 Tap to Play 放大显示,告诉玩家可以开始了。

当游戏退出 WaitingForTap 状态,进入 play 状态时,willExit(to:) 方法被调用,Tap to Play 会被缩小到 0。

Build & run,点击屏幕开始玩游戏!

[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分
[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分

好了,但当你点击屏幕,什么也不发生。那是下一个游戏状态的事情!

“游戏中”状态

Playging 状态会开始游戏,并管理小球的速度。

首先,回到 GameScene.swift,实现助手方法:

func randomFloat(from: CGFloat, to: CGFloat) -> CGFloat {
  let rand: CGFloat = CGFloat(Float(arc4random()) / 0xFFFFFFFF)
  return (rand) * (to - from) + from
}

这个助手方法返回一个位于两个参数之间的随机数。你会用它来让小球一开始的方向产生一些随机性。

现在,打开 Game States 文档夹下的 Playing.swift 文档,添加一个助手方法:

func randomDirection() -> CGFloat {
  let speedFactor: CGFloat = 3.0
  if scene.randomFloat(from: 0.0, to: 100.0) >= 50 {
    return -speedFactor
  } else {
    return speedFactor
  }
}

这些代码就像“猜硬币”一样,返回一个正数或者负数。这个方法为小球的初始方向变得随机。

然后在 didEnter(from:) 方法中添加代码:

if previousState is WaitingForTap {
  let ball = scene.childNode(withName: BallCategoryName) as! SKSpriteNode
  ball.physicsBody!.applyImpulse(CGVector(dx: randomDirection(), dy: randomDirection()))
}

当游戏进入 Playing 状态,获取小球 sprite,调用它的 applyImpulse(_:) 方法,让它开始移动。

然后在 update(deltaTime:) 方法中添加代码:

let ball = scene.childNode(withName: BallCategoryName) as! SKSpriteNode
let maxSpeed: CGFloat = 400.0

let xSpeed = sqrt(ball.physicsBody!.velocity.dx * ball.physicsBody!.velocity.dx)
let ySpeed = sqrt(ball.physicsBody!.velocity.dy * ball.physicsBody!.velocity.dy)

let speed = sqrt(ball.physicsBody!.velocity.dx * ball.physicsBody!.velocity.dx + ball.physicsBody!.velocity.dy * ball.physicsBody!.velocity.dy)

if xSpeed <= 10.0 {
  ball.physicsBody!.applyImpulse(CGVector(dx: randomDirection(), dy: 0.0))
}
if ySpeed <= 10.0 {
  ball.physicsBody!.applyImpulse(CGVector(dx: 0.0, dy: randomDirection()))
}

if speed > maxSpeed {
  ball.physicsBody!.linearDamping = 0.4
} else {
  ball.physicsBody!.linearDamping = 0.0
}

update(deltaTime:) 方法会在每一帧的 Playing 状态时调用。获得小球对象,判断它的速度,即移动速度。如果 x 或 y 速度低于某个阈值,小球会卡在直上直下或直左直右移动的状态,如果这样,我们需要施加另外一个力,让它重新回到成一定角度的运动。

同时,在小球移动的过程中速度回增加。如果速度太快,你需要增加线型阻尼,以便球慢下来。

现在 Playing 状态准备就绪,是时候开始游戏了!

回到 GameScene.swift 将 touchesBegan(_:with:) 方法替换为:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
  switch gameState.currentState {
  case is WaitingForTap:
    gameState.enter(Playing.self)
    isFingerOnPaddle = true

  case is Playing:
    let touch = touches.first
    let touchLocation = touch!.location(in: self)

    if let body = physicsWorld.body(at: touchLocation) {
      if body.node!.name == PaddleCategoryName {
        isFingerOnPaddle = true
      }
    }

  default:
    break
  }
}

检查游戏的当前状态,并根据当前状态做相应的改变。然后,需要修改 update(_:) 方法为:

override func update(_ currentTime: TimeInterval) {
  gameState.update(deltaTime: currentTime)
}

update(_:) 方法在每帧刷新时调用。在这里你调用了 Playing 状态的 update(deltaTime:) 方法来控制球的速度。

Build & run,点击屏幕,状态机开始生效了!

[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分
[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分

游戏结束状态

GameOver 状态在竹砖被摧毁,或者小球掉到屏幕底部后发生。

打开 Game States 文档夹下的 GameOver.swift 文档, 在 didEnter(from:) 方法添加:

if previousState is Playing {
  let ball = scene.childNode(withName: BallCategoryName) as! SKSpriteNode
  ball.physicsBody!.linearDamping = 1.0
  scene.physicsWorld.gravity = CGVector(dx: 0.0, dy: -9.8)
}

当游戏进入 GameOver 状态,设置了小球的线性阻尼和重力加速度,调至小球掉到地板上并逐渐变慢。

这就是游戏结束状态。接下来实现判定输赢的代码!

有赢就有输

状态机也准备好了,游戏接近完成。现在你需要判断游戏的输赢。

打开 GameScene.swift,添加一个助手方法:

func isGameWon() -> Bool {
  var numberOfBricks = 0
  self.enumerateChildNodes(withName: BlockCategoryName) {
    node, stop in
    numberOfBricks = numberOfBricks + 1
  }
  return numberOfBricks == 0
}

这个方法通过遍历场景中的所有子节点检查场景中还剩下几块砖。对于每个子节点,判断名字是否叫做 BlockCategoryName。如果一块砖都没有了,判定玩家胜,返回返回 true。

在 gametState 属性声明下增加一个属性:

var gameWon : Bool = false {
  didSet {
    let gameOver = childNode(withName: GameMessageName) as! SKSpriteNode
    let textureName = gameWon ? "YouWon" : "GameOver"
    let texture = SKTexture(imageNamed: textureName)
    let actionSequence = SKAction.sequence([SKAction.setTexture(texture), 
      SKAction.scale(to: 1.0, duration: 0.25)])

    gameOver.run(actionSequence)
  }
}

这里,你定义了一个 gameWon 变量,并定义了它的 didSet 属性观察器。这允许你观察属性值的改变,并作出处理。这里,你将 GameMessage 节点的贴图修改为 YouWon 或 GameOver,并显示到屏幕上。

注意:属性观察器有一个参数,你可以用来读取新值(在 willSet 中)和旧值(在 didSet 中),这样当变化发生时可以对二者进行比较。这 2 个参数默认叫做 newValue 和 oldValue,如果你没有提供替代的名字的话。如果你想进一步了解这方面的内容,请阅读 Swift 编程语言:声明

然后,修改 didBegin(_:) 方法。

首先,在 didBegin(_:) 方法一开始添加:

if gameState.currentState is Playing {
// 这里是原来的代码...
} // if 语句结束

这会防止游戏在未处于 Playing 状态时进行碰撞检测。

将这一句:

print("Hit bottom. First contact has been made.")

替换为:

gameState.enter(GameOver.self)
gameWon = false

当球碰到屏幕底部,游戏结束。

将 // TODO: 一句替换为:

if isGameWon() {
  gameState.enter(GameOver.self)
  gameWon = true
}

当所有的砖块被击碎后游戏胜利!

最后,在 touchesBegan(_:with:) 的 default 分支之前添加:

case is GameOver:
  let newScene = GameScene(fileNamed:"GameScene")
  newScene!.scaleMode = .aspectFit
  let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
  self.view?.presentScene(newScene!, transition: reveal)

你的游戏终于完成了!Build & run!

[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分
[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分

终止触摸

现在游戏已经完成了,让我们给更上一层楼,为它添加一些新功能!你将在球发生碰撞以及砖块被击碎时增加一些音效。游戏结束时也会添加一小段音乐。最后,为小球添加一个专门的粒子发射器,当它反弹时,给它一段尾迹。

添加音效

为了节省时间,项目中已经添加了几个声音文档。首先,打开 GameScene.swift,添加如下常量,就在 gameWon 变量下边:

let blipSound = SKAction.playSoundFileNamed(“pongblip”, waitForCompletion: false)

let blipPaddleSound = SKAction.playSoundFileNamed(“paddleBlip”, waitForCompletion: false)

let bambooBreakSound = SKAction.playSoundFileNamed(“BambooBreak”, waitForCompletion: false)

let gameWonSound = SKAction.playSoundFileNamed(“game-won”, waitForCompletion: false)

let gameOverSound = SKAction.playSoundFileNamed(“game-over”, waitForCompletion: false)

上面定义了一堆的 SKAction 常量,每个加载不同的声音文档。因为在使用这些动作之前定义,它们会预加载进内存,防止在第一次播放时游戏出现卡顿。

然后,在 didMove(to:) 方法中,将设置小球的 contactTestBitMask 一句改成:

    ball.physicsBody!.contactTestBitMask = BottomCategory | BlockCategory | BorderCategory | PaddleCategory

没有任何新东西,你在小球的 contactTestBitMask 中添加了 BorderCategory 和 PaddleCategory 以便检测小球和屏幕边框、木板的碰撞。

修改 didBegin(_:) 方法,让它根据 firstBody 和 secondBody 播放对应的音效:

```swift
// 1
if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == BorderCategory {
  run(blipSound)
}

// 2
if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == PaddleCategory {
  run(blipPaddleSound)
}




<div class="se-preview-section-delimiter"></div>
  1. 当小球从屏幕边框弹开时播放 blipSound。
  2. 当碰到木板是播放 blipPaddleSound。

当然,当球击碎砖块时,你需要播放破碎音,在 breakBlock(node:) 方法顶部添加:

run(bambooBreakSound)

最后,在 gameWon 变量的 didSet 观察器中插入这句:

run(gameWon ? gameWonSound : gameOverSound)

还有一个地方要改。

我们需要为小球添加一个粒子系统,当它反弹时会留下一段火焰一样的尾迹!

在 didMove(to:) 方法中添加代码:

// 1
let trailNode = SKNode()
trailNode.zPosition = 1
addChild(trailNode)
// 2
let trail = SKEmitterNode(fileNamed: "BallTrail")!
// 3
trail.targetNode = trailNode
// 4
ball.addChild(trail)
  1. 新建一个 SKNode,用于作为粒子系统的 targetNode 属性。
  2. 从 BallTrail.sks 文档创建一个 SKEmitterNode。
  3. 将它的 targetNode 设置为 trailNode。这会将粒子固定,这样它们会留下一个痕迹,而不是跟随小球运动。
  4. 通过 addChild 方式将 SKEmitterNode 绑定到小球上。

这这样了——你已经完成了!Build & run,你的游戏再增强后是这个样子:

[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分
[译]如何用 Sprite Kit 和 Swift 制作一个逃逸游戏-第二部分

结束

你可以从 这里 下载最终完成后的项目。

这是一个简单的逃逸游戏的例子。一旦完成了它,你还可以增加更多的内容。你可以添加积分,或者给砖块一个生命值,添加各种类别的砖块,小球必须击中砖块几次才能摧毁它们。你可以添加一些会赠与奖励的砖块或者能够提升等级的砖块!

如果你想学习更多 Sprite Kit 的课程,你可以阅读我们的 2D iOS& tvOS 游戏教程

这本书会教你所有关于制作 iOS & tvOS 游戏的知识——包括物理引擎、瓦片地图、粒子系统、以及如何通过一些美化和特效让你的游戏获得“加分”。

希望你喜欢本教程,如果有任何问题和评论,请在下面留言!