教你用 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)是一款流行的物理驱动游戏,玩家通过剪断挂着糖果的绳索来喂养一只名叫 Om Nom 的小怪兽。只要在正确的时间和位置切断,Om Nom 就会得到一份美味佳肴。

虽然对 Om Nom 有着满满的敬意,可我还是要说,这个游戏真正的明星是它方针的物理学:绳索摆动、重力牵引、糖果按照真实世界中那样落下。

我们可以用苹果的 2D 游戏框架,SpriteKit,借助它的物理引擎创建相似的游戏体验。在本教程中,我们会一起做一个名为 Snip The Vine 的游戏。

注意:本教程假设你对 SpriteKit 有一些经验。如果还不了解 SpriteKit,看看这篇 SpriteKit Swift Tutorial for Beginners

开始

Snip The Vine 中,玩家可以把 可爱的小动物 菠萝喂给鳄鱼。从 下载启动项目 开始。在 Xcode 中打开项目,快速浏览一下结构。

项目文件分散在多个文件夹中。本教程中,我们需要处理 Classes 文件夹,它包含了主要的代码文件。再随便看看其它文件夹,如下所示:

常量设置

常量可以让代码可读性更强,避免重复硬编码的字符串和”魔法数字(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
}

这段代码又定义了两个结构体,LayerPhysicsCategory,每个都包含了很多 CGFloat 和 UInt32 属性。在我们添加东西到场景中时,会用它们来指定 sprite 的 zPostion 和物理类别。

最后,再添加一个结构体

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

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

CanCutMultipleVinesAtOnce 是一种简单的修改游戏参数的方式。会让游戏更有意思的决策并不是显而易见的。像这样的常量就提供了一种简单的方式,让我们在各种方法之间切换,以便我们在后面修改游戏。

现在可以开始为我们的场景添加节点了。

为场景添加背景子画面(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() 中调用的。在这个方法里,我们创建了一组 SKSpriteNode,并且使用 SKSpriteNode(imageNamed:) 将它们初始化了。要处理多种屏幕尺寸的话,要明确规定背景图片的尺寸。

我们已经将这两个节点的 anchorPoint 从 (0.5, 0.5) 更改到 (0, 0)。这意味着节点的定位是�现对于左下角的,而不是中心,这样就可以轻松地将背景和水置于场景中,并且让它们底部对齐。

注意: anchorPoint 属性使用了 unit 坐标系,(0,0) 表示子画面图片的左下角,(1,1) 表示右上角。因为量度总是为 0 到 1,所以这些坐标与图像尺寸和纵横比无关。

我们还设置了子画面的 zPosition,控制了 SpriteKit 在屏幕上绘制节点的顺序。

回想一下在 Constants.swift 中,我们指定了一些值,用于子画面的 zPosition。这里(Layer)用到了其中两个。BackgroundLayer.Foreground —— 确保背景将保持在其它子画面的后面,前景则始终在最前面绘制。

构建并运行项目。如果没做错的话,就可以看到下面的画面:

把鳄鱼加进场景

提前警告一下,这只鳄鱼很喜欢咬人,注意手指要一直和它保持距离!:]

就像背景布景一样,鳄鱼使用 SKSpriteNode 来表示。但有几个重要的区别:为了游戏逻辑,我们需要保留对鳄鱼的引用;我们还需要为鳄鱼子画面设置物理身体,以检测和处理与其他身体的接触。

还是在 GameScene.swift 里,把如下属性加到类的最上面:

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

这些属性用于保存对鳄鱼和奖励(菠萝)的引用。我们把它们定义为私有的,因为它们不会在 GameScene 之外被访问。

这些属性的类型已经被定义为 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()

这段代码创建了鳄鱼节点,并设置了它的 positionzPosition

与背景布景不同,鳄鱼有 SKPhysicsBody,意味着它可以与世界上其他物体进行物理交互。在后面检测菠萝是否落到它嘴巴里的时候很有用处。我们不希望鳄鱼被打翻、或是从屏幕底下掉出来,所以把 isDynamic 设置为 false,从而防止它受到物理受力的影响。

categoryBitMask 定义了身体所属的物理类别 —— PhysicsCategory。在这里就是鳄鱼。我们把 collisionBitMask 设置为 0 因为我们不希望鳄鱼把其它身体弹飞。我们需要知道的就是何时”奖励“身体会接触到鳄鱼,所以我们设置了响应的 contactTestBitMask

你可能注意到了,鳄鱼的物理身体使用了 SKTexture 进行初始化。其实简单点的话,我们可以直接在身体纹理上复用 CrocMouthOpen ,但那个图片包括了鳄鱼的整个身体,而 mask 纹理只包含鳄鱼的头和嘴。鳄鱼可不能用尾巴吃菠萝!

现在我们会为鳄鱼添加一个”等待“动画。找到 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))

除了要让小鳄鱼显得很焦虑外,这段代码还创建了一些改变鳄鱼节点的纹理的动作,使其在闭嘴和张嘴之间交替。

SKAction.sequence() 构造函数从数组中创建了一个动作序列。在这种情况下,纹理动作按照序列进行组合,并且有2到4秒不定的随机延迟时间。

序列动作被包装在一个 repeatActionForever() 动作中,所以它在那段时间内会一直重复。然后由鳄鱼节点运行这个动作。

搞定!构建并运行,看看这只可怕的爬行动作撕咬它的死亡之颚!

我们现在有了布景,我们也有了一只鳄鱼——现在需要 可爱的小动物一个菠萝。

增加奖励

打开 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)

与鳄鱼类似,菠萝节点也用了物理身体。最大的区别是菠萝会掉落然后弹来弹去,而鳄鱼只是坐在那里,焦急的等待。所以我们没有设置 isDynamic ,让它保留默认值,true。我们还减少了菠萝的密度,这样它就可以更自由的摇摆。

使用物理学

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

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

这样就建立了物理世界的 contactDelegate、重力(gravity)和速度(speed)。重力决定了物理世界中身体的重力加速度,速度决定了模拟执行的速度。(这两个属性都被设置为默认值。)

由于我们把 self 指定为 contact delegate,所以会在第一行出现一个编译器错误,因为 GameScene 还不符合 SKPhysicsContactDelegate 协议。在类定义中添加这个协议可以修复,像这样:

class GameScene: SKScene, SKPhysicsContactDelegate {

再次构建并运行 app。应该能看见菠萝穿过鳄鱼,掉进水里(实际上是在水的后面)。是时候添加葡萄藤了。

添加葡萄藤

SpriteKit 的物理身体旨在模拟刚性物理。但葡萄藤是弯的。所以我们会把每条葡萄藤实现为,具有一段段灵活接头的数组,类似链条。

每条葡萄藤有三个重要的属性:

  • anchorPointCGPoint,表示藤的末端,连接到树的位置
  • length:Int,表示葡萄藤中有多少段
  • name:String,用于标识给定段所属的葡萄藤

在本教程中,游戏只有一关。但在真正的游戏中,我们会希望能够轻松创建新的关卡布局,而无需编写大量代码。一种实现的好方法是独立于游戏逻辑指定关卡数据,比如借助 property list 或 JSON 以将其存储在数据文件中。

因为我们会从文件中加载葡萄藤数据,因此表示葡萄藤数据的自然结构是 NSDictionary 对象的 NSArray,可以使用初始化方法 NSArray(contentsOfFile:) 从 property list 中轻易读取出来。每个 dictionary 都表示一条葡萄藤。

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

// 1 加载葡萄藤数据
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 创建葡萄藤
  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 添加到创建中
  vine.addToScene(self)
 
  // 5 将葡萄藤的另一端连接到奖励
  vine.attachToPrize(prize)
}

使用上面的代码,我们:

  1. 从 property list 文件中加载了葡萄藤数据。可以看看 Resources/Data 中的 VineData.plist 文件,可以看到该文件包含了一个字典数组,每个字典包括 relAnchorPointlength
  1. for 循环遍历了数组的索引。遍历索引,而不是遍历数组对象的原因是我们需要该索引值以为每条葡萄藤生成唯一的名字字符串。这在后面会相当重要。
  2. 对于每个葡萄藤字典,都要取出 lengthrelAnchorPoint,用于初始化新的 VineNode 对象。length 指定了葡萄藤的段数。relAnchorPoint 用于确定葡萄藤相对于场景的尺寸的锚点位置。
  3. 最后,使用 addToScene()VineNode 附到 场景中。
  4. 然后用 attachToPrize() 将其附加到奖励上。

下面我们会在 VineNode 中实现这些方法。

定义葡萄藤类

打开 VineNode.swiftVineNode 是一个自定义类,继承自 SKNode。它本身没有任何视觉外观,而是作为表示容纳葡萄藤段的 SKSpriteNodes 集合。

在类定义中添加如下属性:

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

会出现几个错误,因为 lengthanchorPoint 还没有被初始化。我们把它们声明为非可选值,但却没有分配值。用如下代码替换 init(length:anchorPoint:name:) 方法的实现部分即可修复:

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

相当简单,但由于某些原因还是有错误。有第二个初始化方法,init(coder:) ——我们没有在任何地方调用它,所以它是干嘛用的?

因为 SKNode 实现了 NSCoding 协议,所以它继承了必要初始化方法 init(coder:),表示我们必须初始化非可选值属性,即使我们没有用到它。

现在就干。用以下代码替换掉 init(coder:) 的内容:

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

下一步,我们需要实现 addToScene() 方法。这是一个复杂的方法,所以我们要分阶段来写。首先,找到 addToScene() 并添加以下代码:

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

我们把葡萄藤加到了场景中,并设置了它的 zPosition。接下来,把这个代码块添加到同样的方法中:

// 创建葡萄藤架
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

这样就创建了葡萄藤架,就像用于葡萄藤悬挂的钉子。和鳄鱼一样,这个身体不是动态(dynamic)的,不会与其它身体碰撞。

藤架是圆形的,所以用 SKPhysicsBody(circleOfRadius:) 构造函数。藤架的位置就是我们创建 VineModel 时指定的 anchorPoint

接下来,我们要创建葡萄藤。还是那个方法,把下面的代码加到底部:

// 添加葡萄藤的各个部分
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
}

此循环创建了葡萄藤段的数组,数量与创建 VineModel 时指定的 length 相等。每一段都是拥有自己物理身体的子画面。这些分段是矩形的,因此我们用 SKPhysicsBody(rectangleOfSize:) 来指定物理身体的形状。

和藤架不同,葡萄藤节点是动态的,所以它们四处移动,也会受到重力的影响。

构建并运行 app,看看我们的进展。

我的天呐!葡萄藤段就像切碎的意大利面一样从屏幕上掉了下来!

添加葡萄藤的接头(Joint)

现在的问题是没有把葡萄糖段接在一起。要修复这个问题,我们需要在 addToScene() 的底部添加最后一段代码:

// 为藤架设置接头
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)
}

这段代码设置了分段间的物理接头,把分段连接在了一起。我们用的接头类型是 SKPhysicsJointPin,它表现的就像用锤子把两个节点钉在一起,这两个节点可以绕着钉子转动,但是不能彼此靠近或远离。

再次构建并运行。我们的葡萄藤应该已经逼真的挂在树上了。

最后一步是把葡萄藤附到菠萝上。还是在 VineNode.swift 里面,滚动到 attachToPrize()。添加如下代码:

// 连接奖励和葡萄藤的最后一段
let lastNode = vineSegments.last!
lastNode.position = CGPoint(x: prize.position.x, y: prize.position.y + prize.size.height * 0.1)
 
// 设置连接接头
let joint = SKPhysicsJointPin.joint(withBodyA: lastNode.physicsBody!, 
                                    bodyB: prize.physicsBody!, anchor: lastNode.position)
 
prize.scene?.physicsWorld.add(joint)

这段代码获取了葡萄藤的追后一个分段,并将其置于略高于奖励中心的位置。(这里用这种附加方式,实际上就把奖励挂起来了。如果死板的用中心位置,奖励的重量会被均匀分布,而且还可能会绕着轴线旋转。)我们还钉了另一个接头,把葡萄藤段附加到奖励上。

构建并运行项目。如果所有接头和节点都设置正确了,应该会看到下面这样的屏幕:

棒棒!一只挂着的菠萝——到底是谁把菠萝挂到树上的?:]

剪葡萄藤

你应该已经发现了,我们还不能剪断这些葡萄藤?下面我们来解决这个小问题。

在本节中,我们会用触摸方法,让玩家可以剪断那些悬着的葡萄藤。回到 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)
  })
 
  // 产生一些好看的颗粒
  showMoveParticles(touchPosition: startPoint)
}

这段代码的工作原理如下:对于每次触摸,都会获得它的当前和前一个位置。接下来,使用 SKScene 非常便捷的方法 enumerateBodies(alongRayStart:end:using:),遍历循环这两点间的场景中所有的身体。对于遇到的每个身体,都会调用 checkIfVineCutWithBody(),我们马上就会写这个方法。

最后,代码调用了一个方法,从 Particle.sks 文件加载并创建了 SKEmitterNode,并将其添加到场景中用户触摸的位置。这样只要拖动手指就会产生很好看的绿色烟雾踪迹(相当的秀色可餐!)

向下滚动到 checkIfVineCutWithBody() 方法,添加这段代码到方法体内:

let node = body.node!
 
// 如果有 name,就必然是葡萄藤节点
if let name = node.name {
  // 切断葡萄藤
  node.removeFromParent()
 
  // 让所有名字匹配的节点淡出
  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)
  })
}

上面的代码首先检查连接到物理身体的节点是否有名字。记住场景里除了葡萄藤段外,还有其它节点,我们肯定不想随意一挥就不小心切断了鳄鱼和菠萝!因为我们只为葡萄藤节点命名了,所以如果节点有名字,就可以确定它是某段葡萄藤。

下一步,从场景中删除节点。删除节点还会删除它的 physicsBody,并销毁与其连接的所有接头。葡萄藤现在正式被剪断了!

最后,使用 scene 的 enumerateChildNodes(withName:using:) 遍历场景中所有与被剪断的节点名称相匹配的节点。只有相同葡萄藤中的其它段的节点会匹配,所以我们其实就是遍历被剪断的葡萄藤的分段。

对于每个节点,我们都创建了一个 SKAction 序列,首先淡出节点,然后将其从场景中删除。效果就是每个葡萄糖被切断后都会消失。

构建并运行项目。试着剪断这些葡萄藤——我们现在应该可以滑动切掉全部三个葡萄藤,然后看着奖励掉下来。漂亮的菠萝!:]

处理身体间的接触

在我们写 setUpPhysics() 方法时,把 GameScene 指定为 physicsWorld 的 contactDelegate。我们还配置了 croc 的 contactTestBitMask,以便它与奖励相交时可以收到通知。这太有远见了!

现在我们需要实现 SKPhysicsContactDelegatedidBegin(),当检测到两个适当的 mask body 相交时就会触发。这个方法已经有一个空壳——向下滑动找到它,然后添加如下代码:

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)
}

这段代码检查两个相交的身体是否属于鳄鱼和奖励(我们也不知道被列出的节点的顺序,所以两种组合都要检查)。如果检查通过,我们会触发一个简单的动画序列,把奖励缩小到没有,然后将其从场景中删除。

鳄鱼咀嚼动画

当鳄鱼抓住菠萝时,我们希望它能够咀嚼。在我们刚刚触发菠萝缩小动画的 if 语句中,再添加下面这行:

runNomNomAnimationWithDelay(0.15)

现在找到 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() 删除了当前在鳄鱼节点上的所有动画。然后创建了一个新的动画序列,张开和合上鳄鱼的嘴巴,然后让 crocodile 运行这个序列。

这个新的动画会在奖励落在鳄鱼嘴里时触发,给人一种鳄鱼正在咀嚼的印象。

把下面的代码添加到 checkIfVineCutWithBody()if 语句中:

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

这样可以确保剪葡萄藤时鳄鱼的嘴是张开额,并且让它有机会掉进鳄鱼嘴里。

构建并运行。

重置游戏

如果菠萝落在鳄鱼的嘴里,她就会很开心的咀嚼。但如果真的发生了这种情况,游戏就会被一直挂在那里。

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:) 方法来呈现下一个场景。

在这种情况下,我们要切换的场景是相同的 GameScene 类的新的实例。我们还使用了 SKTransition 类传递转换效果。该转换被指定为这个方法的参数,以便我们可以根据游戏的效果使用不同的转换效果。

回滚到 didBegin(),在 if 语句里面,缩小奖励和 nomnom 动画中的后面,添加以下内容:

// 转到下一关
switchToNewGameWithTransition(SKTransition.doorway(withDuration: 1.0))

这段代码使用 SKTransition.doorway(withDuration:) 初始化方法创建了一个 doorway 转换,供 switchToNewGameWithTransition() 调用。这样就会用一种类似开门的效果显示下一关。 很简洁吧?

结束游戏

也许你想再给水添加一个物理身体,这样就能检测奖励是否击中了它,但如果菠萝飞到了屏幕的侧面,这就没用了。更简单、更友好的方式就是检测菠萝是否已经移动到屏幕底部,然后结束游戏。

SKScene 提供了一个 update() 方法,每帧都会调用一次。找到那个方法,添加下面的逻辑:

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

if 语句检测奖励的 y 坐标是不是小于 0(屏幕底部)。如果是,就调用 switchToNewGameWithTransition() 再开一关,这次使用了 SKTransition.fade(withDuration:)

构建并运行项目。

现在玩家不论成功与否,都会看到场景过渡到新场景中。

添加音效和音乐

我从 incompetech.com 选择了一首好听的丛林之歌,然后从 freesound.org 选了一些音效。

SpriteKit 会为我们处理音效。但是我们会用 AVAudioPlayer 在关卡转换间不间断的播放背景音乐。

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 是否已经被创建。如果没有,就用我们之前添加到 Constants.swiftBackgroundMusic 常量(被转化为 URL)初始化一个新的 AVAudioPlayer ,然后将其分配给属性。numberOfLoops 被设置为 -1,表示音乐会无限循环。

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

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

这将在场景首次加载时开始播放背景音乐(会一直播放直到 app 退出或另一个方法调用了 player 的 stop())。我们可以不用先检查 player 是否正在播放再调用 play(),但这样的话如果关卡开始时已经在播放了,音乐不会被跳过或重新开始。

现在我们还要设置一下后面会用到的音效。和音乐不同,我们不想立马播放音效。相反,我们会创建一些可复用的 SKActions,可用于稍后播放音效。

回到 GameScene 类定义的顶部,添加如下属性:

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

现在回到 setUpAudio() 然后在方法底部添加下面几行代码:

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

这段代码使用 SKActionplaySoundFileNamed(_:waitForCompletion:) 初始化了声音动作。现在是时候播放音效了。

向上滚动到 update() 然后在 if 语句中,switchToNewGameWithTransition() 调用上方添加下面这行代码:

run(splashSoundAction)

当菠萝落在水里时,会发出溅水的声音。接下来,找到 didBegin() 然后在 runNomNomAnimationWithDelay(0.15) 这行的下方添加下面这行代码:

run(nomNomSoundAction)

当鳄鱼抓住奖励时,会发出咔嚓咔嚓的声音。最后,找到 checkIfVineCutWithBody() 然后在 if 语句中添加下面这行代码:

run(sliceSoundAction)

这样当玩家剪断葡萄藤时,就会发出挥击的声音。

构建并运行项目。

有没有发现一个 bug?如果没有击中鳄鱼,溅水的声音会播放好多次。这是因为“完成关卡”逻辑在游戏过渡到下一场景前被重复触发了。要改正的话,在类的顶部添加一个新的状态属性:

private var levelOver = false

现在修改 update()didBegin(),在每个顶部添加如下代码:

if levelOver {
  return
}

最后,还是在这两个方法的 if 语句中,添加一些代码以将 levelOver 状态设置为 true

levelOver = true

现在如果游戏检测到 levelOver 标记已被设置(要么因为菠萝掉到了地上,要么因为鳄鱼迟到了东西),就会停止检查游戏的成功/失败情况,并且不会反复尝试播放这些音效。构建并运行。再也没有尴尬的音效了!

添加触觉反馈

iPhone 7 配备了一个新的 taptic 引擎,为用户提供触摸反馈。最著名的就是在手机全新的 home 键(没有可移动的部件)上模拟“点击”。但感谢 UIFeedbackGenerator 类,开发者就只要用几行代码也可以实现这个效果。

我们会用 UIImpactFeedbackGenerator 子类为 sprite 碰撞添加一些抖动。这个类有三种设置:light、medium 和 heavy。如果鳄鱼在咀嚼菠萝,我们会添加 heavy 效果。如果菠萝飞出了屏幕,会添加 light 效果。

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

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

下一步,使用 impactOccurred() 方法触发反馈。滚动到 update 然后直接在 run(splashSoundAction) 下面添加如下代码:

splash.impactOccurred()

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

chomp.impactOccurred()

构建,然后在 iPhone 7 上运行一下我们的游戏,感受 haptic 反馈。

如果想更多了解 haptic 反馈,看看这个简短的视频教程, iOS 10: Providing Haptic Feedback

增加难度

玩了几轮后,游戏似乎显得太简单了。玩家很快就能找到合适的时间一下切断三条葡萄藤来给鳄鱼喂食。

使用之前我们设置的常量,CanCutMultipleVinesAtOnce,来让游戏变得更加棘手。

GameScene.swift 中,GameScene 类定义的顶部添加最后一个属性:

private var vineCut = false

现在找到 checkIfVineCutWithBody() 方法,在方法顶部添加下面的 if 语句:

if vineCut && !GameConfiguration.CanCutMultipleVinesAtOnce {
  return
}

还是这个方法,在底部添加这行代码:

vineCut = true

找到 touchesMoved(),在上方添加这个方法:

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

这样当用户触摸屏幕时,就会重置 vineCut 标志。

再次构建并运行游戏。现在应该可以看到,每次滑动时只能剪断一条葡萄藤。要剪掉另外一条,需要抬起手指然后再滑一次。

下一步?

在这里下载 完整的示例项目

但不要就此打住!尝试添加新的关卡,不同的葡萄藤,或者增加一个 HUD 来显示分数和时间。

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

如果有任何疑问或评论,直接在下方参与讨论!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容