介绍
在本教程中,这是Scratch系列SpriteKit的第五部分,也是最后一部分,我们介绍了一些先进的技术,可以用来优化基于SpriteKit的游戏,从而改善性能和用户体验。
本教程要求您运行Xcode 7.3或更高版本,其中包括Swift 2.2和iOS 9.3,tvOS 9.2和OS X 10.11.4 SDK。 接下来,您可以使用在上一教程中创建的项目,也可以从GitHub下载新副本。
本系列中用于游戏的图形可以在GraphicRiver上找到 。 GraphicRiver是查找游戏插图和图形的绝佳资源。
1.纹理图集
为了优化游戏的内存使用,SpriteKit以SKTextureAtlas
类的形式提供了纹理地图集的功能。 这些地图集将您指定的纹理有效地组合成一个大的纹理,与单独的单个纹理相比,占用更少的内存。
幸运的是,Xcode可以为您轻松创建纹理图集。 这是在用于游戏中其他图像和资源的相同资产目录中完成的。 打开您的项目,然后导航到Assets.xcassets资产目录。 在左侧边栏的底部,单击+按钮,然后选择“ 新建Sprite Atlas”选项。

结果,新文件夹被添加到资产目录。 单击一次文件夹以将其选中,然后再次单击以重命名。 将其命名为障碍 。 接下来,将“ 障碍物1”和“ 障碍物2”资源拖到该文件夹中。 如果需要,您也可以删除Xcode生成的空白Sprite资产,但这不是必需的。 完成后,扩展的“ 障碍物”纹理图集应如下所示:

现在是时候在代码中使用纹理图集了。 打开MainScene.swift并将以下属性添加到 MainScene
类。 我们使用在资产目录中输入的名称初始化纹理图集。
let obstaclesAtlas = SKTextureAtlas(named: "Obstacles")
虽然不是必需的,但是您可以在使用纹理图集的数据之前将其预加载到内存中。 这使您的游戏消除了加载纹理图集并从中检索第一个纹理时可能发生的任何滞后。 预加载纹理图集是通过一种方法完成的,一旦加载完成,您还可以运行自定义代码块。
在MainScene
类中,在didMoveToView(_:)
方法的末尾添加以下代码:
override func didMoveToView(view: SKView) {
...
obstaclesAtlas.preloadWithCompletionHandler {
// Do something once texture atlas has loaded
}
}
要从纹理地图集中检索纹理,可以使用带有在资产目录中指定的名称的textureNamed(_:)
方法作为参数。 让我们更新MainScene
类中的spawnObstacle(_:)
方法以使用我们刚才创建的纹理图集。 我们从纹理图集获取纹理,并使用它来创建一个精灵节点。
func spawnObstacle(timer: NSTimer) {
if player.hidden {
timer.invalidate()
return
}
let spriteGenerator = GKShuffledDistribution(lowestValue: 1, highestValue: 2)
let texture = obstaclesAtlas.textureNamed("Obstacle \(spriteGenerator)")
let obstacle = SKSpriteNode(texture: texture)
obstacle.xScale = 0.3
obstacle.yScale = 0.3
let physicsBody = SKPhysicsBody(circleOfRadius: 15)
physicsBody.contactTestBitMask = 0x00000001
physicsBody.pinned = true
physicsBody.allowsRotation = false
obstacle.physicsBody = physicsBody
let center = size.width/2.0, difference = CGFloat(85.0)
var x: CGFloat = 0
let laneGenerator = GKShuffledDistribution(lowestValue: 1, highestValue: 3)
switch laneGenerator.nextInt() {
case 1:
x = center - difference
case 2:
x = center
case 3:
x = center + difference
default:
fatalError("Number outside of [1, 3] generated")
}
obstacle.position = CGPoint(x: x, y: (player.position.y + 800))
addChild(obstacle)
obstacle.lightingBitMask = 0xFFFFFFFF
obstacle.shadowCastBitMask = 0xFFFFFFFF
}
请注意,如果您的游戏利用了按需资源(ODR),则可以轻松地为每个纹理图集指定一个或多个标签。 一旦使用ODR API成功访问了正确的资源标签,就可以像使用spawnObstacle(_:)
方法一样使用纹理图集。 您可以在我的另一个教程中阅读有关按需资源的更多信息。
2.保存和加载场景
SpriteKit还使您能够轻松地将场景保存到持久性存储中以及从持久性存储中加载场景。 这样一来,玩家就可以退出游戏,稍后重新启动游戏,并且仍然可以达到与以前相同的水平。
SKScene
类已经遵循的NSCoding
协议可以处理游戏的保存和加载。 SpriteKit对该协议所需方法的实现自动允许非常轻松地保存和加载场景中的所有细节。 如果需要,还可以覆盖这些方法以将一些自定义数据与场景一起保存。
因为我们的游戏非常基础,所以我们将使用一个简单的Bool
值来指示汽车是否坠毁。 这显示了如何保存和加载与场景相关的自定义数据。 将以下两种NSCoding
协议方法添加到MainScene
类中。
// MARK: - NSCoding Protocol
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
let carHasCrashed = aDecoder.decodeBoolForKey("carCrashed")
print("car crashed: \(carHasCrashed)")
}
override func encodeWithCoder(aCoder: NSCoder) {
super.encodeWithCoder(aCoder)
let carHasCrashed = player.hidden
aCoder.encodeBool(carHasCrashed, forKey: "carCrashed")
}
如果您不熟悉NSCoding
协议,则encodeWithCoder(_:)
方法将处理场景的保存,而带有单个NSCoder
参数的初始化器将处理加载。
接下来,将以下方法添加到MainScene
类。 saveScene()
方法使用NSKeyedArchiver
类创建场景的NSData
表示形式。 为了简单NSUserDefaults
,我们将数据存储在NSUserDefaults
。
func saveScene() {
let sceneData = NSKeyedArchiver.archivedDataWithRootObject(self)
NSUserDefaults.standardUserDefaults().setObject(sceneData, forKey: "currentScene")
}
接下来,将MainScene
类中的didBeginContactMethod(_:)
的实现替换为以下内容:
func didBeginContact(contact: SKPhysicsContact) {
if contact.bodyA.node == player || contact.bodyB.node == player {
if let explosionPath = NSBundle.mainBundle().pathForResource("Explosion", ofType: "sks"),
let smokePath = NSBundle.mainBundle().pathForResource("Smoke", ofType: "sks"),
let explosion = NSKeyedUnarchiver.unarchiveObjectWithFile(explosionPath) as? SKEmitterNode,
let smoke = NSKeyedUnarchiver.unarchiveObjectWithFile(smokePath) as? SKEmitterNode {
player.removeAllActions()
player.hidden = true
player.physicsBody?.categoryBitMask = 0
camera?.removeAllActions()
explosion.position = player.position
smoke.position = player.position
addChild(smoke)
addChild(explosion)
saveScene()
}
}
}
对该方法所做的第一个更改是编辑播放器节点的categoryBitMask
而不是将其完全从场景中删除。 这确保了在重新加载场景时,即使播放器节点不可见,该播放器节点仍在该位置,但是不会检测到重复的碰撞。 一旦运行了自定义爆炸逻辑,所做的另saveScene()
更改是调用我们先前定义的saveScene()
方法。
最后,打开ViewController.swift并将viewDidLoad()
方法替换为以下实现:
override func viewDidLoad() {
super.viewDidLoad()
let skView = SKView(frame: view.frame)
var scene: MainScene?
if let savedSceneData = NSUserDefaults.standardUserDefaults().objectForKey("currentScene") as? NSData,
let savedScene = NSKeyedUnarchiver.unarchiveObjectWithData(savedSceneData) as? MainScene {
scene = savedScene
} else if let url = NSBundle.mainBundle().URLForResource("MainScene", withExtension: "sks"),
let newSceneData = NSData(contentsOfURL: url),
let newScene = NSKeyedUnarchiver.unarchiveObjectWithData(newSceneData) as? MainScene {
scene = newScene
}
skView.presentScene(scene)
view.insertSubview(skView, atIndex: 0)
let left = LeftLane(player: scene!.player)
let middle = MiddleLane(player: scene!.player)
let right = RightLane(player: scene!.player)
stateMachine = LaneStateMachine(states: [left, middle, right])
stateMachine?.enterState(MiddleLane)
}
加载场景时,我们首先检查标准NSUserDefaults
是否存在保存的数据。 如果是这样,我们将检索该数据并使用NSKeyedUnarchiver
类重新创建MainScene
对象。 如果没有,我们将获得在Xcode中创建的场景文件的URL,并以类似的方式从中加载数据。
运行您的应用程序,并遇到汽车障碍。 在此阶段,您看不到任何区别。 不过,再次运行您的应用程序,您应该会看到场景已经恢复到刚撞车时的状态。
3.动画循环
在渲染游戏的每一帧之前,SpriteKit都会以特定顺序执行一系列处理。 这组过程称为动画循环 。 这些过程考虑了添加到场景中的动作,物理特性和约束。
如果出于某种原因需要在这些进程之间运行自定义代码,则可以覆盖SKScene
子类中的某些特定方法,也可以指定符合SKSceneDelegate
协议的委托。 请注意,如果您将委托分配给场景,则不会调用以下方法的类的实现。
动画循环过程如下:
第1步
场景调用其update(_:)
方法。 此方法具有单个NSTimeInterval
参数,该参数为您提供当前系统时间。 此时间间隔非常有用,因为它允许您计算前一帧渲染所花费的时间。
如果该值大于1/60秒,则您的游戏将无法以SpriteKit所希望的每秒60帧(FPS)的流畅速度运行。 这意味着您可能需要更改场景的某些方面(例如,粒子,节点数)以降低其复杂性。
第2步
场景将执行并计算已添加到节点的动作,并相应地放置它们。
第三步
场景调用其didEvaluateActions()
方法。 在这里,您可以在SpriteKit继续进行动画循环之前执行任何自定义逻辑。
第4步
场景将执行其物理模拟,并相应地更改场景。
第5步
场景调用其didSimulatePhysics()
方法,您可以使用didEvaluateActions()
方法覆盖该方法。
第6步
场景将应用添加到节点的约束。
步骤7
场景调用其didApplyConstraints()
方法,您可以重写该方法。
步骤8
场景调用其didFinishUpdate()
方法,您也可以覆盖该方法。 这是最终的方法,可以在最终确定该帧的外观之前更改场景。
步骤9
最后,场景将渲染其内容并相应地更新其包含的SKView
。
重要的是要注意,如果使用SKSceneDelegate
对象而不是自定义子类,则每个方法都会获得一个额外的参数并对其名称稍作更改。 这个额外的参数是一个SKScene
对象,它使您可以确定与该方法有关的场景。 SKSceneDelegate
协议定义的方法命名如下:
-
update(_:forScene:)
-
didEvaluateActionsForScene(_:)
-
didSimulatePhysicsForScene(_:)
-
didApplyConstraintsForScene(_:)
-
didFinishUpdateForScene(_:)
即使您不使用这些方法对场景进行任何更改,它们对于调试仍然非常有用。 如果您的游戏始终滞后并且帧速率在游戏中的特定时刻下降,则可以覆盖上述方法的任意组合,并找出每个被调用方法之间的时间间隔。 这样,您就可以准确地找到对于您的游戏而言,以60 FPS运行时过于复杂的行为,物理,约束或图形。
4.绩效最佳实践
批处理图
渲染场景时,默认情况下,SpriteKit运行场景的children
级数组中的节点,并按照与数组中相同的顺序将其绘制到屏幕上。 对于特定节点可能具有的任何子节点,也会重复此过程并将其循环。
单独枚举子节点意味着SpriteKit对每个节点执行一次绘图调用。 虽然对于简单场景,此渲染方法不会显着影响性能,但随着场景获得更多节点,此过程将变得效率很低。
为了提高渲染效率,您可以将场景中的节点组织到不同的图层中。 这是通过SKNode
类的zPosition
属性完成的。 节点的zPosition
越高,它与屏幕的距离就“越近”,这意味着它被呈现在场景中其他节点的顶部。 同样,场景中zPosition
最低的节点出现在最“后”,并且可以与任何其他节点重叠。
将节点组织到层中之后,可以将SKView
对象的ignoreSiblingOrder
属性设置为true
。 这导致SpriteKit使用zPosition
值来渲染场景,而不是children
数组的顺序。 由于将具有相同zPosition
任何节点批处理到一个绘制调用中,而不是每个节点都具有一个,因此此过程效率更高。
重要的是要注意,如果需要,节点的zPosition
值可以为负。 场景中的节点zPosition
递增的顺序进行渲染。
避免自定义动画
SKAction
和SKConstraint
类都包含大量规则,您可以将它们添加到场景中以创建动画。 作为SpriteKit框架的一部分,它们已尽可能进行了优化,并且与SpriteKit的动画循环完美契合。
提供给您的各种各样的动作和约束允许您几乎想要任何可能的动画。 由于这些原因,建议您始终在场景中利用动作和约束来创建动画,而不要在代码中的其他位置执行任何自定义逻辑。
在某些情况下,尤其是如果您需要为相当大的一组节点设置动画时,物理力场甚至还可以产生所需的结果。 力场与SpriteKit的其余物理模拟一起计算时,效率更高。
位掩码
通过仅对场景中的节点使用适当的位掩码,可以进一步优化场景。 除了对于物理碰撞检测至关重要之外,位掩码还可以确定常规的物理模拟和光照如何影响场景中的节点。
对于场景中的任何一对节点,无论它们是否会发生冲突,SpriteKit都会监视它们彼此之间的相对位置。 这意味着,如果保留了启用所有位的默认蒙版,则SpriteKit会跟踪与每个其他节点相比,场景中每个节点的位置。 通过定义适当的位掩码,可以大大简化SpriteKit的物理模拟,从而仅跟踪可能发生冲突的节点之间的关系。
同样,SpriteKit中的灯光仅在其类别位掩码的逻辑AND
为非零值时才会影响节点。 通过编辑这些类别,以使场景中只有最重要的节点才受特定光源的影响,因此可以大大降低场景的复杂性。
结论
现在,您应该知道如何使用更高级的技术(例如纹理图集,批处理图和优化的位掩码)进一步优化SpriteKit游戏。 您还应该对保存和加载场景感到满意,从而为您的播放器提供更好的整体体验。
在整个系列中,我们研究了iOS,tvOS和OS X中SpriteKit框架的许多特性和功能。除了本系列之外,还有其他高级主题,例如自定义OpenGL ES和Metal着色器作为物理领域和关节。
如果您想了解有关这些主题的更多信息,建议您从SpriteKit Framework Reference开始并阅读相关的类。
与往常一样,请务必在下面的评论中留下您的评论和反馈。