一、 简介
SpriteKit是苹果公司推出的iOS和OS X游戏开发框架。这个工具不仅提供了强有力的图形功能,而且还包括一个易于使用的物理引擎。最重要的是,你可以使用你熟悉的工具 ——Swift,Xcode和Interface Builder完成所有的工作!你可以用SpriteKit做很多的事情;但是,想了解它是如何工作的***方法就是使用它开发一个简单的游戏。
在本系列教程(2部分)中,你将要学习如何使用SpriteKit来开发一款Breakout游戏。在上篇中,我们在游戏场景中成功地添了挡板与小球;在本篇中,我们要往游戏场景中添加竹块,并实现游戏的所有其他逻辑。
二、 加入竹块
现在,既然你已经让小球跳跃起来并实现了接触方面的控制,那么接下来让我们添加一些竹块用于小球击打之用。毕竟这是一款打竹块游戏,是不是?
好,切换到文件GameScene.swift,然后在方法didMoveToView(_:)中添加以下代码:
- // 1
- let numberOfBlocks = 8
- let blockWidth = SKSpriteNode(imageNamed: "block").size.width
- let totalBlocksWidth = blockWidth * CGFloat(numberOfBlocks)
- // 2
- let xOffset = (CGRectGetWidth(frame) - 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: CGRectGetHeight(frame) * 0.8)
- block.physicsBody = SKPhysicsBody(rectangleOfSize: block.frame.size)
- block.physicsBody!.allowsRotation = false
- block.physicsBody!.friction = 0.0
- block.physicsBody!.affectedByGravity = false
- block.physicsBody!.dynamic = false
- block.name = BlockCategoryName
- block.physicsBody!.categoryBitMask = BlockCategory
- block.zPosition = 2
- addChild(block)
- }
此代码在屏幕上将创建居中的八块竹块。具体来说,上面代码段实现了:
(1)建立了一些有用的常量,用于保存竹块数量及宽度值等。
(2)计算x偏移量,它对应于屏幕的左边框和***个竹块之间的距离。这里使用屏幕宽度减去所有竹块的宽度,然后除以2来计算。
(3)创建竹块并配置每个竹块适当的物理属性,并使用 blockWidth和xOffset变量来安排每一个的位置。
现在,构建并运行一下你的游戏,并注意观察!请参考下图。
现在,竹块已到位。但是,为了监听小球和竹块之间的碰撞,你必须更新小球的 contactTestBitMask掩码。仍然在 GameScene.swift文件中,编辑didMoveToView(_:)方法中现有的代码行即可——向它添加一个额外的类别:
- ball.physicsBody!.contactTestBitMask = BottomCategory | BlockCategory
上述代码执行了BottomCategory和BlockCategory两个掩码间的按位或操作。其结果是,这两个特定类别的位都设置为1,而所有其他位仍均为零。现在,球与地板以及球和块之间的碰撞信息都会被发送给代理以便进一步处理。
三、 打竹块
现在,你已经准备好块与球之间的碰撞检测了。让我们将一个帮助方法添加到 GameScene.swift文件中,以便实现从场景中删除竹块:
- func breakBlock(node: SKNode) {
- let particles = SKEmitterNode(fileNamed: "BrokenPlatform")!
- particles.position = node.position
- particles.zPosition = 3
- addChild(particles)
- particles.runAction(SKAction.sequence([SKAction.waitForDuration(1.0), SKAction.removeFromParent()]))
- node.removeFromParent()
- }
此方法使用了参数SKNode。首先,它从 BrokenPlatform.sks 文件中创建SKEmitterNode的一个实例,然后将它的位置设置为该节点相同的位置。发射器节点的 zPosition 设置为 3;这样,粒子就能够显示在剩余的竹块上面。把粒子添加到场景后,节点(竹块)将被删除。
[注意]发射器节点是一种特殊类型的节点,它用于显示在场景编辑器中创建的粒子系统。若要检查它是如何配置的,你可以打开文件BrokenPlatform.sks,这是我为本教程专门创建的粒子系统。
剩下要做的唯一事情是根据情况相应地处理委托通知。在didBeginContact(_:) 的末尾添加以下内容:
- if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == BlockCategory {
- breakBlock(secondBody.node!)
- //TODO: check if the game has been won
- }
上面这些代码行检查是否小球和竹块间存在碰撞。如果是这样,你将节点传递给 breakBlock(_:) 方法并随着播放粒子动画从场景中删除竹块!
现在,生成并运行工程。你会注意到当小球击中竹块时竹块应该分开。
四、 游戏控制逻辑
现在,你已经创建了打竹块游戏所需要的所有元素,轮到玩家体验一下激动人心的胜利或是失败的痛苦的时候了!
(一)构建状态机
大多数游戏逻辑受游戏的当前状态所控制。例如,如果游戏是在“主菜单”状态下,那么玩家就不能移动,但如果游戏是在“播放”状态,玩家应该能移动。
大量的简单游戏都是通过使用布尔型变量并结合更新循环来管理游戏状态。通过使用状态机,随着你的游戏变得更加复杂你可以更好地组织代码。
一个状态机用来管理一组状态。其中,只有一个当前状态,并且有一套规则用于状态之间的过渡。随着游戏状态的变化,在退出前一个状态并进入下一状态时状态机都会运行某些方法。这些方法可用于从每个状态内部来控制游戏。在状态更改成功后,状态机将执行当前状态的更新循环。
苹果公司在iOS 9中推出了GameplayKit框架,此框架内置支持状态机,从而使使用状态机的工作非常容易。有关GameplayKit的使用细节,已经超出了本教程的范围;但在本教程中,你将使用其中的两个类:GKStateMachine 和 GKState 类。
(二)添加状态
在我们的打竹块游戏中,共有三种游戏状态:
- WaitingForTap:意味着游戏已完成加载并准备开始启动。
- Playing:处于玩游戏状态。
- GameOver:游戏结束(或者输或者赢)。
为了节省时间,已经有三个 GKState 类添加到项目中(如果好奇的话,你可以查看一下Game States组)。为了创建状态机,首先在 GameScene.swift 文件的顶部添加以下的导入语句:
- import GameplayKit
接下来,在语句var isFingerOnPaddle = false:下面插入这个类变量:
- lazy var gameState: GKStateMachineGKStateMachine = GKStateMachine(states: [
- WaitingForTap(scene: self),
- Playing(scene: self),
- GameOver(scene: self)])
通过定义此变量,你可以有效地创建打竹块游戏的状态机。注意:你正在使用GKState子类数组初始化 GKStateMachine。
(三)实现WaitingForTap状态
WaitingForTap状态意味着游戏已完成加载并准备开始启动了。玩家在屏幕上会看到“Tap to Play”的提示,在游戏进入播放状态之前将等待触摸事件。
现在,在didMoveToView(_:) 方法的末尾添加以下代码︰
- let gameMessage = SKSpriteNode(imageNamed: "TapToPlay")
- gameMessage.name = GameMessageName
- gameMessage.position = CGPoint(x: CGRectGetMidX(frame), y: CGRectGetMidY(frame))
- gameMessage.zPosition = 4
- gameMessage.setScale(0.0)
- addChild(gameMessage)
- gameState.enterState(WaitingForTap)
这将创建显示“Tap to Play”的提示消息,后来它也将用于显示“Game Over”消息。接下来,你需要告诉状态机进入 WaitingForTap 状态。
在 didMoveToView(_:)方法中,你还要删除如下一行:
- ball.physicsBody!.applyImpulse(CGVector(dx: 2.0, dy: -2.0)) // REMOVE
稍后,在本教程中,你需要把这段代码移动到游戏播放状态处。
现在,打开 WaitingForTap.swift 文件。使用如下代码替换DidEnterWithPreviousState(_:)方法和 willExitWithNextState(_:)方法︰
- override func didEnterWithPreviousState(previousState: GKState?) {
- let scale = SKAction.scaleTo(1.0, duration: 0.25)
- scene.childNodeWithName(GameMessageName)!.runAction(scale)
- }
- override func willExitWithNextState(nextState: GKState) {
- if nextState is Playing {
- let scale = SKAction.scaleTo(0, duration: 0.4)
- scene.childNodeWithName(GameMessageName)!.runAction(scale)
- }
- }
当游戏进入WaitingForTap状态时,didEnterWithPreviousState(_:) 方法执行。此函数只是用于放大消息“Tap to Play”相应的精灵,提示玩家开始游戏。
当游戏退出 WaitingForTap状态并进入Playing状态时,会调用 willExitWithNextState(_:)方法,同时消息“Tap to Play”缩小为0。
现在,生成和运行工程,然后点击屏幕来玩玩吧!
好了,现在当你点击屏幕时没事发生。接下来要介绍的游戏状态正是用来解决这个问题!
(四)玩游戏状态
Playing状态将启动游戏并管理小运动球速度。
首先,切换回 GameScene.swift 文件并实现下面的帮助方法︰
- func randomFloat(from from:CGFloat, to:CGFloat) -> CGFloat {
- let rand:CGFloat = CGFloat(Float(arc4random()) / 0xFFFFFFFF)
- return (rand) * (to - from) + from
- }
这个工具函数会返回位于两个传入参数指定的数字之间的随机数。你将使用它在小球运动的初始方向方面加入一些可变性。
现在,打开 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
- }
- }
这段代码只是实现返回一个正数或者负数的功能。这向小球的运动方向方面添加了一点随机性。
接下来,将此代码添加到 didEnterWithPreviousState(_:):
- if previousState is WaitingForTap {
- let ball = scene.childNodeWithName(BallCategoryName) as! SKSpriteNode
- ball.physicsBody!.applyImpulse(CGVector(dx: randomDirection(), dy: randomDirection()))
- }
当游戏进入Playing状态时,小球精灵被检索到,并激活其applyImpulse(_:) 方法。
接下来,将此代码添加到 updateWithDeltaTime(_:) 方法 ︰
- let ball = scene.childNodeWithName(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
- }
当游戏的每帧中处于Playing状态时将调用updateWithDeltaTime(_:)方法。代码中,取得小球数据并检查其速度,本质上对应于运动速度。如果沿 x 或 y方向的 速度低于某一阈值,小球可能被卡住而表现为不停地蹦蹦跳跳,或不停地从一边运动到另一边。如果发生这种情况,需要应用另一种脉冲,从而把它强制性转入角运动状态下。
而且,球的速度随着蹦跳可能不断增加。如果太高了,你需要增加线性阻尼,这样小球最终会慢下来。
现在,玩状态设置了,是时候添加代码来启动游戏了!
在文件GameScene.swift中,将 touchesBegan(_:withEvent:)方法 替换成下面的新代码:
- override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
- switch gameState.currentState {
- case is WaitingForTap:
- gameState.enterState(Playing)
- isFingerOnPaddle = true
- case is Playing:
- let touch = touches.first
- let touchtouchLocation = touch!.locationInNode(self)
- if let body = physicsWorld.bodyAtPoint(touchLocation) {
- if body.node!.name == PaddleCategoryName {
- isFingerOnPaddle = true
- }
- }
- default:
- break
- }
- }
上面代码可以使游戏检查游戏的当前状态,并相应地更改状态。接下来,你需要重写 update(_:) 方法并修改成像这样:
- override func update(currentTime: NSTimeInterval) {
- gameState.updateWithDeltaTime(currentTime)
- }
在渲染每一帧之前都会调用 update(_:) 方法。正是在此处,我们调用玩状态对应的updateWithDeltaTime(_:) 方法来管理小球的运动速度。
现在,生成并运行项目,然后点击屏幕来查看状态机在游戏中的作用!
(五)游戏结束状态
当所有的竹块被压跨或小球跌落到屏幕的底部时GameOver状态发生。
现在,我们打开位于Game States组中的GameOver.swift文件,并将下面这些代码行添加到方法didEnterWithPreviousState(_:)中:
- if previousState is Playing {
- let ball = scene.childNodeWithName(BallCategoryName) as! SKSpriteNode
- ball.physicsBody!.linearDamping = 1.0
- scene.physicsWorld.gravity = CGVectorMake(0, -9.8)
- }
当游戏进入GameOver状态时,线性阻尼应用于小球而且重力得到恢复,从而导致小跌落到地上,速度也慢下来。
关于GameOver状态,我们就讨论至此。接下来要实现的代码是确定玩家是赢了还是输掉了游戏!
(六)游戏结局
到现在,既然状态机都设置好了,可以说游戏的绝大部分已经开发结束。现在,我们需要想一种办法来确定游戏的输赢。
打开文件GameScene.swift并添加下面的帮助方法:
- func isGameWon() -> Bool {
- var numberOfBricks = 0
- self.enumerateChildNodesWithName(BlockCategoryName) {
- node, stop in
- numberOfBricksnumberOfBricks = numberOfBricks + 1
- }
- return numberOfBricks == 0
- }
此方法通过遍历场景中子结点来检查场景中还留下多少竹块。对于每一个子结点,它要检查子结点名字是否等于 BlockCategoryName。如果场景中没有留下竹块,那么玩家赢得了当前游戏,方法返回 true。
现在,将如下属性添加到类的顶部,也就是恰好位于属性gameState的下面:
- var gameWon : Bool = false {
- didSet {
- let gameOver = childNodeWithName(GameMessageName) as! SKSpriteNode
- let textureName = gameWon ? "YouWon" : "GameOver"
- let texture = SKTexture(imageNamed: textureName)
- let actionSequence = SKAction.sequence([SKAction.setTexture(texture),
- SKAction.scaleTo(1.0, duration: 0.25)])
- gameOver.runAction(actionSequence)
- }
- }
在这里,你创建了gameWon变量,并为之附加一个didSet属性观察器。这将允许你观察属性值的变化情况并做出相应的反应。在上面实现代码中,改变游戏消息精灵的纹理以反映游戏是赢了还是输了,然后在屏幕上显示结果。
[注意]属性观察器(Property Observer)有一个允许您检查新值或旧值的参数。当发生属性变化时允许值变化的比较。如果你不提供名称的话,它们自己都有默认名称;在上述代码中分别是newValue和oldValue。
接下来,让我们编辑一下didBeginContact(_:) 方法,如下所示:
首先,把下面代码添加到didBeginContact(_:)方法的最顶端:
- if gameState.currentState is Playing {
- // Previous code remains here...
- } // Don't forget to close the 'if' statement at the end of the method.
这段代码的功能是:当游戏还未处于玩状态时,防止任何的接触发生。
接下来,使用下面这段代码:
- print("Hit bottom. First contact has been made.")
替换掉下面的代码:
- gameState.enterState(GameOver)
- gameWon = false
现在,当小球碰到屏幕的底部时游戏结束。
请使用如下代码替换掉//TODO:部分:
- if isGameWon() {
- gameState.enterState(GameOver)
- gameWon = true
- }
- When all the blocks are broken you win!
- Finally, add this code to touchesBegan(_:withEvent:) just above default:
- case is GameOver:
- let newScene = GameScene(fileNamed:"GameScene")
- newScene!.scaleMode = .AspectFit
- let reveal = SKTransition.flipHorizontalWithDuration(0.5)
- self.view?.presentScene(newScene!, transition: reveal)
至此,你的游戏已经完成!你可以构建并运行它了。
五、 游戏润色
现在,打竹块游戏主要功能开发完毕。接下来,让我们在游戏中添加些许的润色!每当小球发生接触和当竹块破裂时加入一些音效。当游戏结束的时候,也添加一种快速爆炸的音乐效果。***,您将把一个粒子发射器添加到小球,以便当小球在屏幕周围来回反弹时留下一道痕迹。
(一)加入声效
为了节省时间,项目中已经导入了各种声音文件。现在,打开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常量,其中每一个都将加载并播放声音文件。因为你在需要它们之前定义了这些操作,所以它们会被预先加载到内存,这在你***次播放声音时防止游戏延迟。
下一步,将在didMoveToView(_:)方法中设置小球的contactTestBitMask掩码的那一行更新为以下形式︰
- ball.physicsBody!.contactTestBitMask = BottomCategory | BlockCategory | BorderCategory | PaddleCategory
并没有什么新内容,只是在小球的contactTestBitMask掩码上添加了BorderCategory和PaddleCategory,这样你就可以检测到与屏幕边界的接触,以及当小球与挡板接触时使用。
接下来,让我们修改一下方法didBeginContact(_:)来加入声音效果,方法是把以下几行添加到设置firstBody和secondBody的if/else语句后面:
- // 1
- if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == BorderCategory {
- runAction(blipSound)
- }
- // 2
- if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == PaddleCategory {
- runAction(blipPaddleSound)
- }
此代码负责检查两个新的碰撞:
(1)在从屏幕边界反弹时播放blipSound声效。
(2)在小球与挡板接触时播放blipPaddleSound声效。
当然,你希望在小球打破竹块时使用令人满意的嘎吱声效。为此,你可以将下面一行添加到方法breakBlock(_:) 的顶部:
- runAction(bambooBreakSound)
***,在类顶部的针对变量gameWon创建的didSet属性观察器的里面插入下面的行码行即可:
- runAction(gameWon ? gameWonSound : gameOverSound)
(二)加入粒子系统
现在,让我们给小球添加一个粒子系统;这样一来,当它四处反弹时会留下一条火苗样式的轨迹!
为此,可以将下面的代码添加到方法didMoveToView(_:)中:
- // 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)将SKEmitterNode附加到小球身上;这可以通过将其添加为它的一个子节点来实现。
好了,所有的工作都已经做完!现在,你可以再次生成并运行项目来看看你的游戏在添加了一些小内容后是多么精致了。请参考下图。
六、 小结
强烈建议您下载本教程的实例代码以便进行进一步的研究(地址是https://cdn4.raywenderlich.com/wp-content/uploads/2016/04/BreakoutFinal_p2.zip)。
当然,本文给出的仅是一个简单版本的打竹块游戏,其实你还有很多可以要扩展的内容。例如,你可以添加评分功能,也可以扩展代码给特定竹块***时设置特定的得分值,建立不同类型的竹块,并在竹块被摧毁之前使小球不得不多次击打某些它们(或全部)。此外,你还可以添加一定特定类型的竹块使之掉落一定的奖金或道具,让挡板对竹块发射激光,等等。总之,任由你作主吧!