基于SpriteKit+Swift開發打竹塊游戲(下篇)
譯文一、 簡介
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)。
當然,本文給出的僅是一個簡單版本的打竹塊游戲,其實你還有很多可以要擴展的內容。例如,你可以添加評分功能,也可以擴展代碼給特定竹塊***時設置特定的得分值,建立不同類型的竹塊,并在竹塊被摧毀之前使小球不得不多次擊打某些它們(或全部)。此外,你還可以添加一定特定類型的竹塊使之掉落一定的獎金或道具,讓擋板對竹塊發射激光,等等。總之,任由你作主吧!