教你用 SpriteKit 做一個自己的”割繩子“游戲(Swift 3)

本文翻譯自 How To Make a Game Like Cut the Rope Using SpriteKit and Swift

2017年1月20日更新:由 Kevin Colligan 更新至 iOS 10,Xcode 8 和 Swift 3。 原文 作者是 Tammy Coron ,上一次更新是 Nick Lockwood 。

給鱷魚喂過菠蘿嗎?這篇教程會教你!
給鱷魚喂過菠蘿嗎?這篇教程會教你!

割繩子(Cut The Rope)是一款流行的物理驅(qū)動游戲,玩家通過剪斷掛著糖果的繩索來喂養(yǎng)一只名叫 Om Nom 的小怪獸。只要在正確的時間和位置切斷,Om Nom 就會得到一份美味佳肴。

雖然對 Om Nom 有著滿滿的敬意,可我還是要說,這個游戲真正的明星是它方針的物理學:繩索擺動、重力牽引、糖果按照真實世界中那樣落下。

我們可以用蘋果的 2D 游戲框架,SpriteKit,借助它的物理引擎創(chuàng)建相似的游戲體驗。在本教程中,我們會一起做一個名為 Snip The Vine 的游戲。

注意:本教程假設(shè)你對 SpriteKit 有一些經(jīng)驗。如果還不了解 SpriteKit,看看這篇 SpriteKit Swift Tutorial for Beginners 。

開始

Snip The Vine 中,玩家可以把 可愛的小動物 菠蘿喂給鱷魚。從 下載啟動項目 開始。在 Xcode 中打開項目,快速瀏覽一下結(jié)構(gòu)。

項目文件分散在多個文件夾中。本教程中,我們需要處理 Classes 文件夾,它包含了主要的代碼文件。再隨便看看其它文件夾,如下所示:

常量設(shè)置

常量可以讓代碼可讀性更強,避免重復(fù)硬編碼的字符串和”魔法數(shù)字(magic numbers)“。

打開 Constants.swift 然后添加如下代碼:

struct ImageName {
  static let Background = "Background"
  static let Ground = "Ground"
  static let Water = "Water"
  static let VineTexture = "VineTexture"
  static let VineHolder = "VineHolder"
  static let CrocMouthClosed = "CrocMouthClosed"
  static let CrocMouthOpen = "CrocMouthOpen"
  static let CrocMask = "CrocMask"
  static let Prize = "Pineapple"
  static let PrizeMask = "PineappleMask"
}
 
struct SoundFile {
  static let BackgroundMusic = "CheeZeeJungle.caf"
  static let Slice = "Slice.caf"
  static let Splash = "Splash.caf"
  static let NomNom = "NomNom.caf"
}

上面的代碼為 sprite 圖片名和聲音文件這些東西定義了常量。

緊接著上面,添加如下代碼:

struct Layer {
  static let Background: CGFloat = 0
  static let Crocodile: CGFloat = 1
  static let Vine: CGFloat = 1
  static let Prize: CGFloat = 2
  static let Foreground: CGFloat = 3
}
 
struct PhysicsCategory {
  static let Crocodile: UInt32 = 1
  static let VineHolder: UInt32 = 2
  static let Vine: UInt32 = 4
  static let Prize: UInt32 = 8
}

這段代碼又定義了兩個結(jié)構(gòu)體,LayerPhysicsCategory,每個都包含了很多 CGFloat 和 UInt32 屬性。在我們添加?xùn)|西到場景中時,會用它們來指定 sprite 的 zPostion 和物理類別。

最后,再添加一個結(jié)構(gòu)體

struct GameConfiguration {
  static let VineDataFile = "VineData.plist"
  static let CanCutMultipleVinesAtOnce = false
}

VineDataFile 定義了文件名,用于確定葡萄藤放置的位置。

CanCutMultipleVinesAtOnce 是一種簡單的修改游戲參數(shù)的方式。會讓游戲更有意思的決策并不是顯而易見的。像這樣的常量就提供了一種簡單的方式,讓我們在各種方法之間切換,以便我們在后面修改游戲。

現(xiàn)在可以開始為我們的場景添加節(jié)點了。

為場景添加背景子畫面(Sprites)

打開 GameScene.swift 然后將如下代碼添加到 setUpScenery():

let background = SKSpriteNode(imageNamed: ImageName.Background)
background.anchorPoint = CGPoint(x: 0, y: 0)
background.position = CGPoint(x: 0, y: 0)
background.zPosition = Layer.Background
background.size = CGSize(width: size.width, height: size.height)
addChild(background)
 
let water = SKSpriteNode(imageNamed: ImageName.Water)
water.anchorPoint = CGPoint(x: 0, y: 0)
water.position = CGPoint(x: 0, y: 0) 
water.zPosition = Layer.Foreground
water.size = CGSize(width: size.width, height: size.height * 0.2139)
addChild(water)

setUpScenery() 方法是從 didMove() 中調(diào)用的。在這個方法里,我們創(chuàng)建了一組 SKSpriteNode,并且使用 SKSpriteNode(imageNamed:) 將它們初始化了。要處理多種屏幕尺寸的話,要明確規(guī)定背景圖片的尺寸。

我們已經(jīng)將這兩個節(jié)點的 anchorPoint 從 (0.5, 0.5) 更改到 (0, 0)。這意味著節(jié)點的定位是?現(xiàn)對于左下角的,而不是中心,這樣就可以輕松地將背景和水置于場景中,并且讓它們底部對齊。

注意: anchorPoint 屬性使用了 unit 坐標系,(0,0) 表示子畫面圖片的左下角,(1,1) 表示右上角。因為量度總是為 0 到 1,所以這些坐標與圖像尺寸和縱橫比無關(guān)。

我們還設(shè)置了子畫面的 zPosition,控制了 SpriteKit 在屏幕上繪制節(jié)點的順序。

回想一下在 Constants.swift 中,我們指定了一些值,用于子畫面的 zPosition。這里(Layer)用到了其中兩個。BackgroundLayer.Foreground —— 確保背景將保持在其它子畫面的后面,前景則始終在最前面繪制。

構(gòu)建并運行項目。如果沒做錯的話,就可以看到下面的畫面:

把鱷魚加進場景

提前警告一下,這只鱷魚很喜歡咬人,注意手指要一直和它保持距離!:]

就像背景布景一樣,鱷魚使用 SKSpriteNode 來表示。但有幾個重要的區(qū)別:為了游戲邏輯,我們需要保留對鱷魚的引用;我們還需要為鱷魚子畫面設(shè)置物理身體,以檢測和處理與其他身體的接觸。

還是在 GameScene.swift 里,把如下屬性加到類的最上面:

private var crocodile: SKSpriteNode!
private var prize: SKSpriteNode!

這些屬性用于保存對鱷魚和獎勵(菠蘿)的引用。我們把它們定義為私有的,因為它們不會在 GameScene 之外被訪問。

這些屬性的類型已經(jīng)被定義為 SKSpriteNode!。! 表示它們是被隱式拆包的可選值,告訴 Swift 自己并不需要立刻被初始化。只有在你百分百確信訪問它們的時候,它們不會是 nil 的情況下才這么使用……否則 app 將會崩潰。

找到 GameScene.swift 里面的 setUpCrocodile() 方法,然后添加如下代碼:

crocodile = SKSpriteNode(imageNamed: ImageName.CrocMouthClosed)
crocodile.position = CGPoint(x: size.width * 0.75, y: size.height * 0.312)
crocodile.zPosition = Layer.Crocodile
crocodile.physicsBody = SKPhysicsBody(texture: SKTexture(imageNamed: ImageName.CrocMask), size: crocodile.size)
crocodile.physicsBody?.categoryBitMask = PhysicsCategory.Crocodile
crocodile.physicsBody?.collisionBitMask = 0
crocodile.physicsBody?.contactTestBitMask = PhysicsCategory.Prize
crocodile.physicsBody?.isDynamic = false
 
addChild(crocodile)
 
animateCrocodile()

這段代碼創(chuàng)建了鱷魚節(jié)點,并設(shè)置了它的 positionzPosition

與背景布景不同,鱷魚有 SKPhysicsBody,意味著它可以與世界上其他物體進行物理交互。在后面檢測菠蘿是否落到它嘴巴里的時候很有用處。我們不希望鱷魚被打翻、或是從屏幕底下掉出來,所以把 isDynamic 設(shè)置為 false,從而防止它受到物理受力的影響。

categoryBitMask 定義了身體所屬的物理類別 —— PhysicsCategory。在這里就是鱷魚。我們把 collisionBitMask 設(shè)置為 0 因為我們不希望鱷魚把其它身體彈飛。我們需要知道的就是何時”獎勵“身體會接觸到鱷魚,所以我們設(shè)置了響應(yīng)的 contactTestBitMask。

你可能注意到了,鱷魚的物理身體使用了 SKTexture 進行初始化。其實簡單點的話,我們可以直接在身體紋理上復(fù)用 CrocMouthOpen ,但那個圖片包括了鱷魚的整個身體,而 mask 紋理只包含鱷魚的頭和嘴。鱷魚可不能用尾巴吃菠蘿!

現(xiàn)在我們會為鱷魚添加一個”等待“動畫。找到 animateCrocodile() 方法,添加如下代碼:

let duration = 2.0 + drand48() * 2.0
let open = SKAction.setTexture(SKTexture(imageNamed: ImageName.CrocMouthOpen))
let wait = SKAction.wait(forDuration: duration)
let close = SKAction.setTexture(SKTexture(imageNamed: ImageName.CrocMouthClosed))
let sequence = SKAction.sequence([wait, open, wait, close])
 
crocodile.run(SKAction.repeatForever(sequence))

除了要讓小鱷魚顯得很焦慮外,這段代碼還創(chuàng)建了一些改變鱷魚節(jié)點的紋理的動作,使其在閉嘴和張嘴之間交替。

SKAction.sequence() 構(gòu)造函數(shù)從數(shù)組中創(chuàng)建了一個動作序列。在這種情況下,紋理動作按照序列進行組合,并且有2到4秒不定的隨機延遲時間。

序列動作被包裝在一個 repeatActionForever() 動作中,所以它在那段時間內(nèi)會一直重復(fù)。然后由鱷魚節(jié)點運行這個動作。

搞定!構(gòu)建并運行,看看這只可怕的爬行動作撕咬它的死亡之顎!

我們現(xiàn)在有了布景,我們也有了一只鱷魚——現(xiàn)在需要 可愛的小動物一個菠蘿。

增加獎勵

打開 GameScene.swift 然后找到 setUpPrize() 方法。添加如下代碼:

prize = SKSpriteNode(imageNamed: ImageName.Prize)
prize.position = CGPoint(x: size.width * 0.5, y: size.height * 0.7)
prize.zPosition = Layer.Prize
prize.physicsBody = SKPhysicsBody(texture: SKTexture(imageNamed: ImageName.Prize), size: prize.size)
prize.physicsBody?.categoryBitMask = PhysicsCategory.Prize
prize.physicsBody?.collisionBitMask = 0
prize.physicsBody?.density = 0.5
 
addChild(prize)

與鱷魚類似,菠蘿節(jié)點也用了物理身體。最大的區(qū)別是菠蘿會掉落然后彈來彈去,而鱷魚只是坐在那里,焦急的等待。所以我們沒有設(shè)置 isDynamic ,讓它保留默認值,true。我們還減少了菠蘿的密度,這樣它就可以更自由的搖擺。

使用物理學

在讓菠蘿掉下之前,最好能配置一下物理世界。找到 GameScene.swift 中的 setUpPhysics() 方法,然后添加下面的三行代碼:

physicsWorld.contactDelegate = self
physicsWorld.gravity = CGVector(dx: 0.0, dy: -9.8)
physicsWorld.speed = 1.0

這樣就建立了物理世界的 contactDelegate、重力(gravity)和速度(speed)。重力決定了物理世界中身體的重力加速度,速度決定了模擬執(zhí)行的速度。(這兩個屬性都被設(shè)置為默認值。)

由于我們把 self 指定為 contact delegate,所以會在第一行出現(xiàn)一個編譯器錯誤,因為 GameScene 還不符合 SKPhysicsContactDelegate 協(xié)議。在類定義中添加這個協(xié)議可以修復(fù),像這樣:

class GameScene: SKScene, SKPhysicsContactDelegate {

再次構(gòu)建并運行 app。應(yīng)該能看見菠蘿穿過鱷魚,掉進水里(實際上是在水的后面)。是時候添加葡萄藤了。

添加葡萄藤

SpriteKit 的物理身體旨在模擬剛性物理。但葡萄藤是彎的。所以我們會把每條葡萄藤實現(xiàn)為,具有一段段靈活接頭的數(shù)組,類似鏈條。

每條葡萄藤有三個重要的屬性:

  • anchorPointCGPoint,表示藤的末端,連接到樹的位置
  • length:Int,表示葡萄藤中有多少段
  • name:String,用于標識給定段所屬的葡萄藤

在本教程中,游戲只有一關(guān)。但在真正的游戲中,我們會希望能夠輕松創(chuàng)建新的關(guān)卡布局,而無需編寫大量代碼。一種實現(xiàn)的好方法是獨立于游戲邏輯指定關(guān)卡數(shù)據(jù),比如借助 property list 或 JSON 以將其存儲在數(shù)據(jù)文件中。

因為我們會從文件中加載葡萄藤數(shù)據(jù),因此表示葡萄藤數(shù)據(jù)的自然結(jié)構(gòu)是 NSDictionary 對象的 NSArray,可以使用初始化方法 NSArray(contentsOfFile:) 從 property list 中輕易讀取出來。每個 dictionary 都表示一條葡萄藤。

GameScene.swift 中,找到 setUpVines() 然后添加如下代碼:

// 1 加載葡萄藤數(shù)據(jù)
let dataFile = Bundle.main.path(forResource: GameConfiguration.VineDataFile, ofType: nil)
let vines = NSArray(contentsOfFile: dataFile!) as! [NSDictionary]
 
// 2 添加葡萄藤
for i in 0..<vines.count {
  // 3 創(chuàng)建葡萄藤
  let vineData = vines[i]
  let length = Int(vineData["length"] as! NSNumber)
  let relAnchorPoint = CGPointFromString(vineData["relAnchorPoint"] as! String)
  let anchorPoint = CGPoint(x: relAnchorPoint.x * size.width,
                            y: relAnchorPoint.y * size.height)
  let vine = VineNode(length: length, anchorPoint: anchorPoint, name: "\(i)")
 
  // 4 添加到創(chuàng)建中
  vine.addToScene(self)
 
  // 5 將葡萄藤的另一端連接到獎勵
  vine.attachToPrize(prize)
}

使用上面的代碼,我們:

  1. 從 property list 文件中加載了葡萄藤數(shù)據(jù)??梢钥纯?Resources/Data 中的 VineData.plist 文件,可以看到該文件包含了一個字典數(shù)組,每個字典包括 relAnchorPointlength
  1. for 循環(huán)遍歷了數(shù)組的索引。遍歷索引,而不是遍歷數(shù)組對象的原因是我們需要該索引值以為每條葡萄藤生成唯一的名字字符串。這在后面會相當重要。
  2. 對于每個葡萄藤字典,都要取出 lengthrelAnchorPoint,用于初始化新的 VineNode 對象。length 指定了葡萄藤的段數(shù)。relAnchorPoint 用于確定葡萄藤相對于場景的尺寸的錨點位置。
  3. 最后,使用 addToScene()VineNode 附到 場景中。
  4. 然后用 attachToPrize() 將其附加到獎勵上。

下面我們會在 VineNode 中實現(xiàn)這些方法。

定義葡萄藤類

打開 VineNode.swift。VineNode 是一個自定義類,繼承自 SKNode。它本身沒有任何視覺外觀,而是作為表示容納葡萄藤段的 SKSpriteNodes 集合。

在類定義中添加如下屬性:

private let length: Int
private let anchorPoint: CGPoint
private var vineSegments: [SKNode] = []

會出現(xiàn)幾個錯誤,因為 lengthanchorPoint 還沒有被初始化。我們把它們聲明為非可選值,但卻沒有分配值。用如下代碼替換 init(length:anchorPoint:name:) 方法的實現(xiàn)部分即可修復(fù):

self.length = length
self.anchorPoint = anchorPoint
 
super.init()
 
self.name = name

相當簡單,但由于某些原因還是有錯誤。有第二個初始化方法,init(coder:) ——我們沒有在任何地方調(diào)用它,所以它是干嘛用的?

因為 SKNode 實現(xiàn)了 NSCoding 協(xié)議,所以它繼承了必要初始化方法 init(coder:),表示我們必須初始化非可選值屬性,即使我們沒有用到它。

現(xiàn)在就干。用以下代碼替換掉 init(coder:) 的內(nèi)容:

length = aDecoder.decodeInteger(forKey: "length")
anchorPoint = aDecoder.decodeCGPoint(forKey: "anchorPoint")
 
super.init(coder: aDecoder)

下一步,我們需要實現(xiàn) addToScene() 方法。這是一個復(fù)雜的方法,所以我們要分階段來寫。首先,找到 addToScene() 并添加以下代碼:

// 把葡萄藤加到場景中
zPosition = Layer.Vine
scene.addChild(self)

我們把葡萄藤加到了場景中,并設(shè)置了它的 zPosition。接下來,把這個代碼塊添加到同樣的方法中:

// 創(chuàng)建葡萄藤架
let vineHolder = SKSpriteNode(imageNamed: ImageName.VineHolder)
vineHolder.position = anchorPoint
vineHolder.zPosition = 1
 
addChild(vineHolder)
 
vineHolder.physicsBody = SKPhysicsBody(circleOfRadius: vineHolder.size.width / 2)
vineHolder.physicsBody?.isDynamic = false
vineHolder.physicsBody?.categoryBitMask = PhysicsCategory.VineHolder
vineHolder.physicsBody?.collisionBitMask = 0

這樣就創(chuàng)建了葡萄藤架,就像用于葡萄藤懸掛的釘子。和鱷魚一樣,這個身體不是動態(tài)(dynamic)的,不會與其它身體碰撞。

藤架是圓形的,所以用 SKPhysicsBody(circleOfRadius:) 構(gòu)造函數(shù)。藤架的位置就是我們創(chuàng)建 VineModel 時指定的 anchorPoint。

接下來,我們要創(chuàng)建葡萄藤。還是那個方法,把下面的代碼加到底部:

// 添加葡萄藤的各個部分
for i in 0..<length {
  let vineSegment = SKSpriteNode(imageNamed: ImageName.VineTexture)
  let offset = vineSegment.size.height * CGFloat(i + 1)
  vineSegment.position = CGPoint(x: anchorPoint.x, y: anchorPoint.y - offset)
  vineSegment.name = name
 
  vineSegments.append(vineSegment)
  addChild(vineSegment)
 
  vineSegment.physicsBody = SKPhysicsBody(rectangleOf: vineSegment.size)
  vineSegment.physicsBody?.categoryBitMask = PhysicsCategory.Vine
  vineSegment.physicsBody?.collisionBitMask = PhysicsCategory.VineHolder
}

此循環(huán)創(chuàng)建了葡萄藤段的數(shù)組,數(shù)量與創(chuàng)建 VineModel 時指定的 length 相等。每一段都是擁有自己物理身體的子畫面。這些分段是矩形的,因此我們用 SKPhysicsBody(rectangleOfSize:) 來指定物理身體的形狀。

和藤架不同,葡萄藤節(jié)點是動態(tài)的,所以它們四處移動,也會受到重力的影響。

構(gòu)建并運行 app,看看我們的進展。

我的天吶!葡萄藤段就像切碎的意大利面一樣從屏幕上掉了下來!

添加葡萄藤的接頭(Joint)

現(xiàn)在的問題是沒有把葡萄糖段接在一起。要修復(fù)這個問題,我們需要在 addToScene() 的底部添加最后一段代碼:

// 為藤架設(shè)置接頭
let joint = SKPhysicsJointPin.joint(withBodyA: vineHolder.physicsBody!,
                                    bodyB: vineSegments[0].physicsBody!,
                                    anchor: CGPoint(x: vineHolder.frame.midX, y: vineHolder.frame.midY))
scene.physicsWorld.add(joint)
 
// 在葡萄藤分段間增加接頭
for i in 1..<length {
  let nodeA = vineSegments[i - 1]
  let nodeB = vineSegments[i]
  let joint = SKPhysicsJointPin.joint(withBodyA: nodeA.physicsBody!, bodyB: nodeB.physicsBody!,
                                      anchor: CGPoint(x: nodeA.frame.midX, y: nodeA.frame.minY))
 
  scene.physicsWorld.add(joint)
}

這段代碼設(shè)置了分段間的物理接頭,把分段連接在了一起。我們用的接頭類型是 SKPhysicsJointPin,它表現(xiàn)的就像用錘子把兩個節(jié)點釘在一起,這兩個節(jié)點可以繞著釘子轉(zhuǎn)動,但是不能彼此靠近或遠離。

再次構(gòu)建并運行。我們的葡萄藤應(yīng)該已經(jīng)逼真的掛在樹上了。

最后一步是把葡萄藤附到菠蘿上。還是在 VineNode.swift 里面,滾動到 attachToPrize()。添加如下代碼:

// 連接獎勵和葡萄藤的最后一段
let lastNode = vineSegments.last!
lastNode.position = CGPoint(x: prize.position.x, y: prize.position.y + prize.size.height * 0.1)
 
// 設(shè)置連接接頭
let joint = SKPhysicsJointPin.joint(withBodyA: lastNode.physicsBody!, 
                                    bodyB: prize.physicsBody!, anchor: lastNode.position)
 
prize.scene?.physicsWorld.add(joint)

這段代碼獲取了葡萄藤的追后一個分段,并將其置于略高于獎勵中心的位置。(這里用這種附加方式,實際上就把獎勵掛起來了。如果死板的用中心位置,獎勵的重量會被均勻分布,而且還可能會繞著軸線旋轉(zhuǎn)。)我們還釘了另一個接頭,把葡萄藤段附加到獎勵上。

構(gòu)建并運行項目。如果所有接頭和節(jié)點都設(shè)置正確了,應(yīng)該會看到下面這樣的屏幕:

棒棒!一只掛著的菠蘿——到底是誰把菠蘿掛到樹上的?:]

剪葡萄藤

你應(yīng)該已經(jīng)發(fā)現(xiàn)了,我們還不能剪斷這些葡萄藤?下面我們來解決這個小問題。

在本節(jié)中,我們會用觸摸方法,讓玩家可以剪斷那些懸著的葡萄藤?;氐?GameScene.swift,找到 touchesMoved() 然后添加如下代碼:

for touch in touches {
  let startPoint = touch.location(in: self)
  let endPoint = touch.previousLocation(in: self)
 
  // 檢查是否切割葡萄藤
  scene?.physicsWorld.enumerateBodies(alongRayStart: startPoint, end: endPoint,
                                      using: { (body, point, normal, stop) in
    self.checkIfVineCutWithBody(body)
  })
 
  // 產(chǎn)生一些好看的顆粒
  showMoveParticles(touchPosition: startPoint)
}

這段代碼的工作原理如下:對于每次觸摸,都會獲得它的當前和前一個位置。接下來,使用 SKScene 非常便捷的方法 enumerateBodies(alongRayStart:end:using:),遍歷循環(huán)這兩點間的場景中所有的身體。對于遇到的每個身體,都會調(diào)用 checkIfVineCutWithBody(),我們馬上就會寫這個方法。

最后,代碼調(diào)用了一個方法,從 Particle.sks 文件加載并創(chuàng)建了 SKEmitterNode,并將其添加到場景中用戶觸摸的位置。這樣只要拖動手指就會產(chǎn)生很好看的綠色煙霧蹤跡(相當?shù)男闵刹停。?/p>

向下滾動到 checkIfVineCutWithBody() 方法,添加這段代碼到方法體內(nèi):

let node = body.node!
 
// 如果有 name,就必然是葡萄藤節(jié)點
if let name = node.name {
  // 切斷葡萄藤
  node.removeFromParent()
 
  // 讓所有名字匹配的節(jié)點淡出
  enumerateChildNodes(withName: name, using: { (node, stop) in
    let fadeAway = SKAction.fadeOut(withDuration: 0.25)
    let removeNode = SKAction.removeFromParent()
    let sequence = SKAction.sequence([fadeAway, removeNode])
    node.run(sequence)
  })
}

上面的代碼首先檢查連接到物理身體的節(jié)點是否有名字。記住場景里除了葡萄藤段外,還有其它節(jié)點,我們肯定不想隨意一揮就不小心切斷了鱷魚和菠蘿!因為我們只為葡萄藤節(jié)點命名了,所以如果節(jié)點有名字,就可以確定它是某段葡萄藤。

下一步,從場景中刪除節(jié)點。刪除節(jié)點還會刪除它的 physicsBody,并銷毀與其連接的所有接頭。葡萄藤現(xiàn)在正式被剪斷了!

最后,使用 scene 的 enumerateChildNodes(withName:using:) 遍歷場景中所有與被剪斷的節(jié)點名稱相匹配的節(jié)點。只有相同葡萄藤中的其它段的節(jié)點會匹配,所以我們其實就是遍歷被剪斷的葡萄藤的分段。

對于每個節(jié)點,我們都創(chuàng)建了一個 SKAction 序列,首先淡出節(jié)點,然后將其從場景中刪除。效果就是每個葡萄糖被切斷后都會消失。

構(gòu)建并運行項目。試著剪斷這些葡萄藤——我們現(xiàn)在應(yīng)該可以滑動切掉全部三個葡萄藤,然后看著獎勵掉下來。漂亮的菠蘿!:]

處理身體間的接觸

在我們寫 setUpPhysics() 方法時,把 GameScene 指定為 physicsWorld 的 contactDelegate。我們還配置了 croc 的 contactTestBitMask,以便它與獎勵相交時可以收到通知。這太有遠見了!

現(xiàn)在我們需要實現(xiàn) SKPhysicsContactDelegatedidBegin(),當檢測到兩個適當?shù)?mask body 相交時就會觸發(fā)。這個方法已經(jīng)有一個空殼——向下滑動找到它,然后添加如下代碼:

if (contact.bodyA.node == crocodile && contact.bodyB.node == prize)
  || (contact.bodyA.node == prize && contact.bodyB.node == crocodile) {
 
  // 把菠蘿縮小出去
  let shrink = SKAction.scale(to: 0, duration: 0.08)
  let removeNode = SKAction.removeFromParent()
  let sequence = SKAction.sequence([shrink, removeNode])
  prize.run(sequence)
}

這段代碼檢查兩個相交的身體是否屬于鱷魚和獎勵(我們也不知道被列出的節(jié)點的順序,所以兩種組合都要檢查)。如果檢查通過,我們會觸發(fā)一個簡單的動畫序列,把獎勵縮小到?jīng)]有,然后將其從場景中刪除。

鱷魚咀嚼動畫

當鱷魚抓住菠蘿時,我們希望它能夠咀嚼。在我們剛剛觸發(fā)菠蘿縮小動畫的 if 語句中,再添加下面這行:

runNomNomAnimationWithDelay(0.15)

現(xiàn)在找到 runNomNomAnimationWithDelay() 并添加這段代碼:

crocodile.removeAllActions()
 
let closeMouth = SKAction.setTexture(SKTexture(imageNamed: ImageName.CrocMouthClosed))
let wait = SKAction.wait(forDuration: delay)
let openMouth = SKAction.setTexture(SKTexture(imageNamed: ImageName.CrocMouthOpen))
let sequence = SKAction.sequence([closeMouth, wait, openMouth, wait, closeMouth])
 
crocodile.run(sequence)

上面的代碼用 removeAllActions() 刪除了當前在鱷魚節(jié)點上的所有動畫。然后創(chuàng)建了一個新的動畫序列,張開和合上鱷魚的嘴巴,然后讓 crocodile 運行這個序列。

這個新的動畫會在獎勵落在鱷魚嘴里時觸發(fā),給人一種鱷魚正在咀嚼的印象。

把下面的代碼添加到 checkIfVineCutWithBody()if 語句中:

crocodile.removeAllActions()
crocodile.texture = SKTexture(imageNamed: ImageName.CrocMouthOpen)
animateCrocodile()

這樣可以確保剪葡萄藤時鱷魚的嘴是張開額,并且讓它有機會掉進鱷魚嘴里。

構(gòu)建并運行。

重置游戲

如果菠蘿落在鱷魚的嘴里,她就會很開心的咀嚼。但如果真的發(fā)生了這種情況,游戲就會被一直掛在那里。

GameScene.swift 中,找到 switchToNewGameWithTransition(),添加如下代碼:

let delay = SKAction.wait(forDuration: 1)
let sceneChange = SKAction.run({
  let scene = GameScene(size: self.size)
  self.view?.presentScene(scene, transition: transition)
})
 
run(SKAction.sequence([delay, sceneChange]))

上面的代碼使用了 SKViewpresentScene(_:transition:) 方法來呈現(xiàn)下一個場景。

在這種情況下,我們要切換的場景是相同的 GameScene 類的新的實例。我們還使用了 SKTransition 類傳遞轉(zhuǎn)換效果。該轉(zhuǎn)換被指定為這個方法的參數(shù),以便我們可以根據(jù)游戲的效果使用不同的轉(zhuǎn)換效果。

回滾到 didBegin(),在 if 語句里面,縮小獎勵和 nomnom 動畫中的后面,添加以下內(nèi)容:

// 轉(zhuǎn)到下一關(guān)
switchToNewGameWithTransition(SKTransition.doorway(withDuration: 1.0))

這段代碼使用 SKTransition.doorway(withDuration:) 初始化方法創(chuàng)建了一個 doorway 轉(zhuǎn)換,供 switchToNewGameWithTransition() 調(diào)用。這樣就會用一種類似開門的效果顯示下一關(guān)。 很簡潔吧?

結(jié)束游戲

也許你想再給水添加一個物理身體,這樣就能檢測獎勵是否擊中了它,但如果菠蘿飛到了屏幕的側(cè)面,這就沒用了。更簡單、更友好的方式就是檢測菠蘿是否已經(jīng)移動到屏幕底部,然后結(jié)束游戲。

SKScene 提供了一個 update() 方法,每幀都會調(diào)用一次。找到那個方法,添加下面的邏輯:

if prize.position.y <= 0 {
  switchToNewGameWithTransition(SKTransition.fade(withDuration: 1.0))
}

if 語句檢測獎勵的 y 坐標是不是小于 0(屏幕底部)。如果是,就調(diào)用 switchToNewGameWithTransition() 再開一關(guān),這次使用了 SKTransition.fade(withDuration:)。

構(gòu)建并運行項目。

現(xiàn)在玩家不論成功與否,都會看到場景過渡到新場景中。

添加音效和音樂

我從 incompetech.com 選擇了一首好聽的叢林之歌,然后從 freesound.org 選了一些音效。

SpriteKit 會為我們處理音效。但是我們會用 AVAudioPlayer 在關(guān)卡轉(zhuǎn)換間不間斷的播放背景音樂。

GameScene.swift 添加另一個屬性:

private static var backgroundMusicPlayer: AVAudioPlayer!

這樣就聲明了一個類型屬性,GameScene 所有實例就都可以訪問到相同的 backgroundMusicPlayer 了。找到 setUpAudio() 方法然后添加如下代碼:

if GameScene.backgroundMusicPlayer == nil {
  let backgroundMusicURL = Bundle.main.url(forResource: SoundFile.BackgroundMusic, withExtension: nil)
 
  do {
    let theme = try AVAudioPlayer(contentsOf: backgroundMusicURL!)
    GameScene.backgroundMusicPlayer = theme
 
  } catch {
    // 無法加載文件 :[
  }
 
  GameScene.backgroundMusicPlayer.numberOfLoops = -1
}

上面的代碼檢查 backgroundMusicPlayer 是否已經(jīng)被創(chuàng)建。如果沒有,就用我們之前添加到 Constants.swiftBackgroundMusic 常量(被轉(zhuǎn)化為 URL)初始化一個新的 AVAudioPlayer ,然后將其分配給屬性。numberOfLoops 被設(shè)置為 -1,表示音樂會無限循環(huán)。

下一步,在 setUpAudio() 方法的底部,添加這段代碼:

if !GameScene.backgroundMusicPlayer.isPlaying {
  GameScene.backgroundMusicPlayer.play()
}

這將在場景首次加載時開始播放背景音樂(會一直播放直到 app 退出或另一個方法調(diào)用了 player 的 stop())。我們可以不用先檢查 player 是否正在播放再調(diào)用 play(),但這樣的話如果關(guān)卡開始時已經(jīng)在播放了,音樂不會被跳過或重新開始。

現(xiàn)在我們還要設(shè)置一下后面會用到的音效。和音樂不同,我們不想立馬播放音效。相反,我們會創(chuàng)建一些可復(fù)用的 SKActions,可用于稍后播放音效。

回到 GameScene 類定義的頂部,添加如下屬性:

private var sliceSoundAction: SKAction!
private var splashSoundAction: SKAction!
private var nomNomSoundAction: SKAction!

現(xiàn)在回到 setUpAudio() 然后在方法底部添加下面幾行代碼:

sliceSoundAction = SKAction.playSoundFileNamed(SoundFile.Slice, waitForCompletion: false)
splashSoundAction = SKAction.playSoundFileNamed(SoundFile.Splash, waitForCompletion: false)
nomNomSoundAction = SKAction.playSoundFileNamed(SoundFile.NomNom, waitForCompletion: false)

這段代碼使用 SKActionplaySoundFileNamed(_:waitForCompletion:) 初始化了聲音動作?,F(xiàn)在是時候播放音效了。

向上滾動到 update() 然后在 if 語句中,switchToNewGameWithTransition() 調(diào)用上方添加下面這行代碼:

run(splashSoundAction)

當菠蘿落在水里時,會發(fā)出濺水的聲音。接下來,找到 didBegin() 然后在 runNomNomAnimationWithDelay(0.15) 這行的下方添加下面這行代碼:

run(nomNomSoundAction)

當鱷魚抓住獎勵時,會發(fā)出咔嚓咔嚓的聲音。最后,找到 checkIfVineCutWithBody() 然后在 if 語句中添加下面這行代碼:

run(sliceSoundAction)

這樣當玩家剪斷葡萄藤時,就會發(fā)出揮擊的聲音。

構(gòu)建并運行項目。

有沒有發(fā)現(xiàn)一個 bug?如果沒有擊中鱷魚,濺水的聲音會播放好多次。這是因為“完成關(guān)卡”邏輯在游戲過渡到下一場景前被重復(fù)觸發(fā)了。要改正的話,在類的頂部添加一個新的狀態(tài)屬性:

private var levelOver = false

現(xiàn)在修改 update()didBegin(),在每個頂部添加如下代碼:

if levelOver {
  return
}

最后,還是在這兩個方法的 if 語句中,添加一些代碼以將 levelOver 狀態(tài)設(shè)置為 true

levelOver = true

現(xiàn)在如果游戲檢測到 levelOver 標記已被設(shè)置(要么因為菠蘿掉到了地上,要么因為鱷魚遲到了東西),就會停止檢查游戲的成功/失敗情況,并且不會反復(fù)嘗試播放這些音效。構(gòu)建并運行。再也沒有尷尬的音效了!

添加觸覺反饋

iPhone 7 配備了一個新的 taptic 引擎,為用戶提供觸摸反饋。最著名的就是在手機全新的 home 鍵(沒有可移動的部件)上模擬“點擊”。但感謝 UIFeedbackGenerator 類,開發(fā)者就只要用幾行代碼也可以實現(xiàn)這個效果。

我們會用 UIImpactFeedbackGenerator 子類為 sprite 碰撞添加一些抖動。這個類有三種設(shè)置:light、medium 和 heavy。如果鱷魚在咀嚼菠蘿,我們會添加 heavy 效果。如果菠蘿飛出了屏幕,會添加 light 效果。

首先,實例化反饋生成器。在 GameScene.swift 中,在 didMove() 之前添加如下屬性:

let chomp = UIImpactFeedbackGenerator(style: .heavy)
let splash = UIImpactFeedbackGenerator(style: .light)

下一步,使用 impactOccurred() 方法觸發(fā)反饋。滾動到 update 然后直接在 run(splashSoundAction) 下面添加如下代碼:

splash.impactOccurred()

下一步,找到 didBegin() 然后在 run(nomNomSoundAction) 行下方,添加如下代碼:

chomp.impactOccurred()

構(gòu)建,然后在 iPhone 7 上運行一下我們的游戲,感受 haptic 反饋。

如果想更多了解 haptic 反饋,看看這個簡短的視頻教程, iOS 10: Providing Haptic Feedback

增加難度

玩了幾輪后,游戲似乎顯得太簡單了。玩家很快就能找到合適的時間一下切斷三條葡萄藤來給鱷魚喂食。

使用之前我們設(shè)置的常量,CanCutMultipleVinesAtOnce,來讓游戲變得更加棘手。

GameScene.swift 中,GameScene 類定義的頂部添加最后一個屬性:

private var vineCut = false

現(xiàn)在找到 checkIfVineCutWithBody() 方法,在方法頂部添加下面的 if 語句:

if vineCut && !GameConfiguration.CanCutMultipleVinesAtOnce {
  return
}

還是這個方法,在底部添加這行代碼:

vineCut = true

找到 touchesMoved(),在上方添加這個方法:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
  vineCut = false
}

這樣當用戶觸摸屏幕時,就會重置 vineCut 標志。

再次構(gòu)建并運行游戲?,F(xiàn)在應(yīng)該可以看到,每次滑動時只能剪斷一條葡萄藤。要剪掉另外一條,需要抬起手指然后再滑一次。

下一步?

在這里下載 完整的示例項目

但不要就此打??!嘗試添加新的關(guān)卡,不同的葡萄藤,或者增加一個 HUD 來顯示分數(shù)和時間。

如果你想多了解 SpriteKit,一定要看看這本書, 2D Apple Games by Tutorials 。

如果有任何疑問或評論,直接在下方參與討論!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容