用Swift和SpriteKit開發iOS遊戲
之前用SpriteKit做過一個叫做ColorAtom的小遊戲,用了訪問者模式處理碰撞檢測,還用了SpriteKit中的粒子系統、連線體、力場和動畫等,可以說是一個學習SpriteKit比較不錯的Demo,隨著Swift的火熱,我也用Swift和SpriteKit寫了一個更為簡單的小遊戲Spiral
附上Spiral的動圖:
遊戲規則是:玩家是五角星小球,小球自動沿著陀螺線向外運動,當玩家點選螢幕時五角星小球會跳躍到內層螺旋,當五角星小球碰到紅色旋風或滾動到螺旋線終點時遊戲結束。玩家吃掉綠色旋風來得2分,吃到紫色三角得一分並獲得保護罩,保護罩用來抵擋一次紅色旋風。隨著分數的增加遊戲會升級,速度加快。遊戲結束後可以截圖分享到社交網路,也可以選擇重玩。
以下是本文內容:
- 準備工作
- 繪製基本介面
- Swift中用訪問者模式處理碰撞
- 介面資料顯示
- 按鈕的繪製和截圖分享
準備工作
SpriteKit是蘋果iOS7新推出的2D遊戲引擎,這裡不再過多介紹。我們新建工程的時候選取iOS中的Game,然後選擇SpriteKit作為遊戲引擎,語言選擇Swift,Xcode6會為我們自動建立一個遊戲場景GameScene
,它包含GameScene.swift
和GameScene.sks
兩個檔案,sks
檔案可以讓我們視覺化拖拽遊戲控制元件到場景上,然後再程式碼中載入sks
檔案來完成場景的初始化:
extension SKNode { class func unarchiveFromFile(file : NSString) -> SKNode? { let path = NSBundle.mainBundle().pathForResource(file, ofType: "sks") var sceneData = NSData.dataWithContentsOfFile(path, options: .DataReadingMappedIfSafe, error: nil) var archiver = NSKeyedUnarchiver(forReadingWithData: sceneData) archiver.setClass(self.classForKeyedUnarchiver(), forClassName: "SKScene") let scene = archiver.decodeObjectForKey(NSKeyedArchiveRootObjectKey) as GameScene archiver.finishDecoding() return scene } }
但我比較喜歡純寫程式碼的方式來搭接面,因為sks
檔案作為遊戲場景佈局還不成熟,它是iOS8新加入的功能,以前在iOS7的時候sks
檔案只是作為粒子系統的視覺化編輯檔案。
所以我們修改GameViewController.swift
檔案的viewDidLoad()
函式,像以前那樣直接用程式碼載入遊戲場景:
override func viewDidLoad() { super.viewDidLoad() // Configure the view. let skView = self.view as SKView /* Sprite Kit applies additional optimizations to improve rendering performance */ skView.ignoresSiblingOrder = true let scene = GameScene(size: skView.bounds.size) /* Set the scale mode to scale to fit the window */ scene.scaleMode = .AspectFill skView.presentScene(scene) }
GameScene
雖然是Xcode自動生成的,但是隻是個空架子,我們需要把它生成的沒用的程式碼刪掉,比如初始化函式裡內容為“HelloWorld”的SKLabelNode
,還有touchesBegan(touches: NSSet, withEvent event: UIEvent)
方法中繪製飛船的程式碼。把這些刪光後,我們還需要有圖片素材來繪製這四類精靈節點:Player
(五角星),Killer
(紅色旋風),Score
(綠色旋風)和Shield
(紫色三角)。我是用Sketch來繪製這些向量圖形的,檔名為spiral.sketch
,隨同工程檔案一同放到GitHub上了。當然你不需要手動匯出圖片到工程,直接下載工程檔案就好了:
https://github.com/yulingtianxia/Spiral
繪製基本介面
這部分的工作主要是繪製出螺旋線作為地圖,並讓四種精靈節點動起來。
螺旋線的繪製
SKNode
有一個子類SKShapeNode
,專門用於繪製線條的,我們新建一個Map
類,繼承SKShapeNode
。下面我們需要生成一個CGPath
來賦值給Map
的path
屬性:
import UIKit import SpriteKit class Map: SKShapeNode { let spacing:CGFloat = 35 var points:[CGPoint] = [] convenience init(origin:CGPoint,layer:CGFloat){ var x:CGFloat = origin.x var y:CGFloat = origin.y var path = CGPathCreateMutable() self.init() CGPathMoveToPoint(path, nil, x, y) points.append(CGPointMake(x, y)) for index in 1..<layer{ y-=spacing*(2*index-1) CGPathAddLineToPoint(path, nil , x, y) points.append(CGPointMake(x, y)) x-=spacing*(2*index-1) CGPathAddLineToPoint(path, nil , x, y) points.append(CGPointMake(x, y)) y+=spacing*2*index CGPathAddLineToPoint(path, nil , x, y) points.append(CGPointMake(x, y)) x+=spacing*2*index CGPathAddLineToPoint(path, nil , x, y) points.append(CGPointMake(x, y)) } self.path = path self.glowWidth = 1 self.antialiased = true CGPathGetCurrentPoint(path) } }
演算法很簡單,就是順時針計算點座標然後畫線,這裡把每一步的座標都存入了points
陣列裡,是為了以後計算其他資料時方便。因為這部分演算法不難而且不是我們的重點,這裡不過多介紹了。
四種精靈的繪製
因為四種精靈都是沿著Map
類的路徑來順時針運動,它們的動畫繪製是相似的,所以我建立了一個Shape
類作為基類來繪製動畫,它繼承於SKSpriteKit
類,並擁有半徑(radius
)、移動速度(moveSpeed
)和線段計數(lineNum
)這三個屬性。其中lineNum
是用於標記精靈在螺旋線第幾條線段上的,這樣比較方便計算動畫的引數。
class Shape: SKSpriteNode { let radius:CGFloat = 10 var moveSpeed:CGFloat = 50 var lineNum = 0 init(name:String,imageName:String){ super.init(texture: SKTexture(imageNamed: imageName),color:SKColor.clearColor(), size: CGSizeMake(radius*2, radius*2)) self.physicsBody = SKPhysicsBody(circleOfRadius: radius) self.physicsBody.usesPreciseCollisionDetection = true self.physicsBody.collisionBitMask = 0 self.physicsBody.contactTestBitMask = playerCategory|killerCategory|scoreCategory moveSpeed += CGFloat(Data.speedScale) * self.moveSpeed self.name = name self.physicsBody.angularDamping = 0 } }
建構函式中設定了Shape
類的一些物理引數,比如物理體的形狀大小,碰撞檢測掩碼等。這裡設定usesPreciseCollisionDetection
為true
是為了增加碰撞檢測的精度,常用於體積小速度快的物體。collisionBitMask
屬性標記了需要模擬物理碰撞的類別,contactTestBitMask
屬性標記了需要檢測到碰撞的類別。這裡說的“類別”指的是物體的類別:
let playerCategory:UInt32 = 0x1 << 0; let killerCategory:UInt32 = 0x1 << 1; let scoreCategory:UInt32 = 0x1 << 2; let shieldCategory:UInt32 = 0x1 << 3;
這種用位運算來判斷和儲存物體類別的方式很常用,上面這段程式碼寫在了NodeCategories.swift
檔案中。
為了描述Shape
的速度隨著遊戲等級上升而增加,這裡速度的計算公式含有Data.speedScale
作為引數,關於Data
“類”在後面會講到。
為了讓精靈動起來,需要知道動畫的移動目的地是什麼。雖然SKAction
有followPath(path: CGPath?, speed: CGFloat)
方法,但是在這裡並不實用,因為Player
會經常改變路線,所以我寫了一個runInMap(map:Map)
方法讓精靈每次只移動到路徑上的下一個節點(之前Map
類儲存的points
屬性用到了吧!嘿嘿)
func runInMap(map:Map){ let distance = calDistanceInMap(map) let duration = distance/moveSpeed let rotate = SKAction.rotateByAngle(distance/10, duration: duration) let move = SKAction.moveTo(map.points[lineNum+1], duration: duration) let group = SKAction.group([rotate,move]) self.runAction(group, completion: { self.lineNum++ if self.lineNum==map.points.count-1 { if self is Player{ Data.gameOver = true } if self is Killer{ self.removeFromParent() } if self is Score{ self.removeFromParent() } if self is Shield{ self.removeFromParent() } } else { self.runInMap(map) } }) }
上面的程式碼先是呼叫calDistanceInMap(map:Map)->CGFloat
方法計算精靈距離下一個節點的距離(也就是需要移動的距離),然後計算精靈需要旋轉動畫時間和移動動畫時間,最後將兩個動畫作為一個group
來執行,在動畫執行結束後判斷精靈是否執行到了最後一個節點,也就是螺旋線的終點:如果到終點了則移除精靈,否則開始遞迴呼叫方法,來開始下一段動畫(奔向下一個節點)。
計算距離的calDistanceInMap(map:Map)->CGFloat
方法程式碼如下:
func calDistanceInMap(map:Map)->CGFloat{ if self.lineNum==map.points.count { return 0 } switch lineNum%4{ case 0: return position.y-map.points[lineNum+1].y case 1: return position.x-map.points[lineNum+1].x case 2: return map.points[lineNum+1].y-position.y case 3: return map.points[lineNum+1].x-position.x default: return 0 } }
到此為止Shape
類完成了,Killer
、Score
和Shield
類比較簡單,繼承Shape
類並設定自身紋理和類別即可:
class Killer: Shape { convenience init() { self.init(name:"Killer",imageName:"killer") self.physicsBody.categoryBitMask = killerCategory } } class Score: Shape { convenience init() { self.init(name:"Score",imageName:"score") self.physicsBody.categoryBitMask = scoreCategory } } class Shield: Shape { convenience init() { self.init(name:"Shield",imageName:"shield") self.physicsBody.categoryBitMask = shieldCategory } }
而Player
因為有護盾狀態並可以在螺旋線上跳躍到內層,所以稍微複雜些:
class Player: Shape { var jump = false var shield:Bool = false { willSet{ if newValue{ self.texture = SKTexture(imageNamed: "player0") } else{ self.texture = SKTexture(imageNamed: "player") } } } convenience init() { self.init(name:"Player",imageName:"player") self.physicsBody.categoryBitMask = playerCategory self.moveSpeed = 70 self.lineNum = 3 } func restart(map:Map) { self.alpha = 1 self.removeAllActions() self.lineNum = 3 self.moveSpeed = 70 self.jump = false self.shield = false self.position = map.points[self.lineNum] self.runInMap(map) } }
Player
類的初始位置是螺旋線第四個節點,而且移動速度要略快於其他三種精靈,所以在這裡設定為70(Shape
預設速度50)。jump
和shield
是用來標記Player
當前狀態的屬性,其中shield
屬性還定義了屬性監察器,這是Swift中儲存屬性具有的響應機制,類似於KVO
。在shield
狀態改變時也同時改變Player
的紋理。需要注意的是構造器中對屬性的改變並不會呼叫屬性檢查器,在willSet
和didSet
中改變自身屬性也不會呼叫屬性檢查器,因為那樣會造成死迴圈。
restart(map:Map)
方法用於在遊戲重新開始時重置Player
的相關資料。
Swift中用訪問者模式處理碰撞
訪問者模式是雙分派(Double Dispatch)模式的一種實現,關於雙分派模式的詳細解釋,參考我的另一篇文章:Double Dispatch模式及其在iOS開發中實踐,裡面包含了C++,Java和Obje-C的實現,這次我們用Swift實現訪問者模式。
因為SpriteKit中物理碰撞檢測到的都是SKPhysicsBody
,所以我們的被訪問者需要包含一個SKPhysicsBody
物件:
class VisitablePhysicsBody{ let body:SKPhysicsBody init(body:SKPhysicsBody){ self.body = body } func acceptVisitor(visitor:ContactVisitor){ visitor.visitBody(body) } }
acceptVisitor
方法傳入的是一個ContactVisitor
類,它是訪問者的基類(也相當於介面),訪問者的visitBody(body:SKPhysicsBody)
方法會根據傳入的body
例項來推斷出被訪問者的真實類別,然後呼叫對應的方法來處理碰撞:
func visitBody(body:SKPhysicsBody){ //第二次dispatch,通過構造方法名來執行對應方法 // 生成方法名,比如"visitPlayer" var contactSelectorString = "visit" + body.node.name + ":" let selector = NSSelectorFromString(contactSelectorString) if self.respondsToSelector(selector){ dispatch_after(0, dispatch_get_main_queue(), { NSThread.detachNewThreadSelector(selector, toTarget:self, withObject: body) }) } }
Swift廢棄了performSelector
方法,所以這裡耍了個小聰明來將訊息傳給具體的訪問者。有關Swift中替代performSelector
的方案,參見這裡
下面讓GameScene
實現SKPhysicsContactDelegate
協議:
func didBeginContact(contact:SKPhysicsContact){ //A->B let visitorA = ContactVisitor.contactVisitorWithBody(contact.bodyA, forContact: contact) let visitableBodyB = VisitablePhysicsBody(body: contact.bodyB) visitableBodyB.acceptVisitor(visitorA) //B->A let visitorB = ContactVisitor.contactVisitorWithBody(contact.bodyB, forContact: contact) let visitableBodyA = VisitablePhysicsBody(body: contact.bodyA) visitableBodyA.acceptVisitor(visitorB) }
跟Objective-C中實現訪問者模式類似,也是通過ContactVisitor
類的工廠方法返回一個對應的子類例項來作為訪問者,然後例項化一個被訪問者,被訪問者接受訪問者的訪問。A訪問B和B訪問A在大多數場合是相同的,但是你不知道誰是A誰是B,所以需要兩種情況都呼叫。下面是ContactVisitor
類的工廠方法和構造器:
class ContactVisitor:NSObject{ let body:SKPhysicsBody! let contact:SKPhysicsContact! class func contactVisitorWithBody(body:SKPhysicsBody,forContact contact:SKPhysicsContact)->ContactVisitor!{ //第一次dispatch,通過node類別返回對應的例項 if 0 != body.categoryBitMask&playerCategory { return PlayerContactVisitor(body: body, forContact: contact) } if 0 != body.categoryBitMask&killerCategory { return KillerContactVisitor(body: body, forContact: contact) } if 0 != body.categoryBitMask&scoreCategory { return ScoreContactVisitor(body: body, forContact: contact) } if 0 != body.categoryBitMask&shieldCategory { return ShieldContactVisitor(body: body, forContact: contact) } return nil } init(body:SKPhysicsBody, forContact contact:SKPhysicsContact){ self.body = body self.contact = contact super.init() } }
PS:上面的程式碼省略了已經提到過的visitBody(body:SKPhysicsBody)
方法
因為這個遊戲邏輯比較簡單,所有碰撞後的邏輯都寫到了PlayerContactVisitor
類裡:
func visitKiller(body:SKPhysicsBody){ let thisNode = self.body.node as Player let otherNode = body.node // println(thisNode.name+"->"+otherNode.name) if thisNode.shield { otherNode.removeFromParent() thisNode.shield = false } else { Data.gameOver = true } } func visitScore(body:SKPhysicsBody){ let thisNode = self.body.node let otherNode = body.node // println(thisNode.name+"->"+otherNode.name) otherNode.removeFromParent() Data.score += 2 } func visitShield(body:SKPhysicsBody){ let thisNode = self.body.node as Player let otherNode = body.node otherNode.removeFromParent() thisNode.shield = true Data.score++ // println(thisNode.name+"->"+otherNode.name) }
上面的方法都是“visit+類名”格式的,處理的是Player
碰撞到其他三種精靈的邏輯。而其他三種精靈之間的碰撞不需要處理,所以KillerContactVisitor
、ScoreContactVisitor
和ShieldContactVisitor
這三個ContactVisitor
的子類很空曠,這裡不再贅述。
我們設定Player
碰撞到Killer
遊戲結束,碰撞到Score
加兩分,碰撞到Shield
加一分並獲得護甲(shield屬性設為true)。可以看到這裡大量用到了Data
“類“”,它其實是一個儲存並管理全域性資料的結構體,它裡面儲存了一些靜態的成員屬性,也可看做非執行緒安全的單例。
介面資料顯示
這部分很簡單,主要是將Data
結構體中儲存的分數和等級等資料通過SKLabelNode
顯示在介面上,只不過我封裝了一個Display
類來將所有的SKLabelNode
統一管理,並讓其實現我定義的DisplayData
協議來讓Data
中的資料變化驅動介面更新:
protocol DisplayData{ func updateData() func levelUp() func gameOver() func restart() }
下面是Data結構體程式碼,大量使用了儲存屬性的監察器來響應資料變化:
struct Data{ static var display:DisplayData? static var updateScore:Int = 5 static var score:Int = 0{ willSet{ if newValue>=updateScore{ updateScore+=5 * ++level } } didSet{ display?.updateData() } } static var highScore:Int = 0 static var gameOver:Bool = false { willSet{ if newValue { let standardDefaults = NSUserDefaults.standardUserDefaults() Data.highScore = standardDefaults.integerForKey("highscore") if Data.highScore < Data.score { Data.highScore = Data.score standardDefaults.setInteger(Data.score, forKey: "highscore") standardDefaults.synchronize() } display?.gameOver() } else { display?.restart() } } didSet{ } } static var level:Int = 1{ willSet{ speedScale = Float(newValue)*0.1 if newValue != 1{ display?.levelUp() } } didSet{ display?.updateData() } } static var speedScale:Float = 0{ willSet{ } didSet{ } } static func restart(){ Data.updateScore = 5 Data.score = 0 Data.level = 1 Data.speedScale = 0 } }
這裡不得不提到一個更新介面時遇到的一個坑,當我想通過名字遍歷GameScene
子節點的時候,一般會用到enumerateChildNodesWithName(name: String?, usingBlock: ((SKNode!, UnsafePointer<ObjCBool>) -> Void)?)
方法,但是這個方法在Xcode6Beta3更新後經常會拋異常強退,這讓我很費解,恰巧遇到此問題的不只是我一個人,所以還是老老實實的自己寫迴圈遍歷加判斷吧。
按鈕的繪製和截圖分享
參考我的另外兩篇文章:在遊戲的SKScene中新增Button和SpriteKit截圖並分享至社交網路
在本工程中只有ShareButton
和ReplayButton
兩個按鈕,Swift版本的程式碼很簡潔,而我通過Social.Framework
中的UIActivityViewController
來分享得分,這部分程式碼寫在了ShareButton.swift
中:
let scene = self.scene as GameScene let image = scene.imageFromNode(scene) let text = "我在Spiral遊戲中得了\(Data.score)分,快來追逐我的步伐吧!" let activityItems = [image,text] let activityController = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) (scene.view.nextResponder() as UIViewController).presentViewController(activityController, animated: true, completion: nil)
相關文章
- [Swift]SpriteKit實現類似畫素鳥的小遊戲 - Crashy PlaneSwift遊戲
- ios應用開發+swift語言入門iOSSwift
- 我的小專欄《 Swift 遊戲開發》開始創作啦~一起來用 Swift 寫遊戲吧!Swift遊戲開發
- iOS開發中使用OC和swift的對比iOSSwift
- Swift 遊戲開發之「能否關個燈」(〇)Swift遊戲開發
- iOS 藍芽開發 - swift版iOS藍芽Swift
- 用 Swift 模仿 Vue + Vuex 進行 iOS 開發(二):CoordinatorSwiftVueiOS
- Swift 遊戲開發之「能否關個燈」(一)Swift遊戲開發
- iOS 開發選擇OC還是Swift?iOSSwift
- 史丹佛iOS Swift開發公開課總結(一)iOSSwift
- 用於遊戲開發的圖形和音樂工具遊戲開發
- iOS UMeng OC和Swift混編iOSSwift
- Unity遊戲示例來了,用Unity開源遊戲資源做遊戲,遊戲開發不再難!Unity遊戲開發
- 鏈遊開發:遊戲和NFT的結合遊戲
- 遊戲開發入門(一)遊戲開發概述遊戲開發
- (iOS)SpriteKit 製作簡易手遊虛擬搖桿(UIKit通用) Double零元件系列iOSUI元件
- Swift iOS:KVOSwiftiOS
- Swift iOS : RichTextSwiftiOS
- Swift iOS : ArchiveSwiftiOSHive
- Facebook測試、釋出和分享小遊戲(開發小遊戲)遊戲
- Flutter與Native混合開發-FlutterBoost整合應用和開發實踐(iOS)FlutteriOS
- NFT遊戲系統開發/遊戲開發技術遊戲開發
- [譯] 安卓應用和遊戲的無障礙開發介紹安卓遊戲
- [譯]iOS開發者在Swift中應避免過度使用@objciOSSwiftOBJ
- 遊戲開發流程遊戲開發
- Swift iOS : 解析jsonSwiftiOSJSON
- Flutter 和iOS 混合開發(一)FlutteriOS
- iOS多包發行策略:適合輕遊戲與獨立遊戲iOS遊戲
- Swift 開發視訊 iOS 開發視訊教程完整版下載 (共四季)SwiftiOS
- 使用Xamarin開發移動應用示例——數獨遊戲(七)新增新遊戲遊戲
- 使用Xamarin開發移動應用示例——數獨遊戲(二)建立遊戲介面遊戲
- 何謂元宇宙?元宇宙遊戲應用開發的內因和推薦元宇宙遊戲
- Python遊戲開發工程師的起步,幾款遊戲開發案例Python遊戲開發工程師
- Appcode 2022 for mac(ios應用開發)APPMaciOS
- 遊戲開發者自己評選出的最佳遊戲,和TGA差了多少?遊戲開發
- 【翻譯】使用React、 Redux 和 SVG 開發遊戲(三)ReactReduxSVG開發遊戲
- 【翻譯】使用React、 Redux 和 SVG 開發遊戲(一)ReactReduxSVG開發遊戲
- [譯] 使用 React、Redux 和 SVG 開發遊戲 - Part 2ReactReduxSVG開發遊戲
- pygame開發小遊戲GAM遊戲