由于很多小伙伴要demo我就不一一發了,直接丟在github上自己下載吧:https://github.com/sideslash/FlappyBird
最近利用業余時間根據官方文檔和網上的資料學習了蘋果官方推出的2D游戲開發引擎Spritekit基本知識,模仿做了一個前兩年火了一火的小游戲flappy bird練練手,現在就來一步一步講講這個游戲我的實現方法。
因為Apple推行Swift開發語言,Swift也將是以后iOS方面開發的主力語言,所有這篇實例我們也就先拋棄Objective-C,而使用Swift3開發語言,如果你還不熟悉Swift的基本語法那趕快去學學吧,如果你已經了解了那么就跟著我繼續吧!
先看看最后最后做完的樣子
1.準備工作
新建工程
先新建一個工程項目,模板選擇Game
語言選擇Swift,開發庫選擇SpriteKit
刪除示例文件和代碼
然后我們的工程就建立好了。接著我們就先把那些Xcode自動創建的示例文件和代碼都刪除掉,先看文件目錄欄,把GameScene.sks和Actions.sks兩個文件刪除掉,然后進入Assets.xcassets把里面的那張飛機圖片刪除掉。這樣我們就把用不到的文件都刪掉了,接下來繼續刪除沒用的代碼
首先進入GameViewController.swift文件,找到那個viewDidLoad()方法,看到里面下面內容。
注意看這一句
if let scene = SKScene(fileNamed: "GameScene") ?{
........
}
這一句是通過一個GameScene的sks文件來創建一個場景實例對象,由于咱們剛剛把GameScene.sks文件刪除了,所以我們現在是創建不出來場景的,所以我們需要把viewDidLoad()里面的代碼改成下面的內容
super.viewDidLoad()
if let view = self.view as! SKView? {
? ? let scene = GameScene(size: view.bounds.size) ?//通過代碼創建一個GameScene類的實例對象
? ? scene.scaleMode = .aspectFill
? ? view.presentScene(scene)
? ? view.ignoresSiblingOrder = true
? ? view.showsFPS = true
? ? view.showsNodeCount = true
}
現在我們就把通過sks文件創建場景對象改成了通過代碼直接創建一個叫做GameScene類的實例對象了。
到這里我們的GameViewController文件就改完了。接下來我們進入GameScene.swift我們最終要的場景類的文件看一看
。。。。我勒個去。。。。你會發現Xcode給我們自動添加了這么多示例的代碼,然并卵,都刪掉!刪到跟下圖一樣只留下didMove()和update()兩個空方法即可!
我們在didMove方法里先加上一句代碼,設置場景的背景色為淡藍色,現在我們就可以運行一下程序看看顯示的是不是一個淡藍色的界面
self.backgroundColor = SKColor(red: 80.0/255.0, green: 192.0/255.0, blue: 203.0/255.0, alpha: 1.0)
didMove()方法會在當前場景被顯示到一個view上的時候調用,你可以在里面做一些初始化的工作
這樣看來一切正常,我們自己的場景終于顯示在玩家面前了。
導入資源文件
我自己選用了3張小鳥的png圖片,一張翅膀上抬、一張翅膀放平、一張翅膀下墜,這樣我們一會就可以做出小鳥在飛的效果。圖片大小都是50*43,你也可以自己在網上找幾張類似的圖片來使用,尺寸別太大,雖然你可以通過代碼改變小鳥的小大,但是如果你的圖片本身很大,你實際需要它顯示的比較小,那么對性能其實有點浪費,不過對于這種小游戲來說你想怎么弄都沒問題的。
PS:稍后如果我把我的工程放上網你們也可以直接下載我的工程,直接用里面的圖片素材
導入圖片注意:先新建一個叫player.atlas的文件夾,然后我們把這三張圖片放到這個文件夾下,然后再將這個文件夾拖到工程里面,注意要勾選copy item if need。
為什么要這樣做?
因為當你把一類相關的貼圖圖片素材放在一個.atlas文件夾里,編譯程序的時候Xcode會把這個文件夾里的圖片都導入“紋理圖集”里,相對于只用獨立的圖片文件而言,使用紋理圖集會非常顯著地提升游戲的渲染性能
然后我們再將另外三張圖片丟入工程的Asserts.xcasserts里即可,分別是地面(floor),上水管(topPipe)和下水管(bottomPipe)
至此準備工作全部完成,我們終于可以開始敲代碼了!
2.布置場景和游戲狀態
PS:由于這個游戲比較小也不復雜,所以咱們也就不設計什么高級的開發模式來開發這個游戲了,全部的布局邏輯代碼全部都寫在GameScene.swift文件里。
布置地面
我們先進入GameScene.swift,給GameScene這個類添加兩個地面的變量
var floor1: SKSpriteNode!
var floor2: SKSpriteNode!
然后再在didMove()方法里添加下面的代碼
// Set floors
floor1 = SKSpriteNode(imageNamed: "floor")
floor1.anchorPoint = CGPoint(x: 0, y: 0)
floor1.position = CGPoint(x: 0, y: 0)
addChild(floor1)
floor2 = SKSpriteNode(imageNamed: "floor")
floor2.anchorPoint = CGPoint(x: 0, y: 0)
floor2.position = CGPoint(x: floor1.size.width, y: 0)
addChild(floor2)
可以看到為什么我弄了兩個floor?因為我們一會要讓floor向左移動,使得看起來小鳥在向右飛,所以我弄了兩個floor頭尾兩連地放著,等會我們就讓兩個floor一起往左邊移動,當左邊的floor完全超出屏幕的時候,就馬上把左邊的floor移動憑借到右邊的floor后面然后繼續向左移動,如此循環下去。
我將anchorPoint設置為(0,0),即SpriteNode的左下角的點作為這個node的錨點,是為了方便定位floor,如果不熟悉錨點是什么的朋友趕快去搜一搜!
SKScene場景的默認錨點為(0,0)即左下角,SKSpriteNode的默認錨點為(0.5,0.5)即它的中心點。
另外SpriteKit的坐標系是向右x增加,向上y增加。而不像做iOS應用開發時候UIKit是向右x增加,向下y增加!
現在讓我們運行一下程序就可以看到我們的地面出現了!
放置小鳥
我們來講我們的游戲主角小鳥顯示出來,同樣給GameScene類增加一個小鳥的變量
var bird: SKSpriteNode!
然后在didMove()方法里,在添加floor的后面添加下面代碼
bird = SKSpriteNode(imageNamed: "player1")
addChild(bird)
這樣我們就將我們的主角小鳥添加到場景上了。等等!你還沒給小鳥設置position呢,不是應該把小鳥放到屏幕中間開始么?
游戲狀態
沒錯,但是在我們設置它位置之前,我們先構想一下我們這個游戲整個運行的流程:
1.一開始小鳥在屏幕中間飛,地面也在移動,但是這個時候還沒有真的開始,所以還不會有水管出現。
2.當玩家準備好了點了一下屏幕,游戲正式開始,小鳥會受重力作用往下墜落,水管開始出現,此時玩家每點擊一次屏幕小鳥就有會受一次上升的力。
3.如果小鳥碰到水管或者小鳥碰到地面了,則游戲結束,小鳥停止飛的動作,場景里的水管和地面都停住不動。此時玩家再點擊屏幕則回到上面1初始狀態。
可以看到,玩家的操作和場景內容的移動與否都與當前游戲的進程狀態有關系,我們也可以看出有三個狀態:1初始狀態 2游戲進行中狀態 3游戲結束狀態
那我們現在GameScene類里面定義一個枚舉來表示不同的狀態,同時給GameScene增加一個游戲狀態的變量
enum GameStatus {
? ? case idle ? ?//初始化
? ? case running ? ?//游戲運行中
? ? case over ? ?//游戲結束
}
var gameStatus: GameStatus = .idle ?//表示當前游戲狀態的變量,初始值為初始化狀態
現在我們知道了整個游戲會有三個進程狀態,那么我們就給GameScene增加三個對應的方法,分別來處理這個三個狀態。
func shuffle() ?{?
//游戲初始化處理方法
gameStatus = .idle
}
func startGame() ?{?
//游戲開始處理方法
gameStatus = .running
}
func gameOver() ?{
//游戲結束處理方法
gameStatus = .over
}
可以看到目前我們只在這三個方法里分別修改了當前游戲的進程狀態變量。
接下來大家再想想上面那個沒解決的問題,設置小鳥初始化的位置該放在那里呢?當然是初始化shuffle()方法里啦,添加下面代碼內容到shuffle()方法里
bird.position = CGPoint(x: self.size.width * 0.5, y: self.size.height * 0.5)
那么我們應該什么時候來調用這三個方法呢?
首先在場景初始化完成的時候,肯定要先調用一下shuffle()初始化,所有我們在didMove()方法里的最后面添加一句
shuffle()
然后再給GameScene添加下面這個方法
override func touchesBegan(_ touches: Set, with event: UIEvent?) {
? ? switch gameStatus {
? ? ? ?case .idle:
? ? ? ? ? startGame() ?//如果在初始化狀態下,玩家點擊屏幕則開始游戲
? ? ? ?case .running:
? ? ? ? ? print("給小鳥一個向上的力") ? //如果在游戲進行中狀態下,玩家點擊屏幕則給小鳥一個向上的力(暫時用print一句話代替)
? ? ? case .over:
? ? ? ? ?shuffle() ?//如果在游戲結束狀態下,玩家點擊屏幕則進入初始化狀態
? ? ? }
}
touchesBegan()是SKScene自帶的系統方法,當玩家手指點擊到屏幕上的時候會調用,可以看到我們用switch語句來處理了三種不同的游戲狀態下,玩家點擊屏幕后做出的不同響應
現在讓我們來運行一下程序,可以看到小鳥也正常的出現在屏幕中間了
3.讓內容動起來
我們目前可以看到雖然我們看到了小鳥和地面,但是怎么都是死的,這也太假了,那么接下來我們要讓他們都動起來,讓小鳥好像真的在飛
移動地面
我們先來移動地面,我們給GameScene添加一個叫做moveScene()的方法,用來使場景內的物體向左移動起來,暫時我們先讓地面移動,稍后還會在這個方法里添加讓水管移動的代碼
func moveScene() {
? ? //make floor move
? ? floor1.position = CGPoint(x: floor1.position.x - 1, y: floor1.position.y)
? ? floor2.position = CGPoint(x: floor2.position.x - 1, y: floor2.position.y)
? ? //check floor position
? ? if floor1.position.x < -floor1.size.width {
? ? ? ? floor1.position = CGPoint(x: floor2.position.x + floor2.size.width, y: floor1.position.y)
? ? }
? ? if floor2.position.x < -floor2.size.width {
? ? ? ? floor2.position = CGPoint(x: floor1.position.x + floor1.size.width, y: floor2.position.y)
? ? }
}
我們在這個方法里先讓兩個floor向左移動1的位置,然后檢查兩個floor是否已經完全超出屏幕的左邊,超出的floor則移動到另一個floor的右邊。
那我們該什么時候調用這個方法呢?我們可以在update()方法里調用moveScene()方法。
還記得update()方法么?我們最開始留下的兩個空方法,一個是didMove()另一個就是update()呀!
update()方法為SKScene自帶的系統方法,在畫面每一幀刷新的時候就會調用一次
那么就在update()方法里添加一下內容代碼
if gameStatus != .over {
? ? moveScene()
}
如果當前游戲狀態不是結束的,則每次調用update()的時候都調用moveScene()方法,回想一下我們上面提高的游戲流程是不是應該這樣呢?
運行一下程序,看我們的地面是不是東西來,就想鳥在向右飛一樣
小鳥動起來
現在我們讓鳥也飛起來吧!
先給GameScene添加兩個新的方法,一個是讓小鳥開始飛,一個是讓小鳥停止飛(游戲結束,小鳥墜地了就要停止飛)
//開始飛
func birdStartFly() {
? ? let flyAction = SKAction.animate(with: [SKTexture(imageNamed: "player1"),
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?SKTexture(imageNamed: "player2"),
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?SKTexture(imageNamed: "player3"),
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?SKTexture(imageNamed: "player2")],
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?timePerFrame: 0.15)
? ? bird.run(SKAction.repeatForever(flyAction), withKey: "fly")
}
//停止飛
func birdStopFly() {
? ? bird.removeAction(forKey: "fly")
}
在birdStartFly()方法里
我們用了準備的3張小鳥的圖片生成了四個SKTexture紋理對象,他們四個連起來就是小鳥的翅膀從上->中->下->中這樣一個循環過程
然后用這一組紋理創建了一個飛的動作(flyAction),同時設置紋理的變化時間為0.15秒
然后讓小鳥重復循環執行這個飛的動作,同時給這個動作使用了一個叫"fly"的key來標識
在birdStopFly()方法里只有一句代碼,就是把fly這個動作從小鳥身上移除掉
接下來我們分別在shuffle()方法里添加一句讓小鳥開始飛,
birdStartFly()
在gameOver()方法里添加一句讓小鳥停止飛
birdStopFly()
現在運行程序就能看到小鳥像是真的在往右邊飛!
4.隨機創造水管
現在我們地面有了,小鳥也有了,該要讓水管上場了。
我們先想想水管出現有什么特點
1.成對的出現,一個在上一個在下,上下兩個水管中間留有一定的高度的距離讓小鳥能通過
2.上下水管之間的高度距離是隨機的,但是有個最小值和最大值
3.一對水管出現之后向左移動,移動出了屏幕左側就要把它移除掉
4.一對水管出現之后,間隔一定的時間,再產生另一對水管,間隔的時間也是隨機數,也要設一個最大和最三小值
5.在游戲初始化狀態下要停止重復創建水管,同時要移除掉場景里上一句殘留的水管。在游戲進行中狀態下才重復創建水管。在游戲結束狀態下,停止創建水管,如果場景里還有存在水管,則停止左移
那么我準備了四個方法來實現水管功能(5個方法不是跟上面5個特點一一對應喔!)
1.方法startCreateRandomPipesAction()? ? 開始重復創建水管的動作方法
2.方法stopCreateRandomPipesAction() ? ? 停止創建水管的動作方法 ? ?
3.方法createRandomPipes() ? ?具體某一次創建一對水管方法,在此方法里計算上下水管大小隨機數
4.方法addPipes(topSize: CGSize, bottomSize: CGSize) ?添加一對水管到場景里,這個方法有兩個參數分別是上水管和下水管的大小,在此方法里僅僅做的是創建兩個SKSpriteNode對象,然后將他們加到場景里
5.方法removeAllPipesNode() ?移除所有正在場景里的水管
我們一個方法一個方法的來
首先添加下面addPipes(topSize: CGSize, bottomSize: CGSize)方法到GameScene里面
func addPipes(topSize: CGSize, bottomSize: CGSize) {
? ? ? ? //創建上水管
? ? ? ? let topTexture = SKTexture(imageNamed: "topPipe") ? ? ?//利用上水管圖片創建一個上水管紋理對象
? ? ? ? let topPipe = SKSpriteNode(texture: topTexture, size: topSize) ?//利用上水管紋理對象和傳入的上水管大小參數創建一個上水管對象
? ? ? ? topPipe.name = "pipe" ? //給這個水管取個名字叫pipe
? ? ? ? topPipe.position = CGPoint(x: self.size.width + topPipe.size.width * 0.5, y: self.size.height - topPipe.size.height * 0.5) //設置上水管的垂直位置為頂部貼著屏幕頂部,水平位置在屏幕右側之外
? ? ? ? //創建下水管,每一句方法都與上面創建上水管的相同意義
? ? ? ? let bottomTexture = SKTexture(imageNamed: "bottomPipe")
? ? ? ? let bottomPipe = SKSpriteNode(texture: bottomTexture, size: bottomSize)
? ? ? ? bottomPipe.name = "pipe"
? ? ? ? bottomPipe.position = CGPoint(x: self.size.width + bottomPipe.size.width * 0.5, y: self.floor1.size.height + bottomPipe.size.height * 0.5) ?//設置下水管的垂直位置為底部貼著地面的頂部,水平位置在屏幕右側之外
? ? ? ? //將上下水管添加到場景里
? ? ? ? addChild(topPipe)
? ? ? ? addChild(bottomPipe)
}
現在你有個一個helper方法可以添加兩個真實的水管到場景里了,我們繼續講下面createRandomPipes()方法代碼添加到GameScene里面
func createRandomPipes() {
? ? ? ? //先計算地板頂部到屏幕頂部的總可用高度
? ? ? ? let height = self.size.height - self.floor1.size.height
? ? ? ? //計算上下管道中間的空檔的隨機高度,最小為空檔高度為2.5倍的小鳥的高度,最大高度為3.5倍的小鳥高度
? ? ? ? let pipeGap = CGFloat(arc4random_uniform(UInt32(bird.size.height))) + bird.size.height * 2.5
? ? ? ? //管道寬度在60
? ? ? ? let pipeWidth = CGFloat(60.0)
? ? ? ? //隨機計算頂部pipe的隨機高度,這個高度肯定要小于(總的可用高度減去空檔的高度)
? ? ? ? let topPipeHeight = CGFloat(arc4random_uniform(UInt32(height - pipeGap)))
? ? ? ? ?//總可用高度減去空檔gap高度減去頂部水管topPipe高度剩下就為底部的bottomPipe高度
? ? ? ? let bottomPipeHeight = height - pipeGap - topPipeHeight
? ? ? ? //調用添加水管到場景方法
? ? ? ? addPipes(topSize: CGSize(width: pipeWidth, height: topPipeHeight), bottomSize: CGSize(width: pipeWidth, height: bottomPipeHeight))
}
現在我們只要調用一次這個createRandomPipes()方法,就能真的創建一個一堆隨機的上下水管并且把他們添加到場景里面了!
創建隨機數通常使用以下兩個方法
arc4random() -> UInt32?
這個方法會隨機床身給一個無符號Int32以內的整數
arc4random_uniform(_ __upper_bound: UInt32) -> UInt32
這個方法比上面那個方法多一個參數,這個參數就是設置這個能產生隨機數的最大值,也就是限定了一個范圍
PS:可以看到我們在這個方法里面計算了好幾個隨機數,最后的目的就是為了計算出上下水管的大小。這里具體的隨機數的大小范圍是可以根據你自己的喜好更改的!比如上下水管的空檔隨機高度,如果你想游戲容易一點就讓這個隨機數最小值變大一點,如果你想游戲難一點就讓隨機數最小值變小。另外我們水管的寬度是寫死60,你也可以讓這個寬度也是一個隨機數。。。
現在我們能創建一對水管了,那我想重復創建該怎么辦呢?那就需要將下面這個方法startCreateRandomPipesAction()添加到GameScene
func startCreateRandomPipesAction() {
? ? ? ? //創建一個等待的action,等待時間的平均值為3.5秒,變化范圍為1秒
? ? ? ? let waitAct = SKAction.wait(forDuration: 3.5, withRange: 1.0) ?
? ? ? ?//創建一個產生隨機水管的action,這個action實際上就是調用一下我們上面新添加的那個createRandomPipes()方法
? ? ? ? let generatePipeAct = SKAction.run { ?
? ? ? ? ? ? ? ? self.createRandomPipes()
? ? ? ? }
? ? ? ? //讓場景開始重復循環執行"等待" -> "創建" -> "等待" -> "創建"。。。。。
? ? ? ? //并且給這個循環的動作設置了一個叫做"createPipe"的key來標識它
? ? ? ? run(SKAction.repeatForever(SKAction.sequence([waitAct, generatePipeAct])), withKey: "createPipe")
}
現在我們只要調用一次startCreateRandomPipesAction()方法后,場景就會每隔一段時間就創建一堆水管添加到場景里了。那我們應該在哪里調用這個方法呢?明顯是在startGame()游戲開始方法里啦
所以在startGame()方法里面最后加上下面這一句
startCreateRandomPipesAction() ?//開始循環創建隨機水管
既然有個開始循環創建,那么就把停止循環創建的方法也加進來吧,添加下面stopCreateRandomPipesAction()方法到GameScene里
func stopCreateRandomPipesAction() {
? ? ? ? self.removeAction(forKey: "createPipe")
}
可以看到這個方法很簡單,僅僅是通過一個action的key將場景的重復創建水管的action移除掉即可。
接下來我我們在gameOver()方法里最后添加上下面這一句,就能讓游戲結束的時候也停止創建水管了
stopCreateRandomPipesAction()
還有最后一個方法要添加的就是移除掉場景里的所有水管,添加下面方法到GameScene
func removeAllPipesNode() {
? ? ? ? for pipe in self.children where pipe.name == "pipe" { ?//循環檢查場景的子節點,同時這個子節點的名字要為pipe
? ? ? ? ? ? ? ? pipe.removeFromParent() ?//將水管這個節點從場景里移除掉
? ? ? ? }
}
然后我們在shuffle()方法里的gameStatus = . idle后面加上下面這一句,這樣我們就能在每一局新開始初始換的時候將上一句可能殘留在場景里的舊水管清空
removeAllPipesNode()
好的!現在我們運行一下我們的游戲,記得游戲一開始是初始化狀態,要點擊一下屏幕才會游戲開始,看到了么每隔幾秒就會有一對水管天添加到場景里
等等!!!說好的水管呢????沒有看到呀!!!!!
沒錯你肯定看不到,因為你記得我們創建了兩個水管SpriteNode之后把他們的位置放在哪里么?我們把他們放在了屏幕右側之外了,你當然看不到啦。但是雖然你看不到你也知道它已經在場景了!注意看右下角那個黑色小條的內容node 和 fps,這是方便我們調試時候用的,顯示游戲場景里的實時的node數量和刷新率,最開始node是4,當你點擊了一下屏幕游戲開始了之后,每隔幾秒node就會加2,這個2就是我們的上下水管了!
所以我們還要讓水管動起來,找到之前寫的moveScene()方法,在移動地面代碼后面加上下面的代碼
//循環檢查場景的子節點,同時這個子節點的名字要為pipe
for pipeNode in self.children where pipeNode.name == "pipe" {?
? ? ? ? //因為我們要用到水管的size,但是SKNode沒有size屬性,所以我們要把它轉成SKSpriteNode
? ? ? ? if let pipeSprite = pipeNode as? SKSpriteNode {?
? ? ? ? ? ? ? ? //將水管左移1
? ? ? ? ? ? ? ? pipeSprite.position = CGPoint(x: pipeSprite.position.x - 1, y: pipeSprite.position.y)
? ? ? ? ? ? ? ? //檢查水管是否完全超出屏幕左側了,如果是則將它從場景里移除掉
? ? ? ? ? ? ? ? if pipeSprite.position.x < -pipeSprite.size.width * 0.5 {
? ? ? ? ? ? ? ? ? ? ? pipeSprite.removeFromParent()
? ? ? ? ? ? ? ?}
? ? ? ? }
}
因為moveScene()方法會在游戲進行中時,每一幀更新的update()方法里調用,所以你現在你再運行程序就會看到了水管跟著地面一起往左邊移動了!
5.物理世界
到此我們已經完成了這個游戲很大一部分了,但是這個游戲還有最重要一部分現在才出場,這就是模擬物理世界!
可以看到我們現在運行程序,小鳥沒有收到重力作用,不會下墜,點擊屏幕小鳥也不會向上飛,小鳥碰到水管也不會死掉,這就是因為缺少了物理世界的模擬。
我覺得物理的模擬是游戲引擎很重要的一個功能,它給了游戲的玩法和開發更多的可能性。那么什么是模擬物理世界?
比如你可以把一個場景當成我們生活的真實物理環境,里面會有重力,會有磁場會有引力場等等。場景里面的物理體會受各種場的影響,還能跟其他物理體有交互,比如物理體直接碰撞了會互相彈開,物理體有自己的質量密度體積等等。是不是很神奇!而且這些物理的計算完全有游戲引擎做好了,你只要會用就行了!
我們這個游戲其實用不到多復雜的物理模擬,僅僅是場景里會有重力,小鳥會受到重力影響自由落體,然后小鳥會跟水管和地面產生碰撞,整個場景有個邊界,小鳥不能一直往上飛出屏幕。
配置場景的物理體
找到didMove()方法,在設置場景背景色代碼后面加上下面內容
// Set Scene physics
self.physicsBody = SKPhysicsBody(edgeLoopFrom: self.frame) ?//給場景添加一個物理體,這個物理體就是一條沿著場景四周的邊,限制了游戲范圍,其他物理體就不會跑出這個場景
self.physicsWorld.contactDelegate = self //物理世界的碰撞檢測代理為場景自己,這樣如果這個物理世界里面有兩個可以碰撞接觸的物理體碰到一起了就會通知他的代理
加完這兩句之后你會發現第二句代碼報錯了!那是因為你讓GameScene成為了物理場景的碰撞檢測代理,但是你并沒有遵守這個代理的協議,所以趕快讓GameScene這個類遵守下面這個協議吧
SKPhysicsContactDelegate
現在就不會報錯了,你可以看到GameScene因為是繼承自SKScene,SKScene是自帶了個物理世界的,有興趣你現在也可以試試打印一下當前物理世界的重力看看 -> print(self.physicsWorld.gravity),結果是不是(x:0,y:-9.8),表示重力是沿著屏幕向下的方向,重力大小是9.8,是不是跟高中物理學的是一樣的呢!
最后先做一個準備工作,在GameScene類的外面加上下面內容
let birdCategory: UInt32 = 0x1 << 0
let pipeCategory: UInt32 = 0x1 << 1
let floorCategory: UInt32 = 0x1 << 2
設置三個常量來表示小鳥、水管和地面物理體,稍后我們后面會用到
配置地面物理體
找到didMove()方法,在添加地面的帶面后面加上下面內容
//配置地面1的物理體
floor1.physicsBody = SKPhysicsBody(edgeLoopFrom: CGRect(x: 0, y: 0, width: floor1.size.width, height: floor1.size.height))
floor1.physicsBody?.categoryBitMask = floorCategory
//配置地面2的物理體
floor2.physicsBody = SKPhysicsBody(edgeLoopFrom: CGRect(x: 0, y: 0, width: floor2.size.width, height: floor2.size.height))
floor2.physicsBody?.categoryBitMask = floorCategory
這里要說明的是物理體的categoryBitMask,這個用來表示當前物理體是哪一個物理體,我們用我們剛剛準備好的floorCategory來表示他,等會碰撞檢測的時候需要通過這個來判斷。
配置小鳥物理體
找到didMove()方法,在添加小鳥的代碼后面,shuffle()方法前面加入下面代碼
bird.physicsBody = SKPhysicsBody(texture: bird.texture!, size: bird.size)
bird.physicsBody?.allowsRotation = false ?//禁止旋轉
bird.physicsBody?.categoryBitMask = birdCategory //設置小鳥物理體標示
bird.physicsBody?.contactTestBitMask = floorCategory | pipeCategory ?//設置可以小鳥碰撞檢測的物理體
上面我們就設置好了小鳥的物理體了,contactTestBitMask是來設置可以與小鳥碰撞檢測的物理體,我們設置了地面和水管,所以通常物理體的categoryBitMask用二進制移位方式來表示,這樣在設置contactTestBitMask的時候就可以直接多個移位的標識做按位取或的運算即可
配置水管物理體
找到addPipes(topSize: CGSize, bottomSize: CGSize)方法,在addChild(topPipe),addChild(bottomPipe)代碼之前加入下面的代碼內容
//配置上水管物理體
topPipe.physicsBody = SKPhysicsBody(texture: topTexture, size: topSize)
topPipe.physicsBody?.isDynamic = false
topPipe.physicsBody?.categoryBitMask = pipeCategory
//配置下水管物理體
bottomPipe.physicsBody = SKPhysicsBody(texture: bottomTexture, size: bottomSize)
bottomPipe.physicsBody?.isDynamic = false
bottomPipe.physicsBody?.categoryBitMask = pipeCategory
選在我們來運行一下游戲吧,你可以看到游戲一開始在初始化狀態小鳥就受到重力的影響而掉到地面上了,這不是我們想要的,我們希望是玩家點擊了屏幕游戲開始了小鳥才會下落
那么請在shuffle()方法里,設置小鳥的position的代碼后面加上下面這句
bird.physicsBody?.isDynamic = false
然后再在startGame()方法里,開始創建水管代碼之前加上下面這句
bird.physicsBody?.isDynamic = true
isDynamic的作用是設置這個物理體當前是否會受到物理環境的影響,默認是true,我們在游戲初始化的時候設置小鳥不受物理環境影響,但是在游戲開始的時候才會受到物理環境的影響
現在再運行游戲就可以看到初始化的時候小鳥停在屏幕中間,點擊了屏幕游戲開始了,小鳥才會掉下來
給小鳥一個速度
現在這游戲簡直就是沒法玩,小鳥一下就掉到地上,怎么點屏幕他都不會網上飛
現在找到touchesBegan()方法,看到這個寫好的switch語句里,.running情況只有一句print("給小鳥一個向上的力"),打印一句話可不會讓小鳥往上飛,現在請將這句print替換為下面這句代碼
bird.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 20))
這個句代碼可以給小鳥的物理體施加一個向上的沖量,讓小鳥獲得一定的向上速度,但是由于小鳥還受重力影響,所以你得經常點擊屏幕才能保持小鳥不掉下去。
Impluse是什么?Impulse在物理上就是沖量的意思,沖量=質量 * (結束速度 - 初始速度),即I = m * (v2 - v1),如果物體的質量為1,那么沖量i = v2 - v1。當一個質量為1的物理體applyImpulse(CGVector(dx: 0, dy: 20))的意思就是讓他在y的方向上疊加20m/s的速度。當然如果物理體質量m不為1,那疊加的速度就不是剛好等于沖量的字面量了,而是要除以m了。如一個質量為2的物理體同樣applyImpulse(CGVector(dx: 0, dy: 20)),結果就是它在y的方向上疊加了10m/s的一個速度
檢測碰撞
現在我們的游戲已經基本能玩了,但是小鳥碰到水管或者掉到地面上小鳥沒有死掉,游戲還在繼續,現在我們就來完善這個問題
記得我們將當前的GameScene設置為了物理世界的碰撞檢測的代理么?接下來我們只要實現檢測到碰撞產生的代理方法即可
在GameScene里添加下面這個方法代碼,didBegin()會在當前物理世界有兩個物理體碰撞接觸了則回調用,這兩個碰撞了的物理體的信息都在contact這個參數里面,分別是bodyA和bodyB
func didBegin(_ contact: SKPhysicsContact) {
? ? ? ? //先檢查游戲狀態是否在運行中,如果不在運行中則不做操作,直接return
? ? ? ? if gameStatus != .running { return }
? ? ? //為了方便我們判斷碰撞的bodyA和bodyB的categoryBitMask哪個小,小的則將它保存到新建的變量bodyA里的,大的則保存到新建變量bodyB里
? ? ? ? var bodyA : SKPhysicsBody
? ? ? ? var bodyB : SKPhysicsBody
? ? ? ? if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
? ? ? ? ? ? bodyA = contact.bodyA
? ? ? ? ? ? bodyB = contact.bodyB
? ? ? ?}else {
? ? ? ? ? ? bodyA = contact.bodyB
? ? ? ? ? ? bodyB = contact.bodyA
? ? ? ?}
? ? ? ?接下來判斷bodyA是否為小鳥,bodyB是否為水管或者地面,如果是則游戲結束,直接調用gameOver()方法
? ? ? ?if (bodyA.categoryBitMask == birdCategory && bodyB.categoryBitMask == pipeCategory) ||
? ? ? ? ? ?(bodyA.categoryBitMask == birdCategory && bodyB.categoryBitMask == floorCategory) {
? ? ? ? ? ? ? ?gameOver()
? ? ? ?}
}
現在我們運行游戲就可以正常玩耍了,小鳥碰到地面或者水管游戲就會結束,小鳥就會落地,水管會停住,如果再點擊一次屏幕就會回到初始狀態,小鳥回到中間,殘留的水管都消失了
但是這個游戲結束有點突兀,最好能給個提示告訴玩家游戲結束了。
我們先給GameScene這個類添加一個變量,來表示游戲結束提示的label
lazy var gameOverLabel: SKLabelNode = {
? ? ? ? ?let label = SKLabelNode(fontNamed: "Chalkduster")
? ? ? ? ?label.text = "Game Over"
? ? ? ? ?return label
}()
注意這個變量我們用了一個lazy來標示,標示這個label是懶加載的,也就是只有在gameOverLabel第一次被調用的時候才會創建,它的創建代碼用一個大括號包住,結尾要帶一對()表示馬上執行意思。這樣我們就通過懶加載創建一個gameOverLabel,他的text內容Game Over提示語。
接下來找到gameOver()方法,在此方法的最后加上下面的代碼,這樣在gameOver的時候就會有一個提示語從天而降了
//禁止用戶點擊屏幕
isUserInteractionEnabled = false
//添加gameOverLabel到場景里
addChild(gameOverLabel)
//設置gameOverLabel其實位置在屏幕頂部
gameOverLabel.position = CGPoint(x: self.size.width * 0.5, y: self.size.height)
//讓gameOverLabel通過一個動畫action移動到屏幕中間
gameOverLabel.run(SKAction.move(by: CGVector(dx:0, dy:-self.size.height * 0.5), duration: 0.5), completion: {
? ? ? ? //動畫結束才重新允許用戶點擊屏幕
? ? ? ? self.isUserInteractionEnabled = true
})
不過要記住在游戲回到初始化狀態下的時候,要把gameOverLabel從場景里移除掉,所以找到shuffle()方法,然后在removeAllPipesNode()方法后面加上下面這一句
gameOverLabel.removeFromParent()
現在我們再來運行一下游戲,就能發現一切正常了,可以愉快的玩耍了!
6.補充提示
雖然游戲能玩了,但是你不覺得少點什么么?
沒錯游戲一般都有分數,表示玩家這句玩的成績怎么樣,所以這個游戲里我們可以添加一個表示玩家小鳥飛了多遠距離的提示。
我們先給GameScene添加一個metersLabel,用它來展示用戶走了多遠的距離,添加下面代碼到你的GameScene
lazy var metersLabel: SKLabelNode = {
? ? ? ? let label = SKLabelNode(text: "meters:0")
? ? ? ? label.verticalAlignmentMode = .top
? ? ? ? label.horizontalAlignmentMode = .center
? ? ? ? return label
}()
可以看到我們同樣使用了懶加載的方式來創建這個metersLabel變量
PS:這里稍微多介紹一點SKLabelNode這個類,如果做過iOS應用開發的朋友應該都知道UILabel這個控件,跟UILabel類似SKLabelNode就是SpriteKit中顯示一段文字的空間,首先他是繼承自SKNode,所以它可以被添加到場景里面,它也可以執行各種Action動作。
另外可能還有一個你不適應的地方就是他的位置布局問題,在做iOS應用時候UILabel有大小,UILabel的原點在它自己左上角,你自然知道怎么放置它了。但是SKLabelNode是沒有size這個屬性的,他的frame屬性也只是readonly的,這怎么辦?
SKLabelNode有兩個新的屬性叫做verticalAlignmentMode和horizontalAlignmentMode,表示這個label在水平和垂直方向上如何布局,他們是枚舉類型。比如你把的SKLabelNode的postion位置設置在(50,100)這個點,然后把他的verticalAlignmentMode 設置為.top,則表示這段文字的頂部是position所在位置的y的水平高度上,如果設置為.bottom,則這段文字的底部水平線高度就是position的y的水平高度。所以horizontalAlignmentMode屬性也是同理,只是它是設置水平方向上的布局。可能等我遲點補充一個圖表示會比較清晰,容易理解
現在我們有了這個label的變量,要將他加到場景上
找到didMove()方法,然后在設置場景的物理體的代碼后面加上下面的代碼內容
// Set Meter Label
metersLabel.position = CGPoint(x: self.size.width * 0.5, y: self.size.height)
metersLabel.zPosition = 100
addChild(metersLabel)
我們把metersLabel放在了屏幕的頂部中間,然后注意第二句metersLabel.zPosition = 100,我們把label在z軸上的位置設置在了100,你可能會問這不是2D游戲么怎么會有z軸,這里的z軸你也可以理解為圖層的層次順序軸,zPosition越大就越靠近玩家,就是說如果兩個場景里的node某一部分重疊了,那么就是zPosition大的那個node會覆蓋住小的那個node,zPosition默認值是0,如果兩個都是0的node重疊了那就要看誰是先被添加進場景的,先被添加進的會被后添加進的覆蓋住。
那么為什么metersLabel要設置一個大一些的zPosition?因為metersLabel是在didMove方法里就添加到場景了,我們又希望它始終不被遮住,但是那些出現的水管是后添加進場景的node,他們移動到metersLabel上面的時候就會覆蓋住它,所以我們才要做這樣的一個操作。
現在我們有一個用來顯示小鳥飛了多遠的label了,該要讓它顯示變化的值了
我們給GameScene添加多一個記錄飛行米數的變量,添加下面代碼到GameScene
var meters = 0 {
? ? didSet ?{
? ? ? ? ?metersLabel.text = "meters:\(meters)"
? ? }
}
meters是一個Int值就可以了,初始設置為0,可以看到我們寫了個didSet{...},表示這個變量每次當被設置了一個新的值就會執行一次didSet里面的代碼,我們在這里重新設置了一個metersLabel現實的內容。
接下來我們要在游戲運行時候不斷增加meters的值,簡單點的方法就是在每一幀刷新的update()方法里去改變
我找到update()方法,然后添加下面的內容到方法里
if gameStatus == .running {
? ? ? meters += 1
}
現在你運行游戲就會看到一旦點擊一下屏幕游戲開始了,飛行的米數就會不斷的刷刷刷的飛漲。
但是還有一件事情別忘了,就是找到shuffle()方法,在里面添加一句下面的代碼,每次回到游戲初始化狀態下時,要把上一局的飛行米數重新清零
meters = 0
現在你再運行游戲就會得到跟文章開篇時候的動圖一樣的效果了!
7.還有什么可以完善的?
至此對于此游戲的基本實現算是寫完了,不過你也可以繼續完善這個游戲,或者用不同的方法來實現試試
1.比如說這里我們的場景內容移動(地面和水管)是直接在update()方法里改變position來實現,那么能不能換成用SKAction的方法來做到呢?
2.雖然游戲能玩了,但是那些會影響到游戲的一些關鍵參數是否已經是最優的選擇了?如果你覺得小鳥的自由落體下墜的太快或者每次小鳥上升的速度太小等等,這些可能都要開發者自己去玩玩嘗試找到最優的參數配置
3.是否可以增加漸進的難度?比如說隨機的產生水管的間隔時間能不能隨游戲進行時間越來越短?等等
4.是否小鳥可以能吃到一些道具讓它在一定時間內不懼怕水管?
5.是否可以添加玩家成績的記錄?
等等等等。。。。。這些就看你想不想去完善了試一試,這里就不做一一實現了,謝謝