用Swift和SpriteKit開發iOS遊戲

楊蕭玉發表於2014-10-28

之前用SpriteKit做過一個叫做ColorAtom的小遊戲,用了訪問者模式處理碰撞檢測,還用了SpriteKit中的粒子系統、連線體、力場和動畫等,可以說是一個學習SpriteKit比較不錯的Demo,隨著Swift的火熱,我也用Swift和SpriteKit寫了一個更為簡單的小遊戲Spiral

附上Spiral的動圖:

遊戲規則是:玩家是五角星小球,小球自動沿著陀螺線向外運動,當玩家點選螢幕時五角星小球會跳躍到內層螺旋,當五角星小球碰到紅色旋風或滾動到螺旋線終點時遊戲結束。玩家吃掉綠色旋風來得2分,吃到紫色三角得一分並獲得保護罩,保護罩用來抵擋一次紅色旋風。隨著分數的增加遊戲會升級,速度加快。遊戲結束後可以截圖分享到社交網路,也可以選擇重玩。

以下是本文內容:

  1. 準備工作
  2. 繪製基本介面
  3. Swift中用訪問者模式處理碰撞
  4. 介面資料顯示
  5. 按鈕的繪製和截圖分享

準備工作

SpriteKit是蘋果iOS7新推出的2D遊戲引擎,這裡不再過多介紹。我們新建工程的時候選取iOS中的Game,然後選擇SpriteKit作為遊戲引擎,語言選擇Swift,Xcode6會為我們自動建立一個遊戲場景GameScene,它包含GameScene.swiftGameScene.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來賦值給Mappath屬性:

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類的一些物理引數,比如物理體的形狀大小,碰撞檢測掩碼等。這裡設定usesPreciseCollisionDetectiontrue是為了增加碰撞檢測的精度,常用於體積小速度快的物體。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“類”在後面會講到。

為了讓精靈動起來,需要知道動畫的移動目的地是什麼。雖然SKActionfollowPath(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類完成了,KillerScoreShield類比較簡單,繼承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)。jumpshield是用來標記Player當前狀態的屬性,其中shield屬性還定義了屬性監察器,這是Swift中儲存屬性具有的響應機制,類似於KVO。在shield狀態改變時也同時改變Player的紋理。需要注意的是構造器中對屬性的改變並不會呼叫屬性檢查器,在willSetdidSet中改變自身屬性也不會呼叫屬性檢查器,因為那樣會造成死迴圈。

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碰撞到其他三種精靈的邏輯。而其他三種精靈之間的碰撞不需要處理,所以KillerContactVisitorScoreContactVisitorShieldContactVisitor這三個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中新增ButtonSpriteKit截圖並分享至社交網路

在本工程中只有ShareButtonReplayButton兩個按鈕,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)

相關文章