一、 简介
SpriteKit是苹果公司推出的跑在iOS和OS X上的游戏开发框架。这个工具不仅提供了强有力的图形功能,而且还包括一个易于使用的物理引擎。最重要的是,你可以使用你熟悉的工具 ——Swift,Xcode和Interface Builder完成所有的工作!你可以用SpriteKit做很多的事情;但是,想了解它是如何工作的最佳方法就是使用它开发一个简单的游戏。
在本系列教程(两部分)中,你将要学习如何使用SpriteKit来开发一款Breakout游戏。其中,加入了完整的碰撞检测技术,使用物理效果控制小球弹跳,通过触摸来拖动挡板,游戏状态控制等。
二、 开始
作为初始准备,建议你一定要先下载本教程对应的初始项目。此项目是使用标准的Xcode游戏模板创建的。所有的资源和状态类都已经被导入到项目中,这样可以节省您的一点时间。随着进一步阅读,你会了解更多的游戏状态。
你不妨先花点时间来熟悉一下整个项目。为此运行命令“Build”和”Run“,你会看到一个横向模式的灰色屏幕。请参考下图。
三、 Sprite Kit Visual Editor简介
让我们从配置场景文件开始工作吧。为此,请打开GameScene.sks文件。这是一个已链接到你的Sprite Kit场景的可视化编辑器,你可以从游戏的 GameScene.swift文件中访问其中已有的每一个元素。
首先,你将调整场景的大小,使其适合您在本教程中介绍的目标屏幕:一个iPhone 6屏幕。为此,你可以在位于Xcode窗口右上角的Attributes inspector的Scene部分完成这一操作。如果你看不到Attributes inspector,您可以通过 View\Utilities\Show Attributes inspector来访问它。请将场景的大小设置为 568 × 320,如下面的屏幕快照所示。
[注意]如果您的资源库中包含了为适应多屏幕缩放因子(即 1 x,2x,3 x等)准备的图片等资源的话,Sprite Kit将自动在当前运行的设备使用正确的资源文件。
现在,我们来考虑游戏的背景。如下面的屏幕快照所示,从Xcode窗口的右下角的对象库面板上拖出一个Color Sprite。如果你看不到对象库面板,那么可以从菜单栏选择View\Utilities\Show Object Library。
请使用属性检查器将位置更改为284,160,把它的纹理设置为bg。
现在,你可以生成并运行一下游戏工程,欣赏一下你的游戏的背景显示情况。
一旦你建立了拥有背景的横屏场景,那么接下来就可以往其中添加小球了!仍然在GameScene.sks文件中,将一个新的Color Sprite拖动到场景中。然后,把它的名称改为ball,纹理设置为ball,位置修改为284,220。然后,再把它的Z位置属性值也设置为2,以确保小球出现在背景上。
现在,生成和运行你的项目,你会看到小球已经出现在屏幕上,如图所示。
然而,到目前为止,我们的游戏还不能动起来,这是因为我们还没有添加物理部分呢。
四、 物理引擎
在Sprite Kit中,你需要工作在两种环境中:你在屏幕上看到的图形世界和物理世界,这决定了对象的移动和交互的方式。
在使用Sprite Kit物理引擎时,你需要做的第一件事是根据你的游戏的需要改变世界。世界对象是在使用Sprite Kit时管理所有的对象和物理模拟的主要对象。它还设置了物理机构加入到世界对象中需要的重力属性。默认的重力值是-9.81,因此类似于地球的实际重力值。所以,只要你在世界中添加一个物体,它就会往下落。
一旦配置了世界对象,你就可以往此世界对象中添加根据物理原则与它进行交互的东西。为此,最通常的方法是创建一个精灵 (图形) 并设置它的物理body。物体的body属性与世界决定了物体的移动方式。
Body可以是受物理力量影响的动态对象(如球,星星,小鸟......),或者是不受物理力量影响的静态对象(平台、墙......)。当创建一个Body时,你可以设置各种属性,如形状、密度、摩擦力,等等。这些属性将严重影响Body在世界范围内的行为。
当定义一个Body时,你可能会担心其大小和密度的单位。在内部,Sprite Kit使用公制(SI单位)。但是,在你的游戏中你通常不需要担心实际的力量和质量,只要你使用一致的值就行。
一旦你在世界中添加了所有的Body,Sprite Kit就会接管过控制权,并进行仿真。
为了设置第一个物理Body,你需要选择刚刚添加的小球节点并从属性检查器的Physics Definition一节中选择Body Type下的Bounding Circle并设置以下属性值︰
- 取消勾选“Allows Rotation”
- 设置Friction为0
- 设置Restitution为1
- 将linear Damping(线性阻尼)设置为0
- 设置角阻尼(Angular Damping)为0
给小球添加物理特性
在这里,你创建了一个基于体积的物理Body,其形式为圆圈,具有与小球精灵完全相同的尺寸。这个物理Body受外力或冲动的影响,并能够与其他物体发生碰撞。
下面具体介绍一下它的属性。
- Allows Rotation:指定是否允许旋转。在本例中,你不希望小球旋转。
- Friction:这个属性也很简单,在我们的例子中要除去所有的摩擦。
- Restitution:是指对象的弹力。其值设置为1意味着,当小球与物体碰撞时将保持原来完整的弹性。简言之,这意味着:小球会以与最初同等的作用力弹回来。
- Linear Damping(线性阻尼):通过减少物体的线性速度来模拟流体或空气摩擦。在本例游戏中,小球移动时不应该减速。所以,在上面你需要设置阻尼为0。
- Angular Damping(角阻尼):除了角速度外,它与线性阻尼是相同的。当你不允许球旋转时将此值设置为可选的。
[注意]通常情况下,最好是让物理Body与玩家看到的极其相似。对于小球来说,我们已经做到完美的匹配。然而,当你需要使用更复杂的形状时,要格外小心,因为很复杂的Body意味着高性能的系统资源消耗。自从IOS 8和Xcode 6以来,Sprite Kit支持alpha蒙版Body类型(alpha masks body types),这将自动地把精灵的形状用作其物理Body的形状,但仍然要小心使用,因为这也可以降低系统性能。
现在,我们再一遍生成并运行工程。如果你反应足够迅速,你应该看到小球从场景中落下,最后消失在屏幕的底部,如图所示。
这种现象的出现存在两个原因︰首先,场景的默认重力模拟了地球重力——沿x轴方向值为0而沿y轴方向是-9.8。第二,你的场景的物理世界是没有界限的,尚无法用作封闭小球的笼子。现在,让我们着手解决这个问题!
五、 把小球关起来
把小球关起来的效果
现在,请打开文件GameScene.swift并把以下代码行添加到didMoveToView(_:)方法的最后,用来创建一种围绕屏幕的无形的障碍︰
- // 1
- let borderBody = SKPhysicsBody(edgeLoopFromRect: self.frame)
- // 2
- borderBody.friction = 0
- // 3
- self.physicsBody = borderBody
让我们分析一下这行代码︰
(1)创建一个基于边缘(edge-based)的Body。与你添加到小球上的基于体积(volume-based)的Body相比,基于边缘的Body并没有质量或体积,并且不受外力或冲量的影响。
(2)我们把摩擦力设置为0,这样,小球与边界障碍发生碰撞时其运动就不会减慢。相反,你想产生一种完美的效果,此时小球沿着它撞击的屏障以相同的角度离开。
(3)你可以设置为每个节点设置一个物理Body。然后,将它添加到场景中。注︰SKPhysicsBody的坐标是相对于节点位置的。
再次运行你的项目,你现在应该看到像以前一样的小球下落情景,但是现在当它降落到笼子的底部边缘时它会回弹回来。因为你从与笼子和环境的接触(Contact)中去除了摩擦力,并且设置小球的变形为完全弹性形变,因此,小球会永远地那样反弹运动下去。
为了让小球运动效果更为圆满,让我们删除重力并施加单脉冲,这样它就会沿着屏幕永远弹起弹落并运动下去。
六、 永久性弹性运动
现在,我们让小球滚动起来(实际上还是弹起)。在文件GameScene.swift中在紧邻上面添加的代码行的后面添加以下代码:
- physicsWorld.gravity = CGVector(dx: 0.0, dy: 0.0)
- let ball = childNodeWithName(BallCategoryName) as! SKSpriteNode
- ball.physicsBody!.applyImpulse(CGVector(dx: 2.0, dy: -2.0))
这段新代码首先从场景中删除所有的重力,然后从场景的子节点上检索到小球并应用脉冲效果。脉冲能够把一种立即生效的力施加到物理Body上,从而让它朝着一个特定的方向(在本例中,沿对角线方向向右)运动。一旦小球设置为运动,它会简单地在屏幕上反弹起来,这是因为您刚添加了屏障部分!
现在,是时候再去试试了!当你编译并运行该项目时,您应该看到一个小球不断跳跃在屏幕上——酷极了!
七、 添加挡板
如果没有挡板,则不能称其为一款打竹块游戏,是不是?
现在,打开GameScene.sks文件来使用Visual Editor生成挡板(还有它的同伴物理Body),方式差不多就像你在场景的底部中间位置放置一个Color Sprite一样,然后设置下列属性值:
- Name = paddle
- Texture = paddle.png
- Position = 284,30
- Z Position = 3
- Body Type > Bounding rectangle
- 取消勾选Dynamic
- Friction: 0
- Restitution: 1
显然,这里大部分的选项与前面创建小球时使用的选项是类似的。然而,这一次你使用了Bounding rectangle来形成物理Body,因而它将更好地匹配矩形的挡板。
这里通过关闭Dynamic选项,设置挡板是静态的。这将确保挡板不会受外力和冲量的影响。你很快会看到为什么这很重要。
如果现在你生成和运行一下项目,你会看到挡板出现在场景中,而小球在碰到挡板时会弹起(如果你能等待一段足够长时间的话)。请参考下图。
到目前为止,一切比较顺利!接下来,我们要使玩家能够移动挡板。
八、 移动挡板
移动挡板需要检测接触相关信息。为此,我们在GameScene类中执行下面的触摸处理方法︰
- override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?)
- override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?)
- override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?)
但是,在此之前,你还需要添加一个属性。转到GameScene.swift文件,然后向类中添加以下属性︰
- var isFingerOnPaddle = false
这个属性负责存储是否玩家点按了挡板这一信息。你会需要它来执行拖动挡板相关操作。
现在,请在GameScene.swift文件的touchesBegan(_:withEvent:)方法中添加如下代码︰
- override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
- let touch = touches.first
- let touchtouchLocation = touch!.locationInNode(self)
- if let body = physicsWorld.bodyAtPoint(touchLocation) {
- if body.node!.name == PaddleCategoryName {
- print("Began touch on paddle")
- isFingerOnPaddle = true
- }
- }
- }
上面的代码获取触摸信息并使用它来查找场景中触摸位置。下一步,使用 bodyAtPoint(_:)方法查找在该位置与节点(如果有的话)相关联的物理Body。
最后,检查触摸位置是否存在一个节点;如果存在的话,判断该节点是否是挡板。这正是较早时创建对象名称发挥作用的时候——你可以通过检查名称来检查特定对象。如果触摸位置处的对象是挡板,那么会有一条日志消息发送到控制台,同时isFingerOnPaddle被设置为true。
现在,你可以建立并重新运行该项目。当你点击挡板时,您应该看到在控制台中的日志消息。
接下来,添加如下所示代码:
- override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
- // 1
- if isFingerOnPaddle {
- // 2
- let touch = touches.first
- let touchtouchLocation = touch!.locationInNode(self)
- let previousLocation = touch!.previousLocationInNode(self)
- // 3
- let paddle = childNodeWithName(PaddleCategoryName) as! SKSpriteNode
- // 4
- var paddlepaddleX = paddle.position.x + (touchLocation.x - previousLocation.x)
- // 5
- paddleX = max(paddleX, paddle.size.width/2)
- paddleX = min(paddleX, size.width - paddle.size.width/2)
- // 6
- paddle.position = CGPoint(x: paddleX, y: paddle.position.y)
- }
- }
这是挡板运动主要的逻辑所在。
(1)检查是否有玩家在触摸挡板。
(2)如果是,那么你需要更新挡板的位置,当然具体方式要取决于玩家移动手指的方式。要做到这一点,你需要得到当前触摸位置和上一次触摸的位置。
(3)获取挡板的SKSpriteNode。
(4)使用当前位置加上新位置和上一次触摸位置的差来计算挡板x坐标值。
(5)在重新定位挡板前,限定一下其x坐标位置,这样挡板就不会走出屏幕的左右侧。
(6)将挡板的位置设置为你刚刚计算的位置。
有关触摸处理剩下的唯一事情是要做一些清理工作,这是在方法 touchesEnded(_:withEvent:)中实现的,如下所示︰
- override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
- isFingerOnPaddle = false
- }
在这里,你将isFingerOnPaddle属性设置为false。这可以确保,当玩家把他们的手指离开屏幕然后再点按挡板时,挡板不会跳到以前的触摸位置。
完美!现在再次生成和运行项目时,你会发现小球弹跳在屏幕周围,而且你可以使用挡板来影响它的运动了。
现在,已经感觉不错了吧!
九、 接触
到目前为止,你已经实现了基本的小球弹跳和挡板移动来控制小球。虽然这已经是很有趣的,但是,要想使其真正成为一款游戏,你还需要一种方法来控制玩家赢取与输掉一次游戏。当小球触及屏幕的底部而不是挡板时玩家应该失败。但你如何使用Sprite Kit来检测这种情况呢?
Sprite Kit可以检测到两个物理物体之间的接触。然而,为了使其正常工作,您需要按照几个步骤来设置某种方式。在此仅给出简短的描述。稍后会解释每个步骤的更多细节。描述如下:
- 设置物理Body位掩码:在你的游戏中,你可能有几种不同类型的物理Body——例如,你可能拥有玩家、敌人、子弹、奖励项等。为了唯一地标识这些不同类型的物理Body,你需要使用几位掩码来配置每个物理Body。这些掩码包括:
- categoryBitMask:这位掩码标识一个Body隶属的类别。你可以使用类别来定义一个Body与其他Body的互动。CategoryBitMask是一个32位整数,其中每一位代表一个类别。所以,你的游戏中可以使用多达32个自定义类别。这应该足够应对大多数游戏中为每个对象类型设置一个单独类别的要求了。对于更复杂的游戏,请记住,每个Body可以隶属多个类别这一技巧。所以,通过精心设计类别,你甚至可以克服32个类别的局限性。
- contactTestBitMask:在这个掩码中设置位能够实现当一个Body接触到分配给该特定类别的另一个Body时Sprite Kit通知代理。默认情况下,所有的位都被清除掉——任何对象之间的接触都不会通知你。为了获得最佳性能,你应该只设置你真正感兴趣的用于相互作用的接触掩码。
- collisionBitMask:借助这个掩码,您可以定义哪些Body可以与当前物理Body碰撞。例如,你可以使用此技术来避免当一个非常沉重的Body遇到一个比它轻得多的物体时的碰撞计算,因为这只会给沉重的Body的速度带来微不足道的变化影响。但是,你也可以使用它让两个Body穿透对方。
- 设置并实现接触委托(delegate,也有的翻译为“代理”):接触委托实际上是SKPhysicsWorld的一个属性。当两个使用了contactTestBitMasks的Body开始和结束碰撞会通知这个委托。
十、 3,2,1接触算法
首先,我们来创建描述不同类别的常数。为此,只需在GameScene.swift文件中添加下列常数定义:
- let BallCategory : UInt32 = 0x1 << 0
- let BottomCategory : UInt32 = 0x1 << 1
- let BlockCategory : UInt32 = 0x1 << 2
- let PaddleCategory : UInt32 = 0x1 << 3
- let BorderCategory : UInt32 = 0x1 << 4
上面定义了五个类别。这里使用的办法是:将最后一位设置为1,所有其他位设置为零。然后使用<<运算符向左移动这个位。因此,每个类别常数只有一位设置为 1 而且在二进制数中的这个1的位置对于上面四个类别来说都是唯一的。
现在,你只需要上述类别来描述屏幕和小球;但是,你还应当使用其他一些办法来解释游戏运行逻辑。
一旦建立了上面这些常数,现在就可以创建横跨屏幕底部的物理Body了。
[建议]各位读者根据本文前面介绍的原则试着使用自己的方法来解决创建围绕屏幕边缘障碍有关的问题。
现在,我们来讨论创建接触有关编程的核心问题。首先,通过将下面的代码添加到 didMoveToView(_:)方法中为游戏对象设置categoryBitMasks掩码:
- let paddle = childNodeWithName(PaddleCategoryName) as! SKSpriteNode
- bottom.physicsBody!.categoryBitMask = BottomCategory
- ball.physicsBody!.categoryBitMask = BallCategory
- paddle.physicsBody!.categoryBitMask = PaddleCategory
- borderBody.categoryBitMask = BorderCategory
此代码简单地把较早前创建的常数赋值给相应的物理Body的categoryBitMask掩码。
现在,通过添加下面一行代码到didMoveToView(_:)方法中来设置contactTestBitMask掩码:
ball.physicsBody!.contactTestBitMask = BottomCategory
现在,你只想要在小球接触屏幕底部时被通知。
下一步,我们来为所有的物理接触创建GameScene类中的委托。
为此,仅需将下面这一行:
- class GameScene: SKScene {
修改成如下形式:
- class GameScene: SKScene, SKPhysicsContactDelegate {
这就可以了:GameScene的身份现在是SKPhysicsContactDelegate(因为它遵守SKPhysicsContactDelegate协议),它将会接收到所有配置的物理Body的碰撞通知。
现在,您需要将GameScene设置为physicsWorld中的委派。所以,将下面一行代码添加到方法didMoveToView(_:)中,正好位于语句physicsWorld.gravity = CGVector (dx: 0.0,dy: 0.0)的下面:
- physicsWorld.contactDelegate = self
最后,您需要执行didBeginContact(_:)来处理碰撞问题。为此,只需要将以下方法添加到GameScene.swift中:
- func didBeginContact(contact: SKPhysicsContact) {
- // 1
- var firstBody: SKPhysicsBody
- var secondBody: SKPhysicsBody
- // 2
- if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
- firstBody = contact.bodyA
- secondBody = contact.bodyB
- } else {
- firstBody = contact.bodyB
- secondBody = contact.bodyA
- }
- // 3
- if firstBody.categoryBitMask == BallCategory && secondBody.categoryBitMask == BottomCategory {
- print("Hit bottom. First contact has been made.")
- }
- }
让我们分析一下上面的方法:
(1)创建两个本地变量来存放参与碰撞的两个物理Body。
(2)检查这两个碰撞的物理Body来看一下其中哪一个使用了较低的 categoryBitmask掩码。然后,将它们存储到本地变量;这样,对应于较低类别的Body总是存储在firstBody变量中。当分析具体类别之间的接触时这将节省你不少的努力。
(3)得益于以前实现的排序操作,现在你只需要检查是否firstBody属于BallCategory类别以及是否secondBody属于BottomCategory类别,以便弄明白小球已碰到了屏幕底部——正如你已经知道的,如果firstBody属于类别BottomCategory ,则secondBody不可能属于BallCategory类别(因为 BottomCategory 比 BallCategory 有更高的位掩码)。在本示例中,我们仅仅输出一条简单的日志消息。
现在,请再次建立和运行你的游戏。如果一切正常,每当小球错过挡板并点击屏幕底部时你应该看到在控制台中的日志消息。就你下图一样:
还好吧!现在最艰难的部分已经完成了。最后,剩下的就是添加竹块和游戏逻辑,你会在本系列的下篇中了解到这些。