原文: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:) 方法中添加磚塊:
// 1let numberOfBlocks = 8let blockWidth = SKSpriteNode(imageNamed: "block").size.widthlet totalBlocksWidth = blockWidth * CGFloat(numberOfBlocks)// 2let xOffset = (frame.width - totalBlocksWidth) / 2// 3for 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 塊磚,並放在螢幕中央。 一些常量,比如磚塊的數目以及它們的寬。 計算 x 位移。這是第一塊磚和螢幕左邊沿的距離。用螢幕寬度減去8塊磚的總寬度再除以 2。 建立磚塊,設定每塊磚的物理屬性,並根據 blockWidth 和 xOffset 設定每塊磚的位置。
Build & run,看看效果。
磚塊準備好了。但為了監聽球和磚之間的碰撞,你必須修改小球的 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。當小球擊中竹磚,竹磚會四分五裂。
添加玩法
現在所有遊戲元素都就緒了,是時候讓玩家體驗輸贏的感覺了。 理解狀態機器
大部分遊戲邏輯都是受遊戲狀態控制的。例如,如果遊戲處於“主菜單”狀態,玩家將無法移動,如果遊戲處於 play 狀態,玩家才可以動。
大量簡單的遊戲通過在 update 迴圈中用布爾值來管理遊戲狀態。通過狀態機器,當遊戲變得複雜時你可以更好地組織代碼。
一個狀態機器通過單個的目前狀態和一系列狀態間轉換規則來管理一組狀態。當遊戲狀態發生改變,狀態機器會執行上一狀態的退出方法和下一狀態的進入方法。這些方法用於控制每個狀態下的玩法。當狀態成功改變,狀態機器會執行目前狀態的 update 迴圈。
蘋果從 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 = GameMessageNamegameMessage.position = CGPoint(x: frame.midX, y: frame.midY)gameMessage.zPosition = 4gameMessage.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,點擊螢幕開始玩遊戲。
好了,但當你點擊螢幕,什麼也不發生。那是下一個遊戲狀態的事情。 “遊戲中”狀態
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! SKSpriteNodelet maxSpeed: CGFloat = 400.0let 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,點擊螢幕,狀態機器開始生效了。
遊戲結束狀態
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。
終止觸摸
現在遊戲已經完成了,讓我們給更上一層樓,為它添加一些新功能。你將在球發生碰撞以及磚塊被擊碎時增加一些音效。遊戲結束時也會添加一小段音樂。最後,為小球添加一個專門的粒子發射器,當它反彈時,給它一段尾跡。 添加音效
為了節省時間,項目中已經添加了幾個音效檔。首先,開啟 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// 1if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == BorderCategory { run(blipSound)}// 2if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == PaddleCategory { run(blipPaddleSound)}<div class="se-preview-section-delimiter"></div>
當小球從螢幕邊框彈開時播放 blipSound。 當碰到木板是播放 blipPaddleSound。
當然,當球擊碎磚塊時,你需要播放破碎音,在 breakBlock(node:) 方法頂部添加:
run(bambooBreakSound)
最後,在 gameWon 變數的 didSet 觀察器中插入這句:
run(gameWon ? gameWonSound : gameOverSound)
還有一個地方要改。
我們需要為小球添加一個粒子系統,當它反彈時會留下一段火焰一樣的尾跡。
在 didMove(to:) 方法中添加代碼:
// 1let trailNode = SKNode()trailNode.zPosition = 1addChild(trailNode)// 2let trail = SKEmitterNode(fileNamed: "BallTrail")!// 3trail.targetNode = trailNode// 4ball.addChild(trail)
建立一個 SKNode,用於作為粒子系統的 targetNode 屬性。 從 BallTrail.sks 檔案建立一個 SKEmitterNode。 將它的 targetNode 設定為 trailNode。這會將粒子固定,這樣它們會留下一個痕迹,而不是跟隨小球運動。 通過 addChild 方式將 SKEmitterNode 綁定到小球上。
這這樣了——你已經完成了。Build & run,你的遊戲再增強後是這個樣子:
結束
你可以從這裡下載最終完成後的項目。
這是一個簡單的逃逸遊戲的例子。一旦完成了它,你還可以增加更多的內容。你可以添加積分,或者給磚塊一個生命值,添加各種類別的磚塊,小球必須擊中磚塊幾次才能摧毀它們。你可以添加一些會贈與獎勵的磚塊或者能夠提升等級的磚塊。
如果你想學習更多 Sprite Kit 的課程,你可以閱讀我們的 2D iOS& tvOS 遊戲教程。
這本書會教你所有關於製作 iOS & tvOS 遊戲的知識——包括物理引擎、瓦片地圖、粒子系統、以及如何通過一些美化和特效讓你的遊戲獲得“加分”。
希望你喜歡本教程,如果有任何問題和評論,請在下面留言。