原文:How To Make a Breakout Game with SpriteKit and Swift: Part 1
作者:Michael Briscoe
译者:kmyhy更新说明:本教程由 Michael Briscoe升级为 Xcode 8 和 Swift 3。原文作者是 Barbara Reichart。
Sprite Kit 是苹果的iOS 和 OS X 游戏开发框架。它不仅拥有强大的绘图能力,也拥有一个易于使用的物理引擎。更好的是,你可以用最熟悉的工具:Swift、Xcode 和 IB 进行开发!
你可以用 Sprite Kit 做许多事,学习它的一个最好方法就是用它编写一个简单游戏。
在这个两部分的教程中,你将学习如何创建一个逃逸游戏,Sprite kit 的使用,碰撞检测,用物理引擎弹动小球,通过触摸控制木板,以及游戏状态。
如果你没接触过 Sprite kit,你可以在继续本教程之前阅读一下这篇 Sprite Kit Swift 初学者教程。
开始
首先下载开始项目。这个项目用标准的 Xcode game 模板创建。为了节省时间,assets 和 state 类已经被导入了。后面来学习游戏状态。
首先来看一眼项目。Build& run,你会看到:
Sprite Kit 可视化编辑器
我们首先来配置场景文件。打开 GameScene.sks 文件。这个 Sprite Kit 场景会在一个可视化比机器中打开,每个元素都陈列在其中,你可以在游戏中和 GameScene.swift 中访问它们。
首先你要修改场景大小,让它和本教程的目标屏幕一致:iPhone 6 的屏幕。你可以用 Xcode 右边的属性面板的 Scene 一栏来干这件事。如果找不到属性面板,你可以通过 View/Utilities/Show Attributes Inspector 来打开属性面板。将场景的 size 设置为 568x320。
注意:如果你的 assets 库中包含了多种比例系数的图片(比如 1x、2x、3x),请将场景大小设置为 1x。Sprite Kit 会自动在运行设备上适配正确的图片。
然后是游戏背景。如下图所示,从 Object Library 中拖一个 Color Sprite 到 Xcode 窗口的右下角。如果你看不见 Object Library,请点击 View/Utilities/Show Object Library菜单。
在属性面板,将 Position 设置为 284,160,Texture 设置为 bg。
Build & run,你会看到:
拥有了一个漂亮的横屏背景后,我们来添加一支小球!还是在 GameScene.sks,拖一个 Color Sprite 到场景中。将它命名为 ball,Texture 设置为 ball,Position 设置为 284,220,Z Position 设为 2,确保小球显示在背景的上层。
Build & run,你会看到:
当然,它不会动。因为你需要为它添加物理引擎。
物理引擎
在 Sprite Kit 中你有两种环境:图形世界,也就是你在屏幕上看到的;以及物理世界,它决定了对象如何移动和交互。
首先,你需要用 Sprite Kit 的物理引擎去根据游戏逻辑改变世界。世界对象在 Sprite Kit 中是管理所有对象和进行模拟物理的主要对象。它还生成了能够施加到所有物体上的重力。默认重力为 -9.81,和地球的一样。因此,当你添加一个物体时,它会往下掉。
配置好世界对象,你可以在它里面添加根据物理规则进行交互的对象。最常见的方式是创建一个 sprite(图片)然后设置它的物理体。body 属性和世界对象决定它如何移动。
Body 可以是动态物体(球、忍者镖、小鸟……),能够移动,受物理力学影响,另外就是静态物体(平台、墙……),这些不受外力影响的东西。当你创建好 Body 之后,你可以设置大量的属性比如形状、密度、摩擦系数等等。这些属性严重影响了物体在世界中的行为。
在定义物体时,你可能奇怪它们的大小和密度单位是什么。在 Sprite Kit 内部使用米制系统(SI 单位)。但是在你自己的游戏中,你通常不关心真实的力和质量,只要始终一致就好。
将所有的物体添加到世界中后,Sprite Kit 会自动进行模拟。
来创建你的第一个物理体,选中刚刚添加的 ball 节点,在属性面板中,找到 Physics Definition 处。选择 Body Type 为 Bounding Circle,然后:
- 反选 Allows Rotation(允许旋转)
- Friction (摩擦系数)设为 0
- Restitution (恢复系数)设为 1
- Linear Damping (线性阻尼)设为 0
- Angular Damping (角补偿)设为 0
这里,你创建了一个立方体类型的物理体,是一个圆,大小和 ball 贴图相同。这个物理体受外力和冲量影响,能够和其它物理体发生碰撞。
它有这些属性:
- Allow Rotation 正如其名。它允许物体旋转或不旋转。这里,你不需要球体旋转。
- Friction 也简单——这里简单地设置为没有摩擦。
- Restitution 它就是对象的弹性。你设置为 1,表示球体和其它物体碰撞后,将表现为绝对弹性。简单说,球体会以相同的力弹回去。
- Linear Damping 模拟了液体或空气对物体线性速度的递减程度。在 Breakout 游戏中,球体在移动过程中速度不会衰减,因此设置为 0。
- Angular Damping 和 Linear Damping 一样,但针对的是角速度。这个设置不是必须的,因为你根本不允许旋转。
注意:通常,最好让物理体和玩家看到的非常接近。对于球体,这是很容易模拟的。但是,越复杂的形状你就越要花心思,因为复杂物体确实比较耗性能。从 iOS 8 和 Xcode 6 开始,Sprite Kit 支持
alpha 遮罩物体类型,自动把 Sprite 的形状作为物理体的形状,但需要注意的是,这会降低性能。
Build & run。如果你眼睛够尖,你会发现小球向下掉,消失在屏幕底部以外。
这是两个原因导致的:首先,场景中默认的重力加速度是模拟地球的重力加速度—— x 轴上是 0,y 轴上是 -9.8。其次,你的场景的物理世界没有边界,边界充当了束缚小球的笼子的角色。让我们来解决它。
困住小球
打开 GameScene.swift,在 didMove(to:) 方法最后添加代码,在屏幕四周创建一个看不见的樊篱:
// 1
let borderBody = SKPhysicsBody(edgeLoopFrom: self.frame)
// 2
borderBody.friction = 0
// 3
self.physicsBody = borderBody
- 创建了一个边界物体。和球体的立方体类型的物体不同,边界物体没有体积和质量,不受外力或冲量的影响。
- Friction 设置为 0,这样球体与之碰撞后不会速度衰减。相反,你需要的是绝对反弹,这样会以击中时相同的角度离开。
- 你可以为每个节点设置物理体。这里,你将边界物体赋给场景。注意:SKPhysicsBody 的坐标是相对于节点位置的。
运行项目,你会看到球体和之前一样下落,但现在会在“笼子”的下端弹回。因为我们将笼子和环境中的摩擦力去掉了,同时将恢复设置为完美弹性体,小球会无限地反弹下去。
小球还没有完成,我们先移除重力,然后给它施加一个力,让它在屏幕上没完没了地反弹。
@H_403_130@无尽的反弹是时候让小球滚动了(也就是反弹)。在 GameScene.swift 的 didMove(to:) 方法中,在之前的代码后添加:
physicsWorld.gravity = CGVector(dx: 0.0,dy: 0.0)
let ball = childNode(withName: BallCategoryName) as! SKSpriteNode
ball.physicsBody!.applyImpulse(CGVector(dx: 2.0,dy: -2.0))
首先从场景中去掉重力加速度,然后从场景的子节点中通过名字(这个名字是你可以通过可视化编辑器进行设置)来获取小球,然后施加一个力。这个力将使物理体沿给定方向运动(在这里,即右下 45 度方向)。当小球开始运动后,因为你添加的“笼子”它只能在屏幕上来回反弹了。
来试一下吧!Build & run,你会看到不断弹来弹去的小球!
添加木板
没有木板就不是逃逸游戏了,现在来添加它吧!
回到 GameScene.swift,用构建小球的方式在可视化编辑器中构建木板(以及它的物理体)——拖入一个 Color Sprite 在屏幕底部正中位置,然后设置它是属性:
- Name = paddle
- Texture = paddle.png
- Position = 284,30
- Z Position = 3
- Body Type > Bounding rectangle
- 反选 Dynamic
- Friction: 0
- Restitution: 1
这些属性大部分和小球类似。只不过这次的物理体是 Bounding rectangle,因为它和长方形的板子更匹配。
这里反选了 Dynamic,这样木板就变成静态物体了。这使得木板不会受力和冲量的影响。待会你就会明白这一点很关键。
Build & run,你会在场景中看到木板,小球会在它上面反弹(如果你有耐心的话):
干得不错——接下来让玩家可以移动木板!
移动木板
开始移动!移动木板需要检测触摸。你可以在 GameScene 中通过实现下列 touch 回调方法来实现检测触摸:
override func touchesBegan(_ touches: Set<UITouch>,with event: UIEvent?)
override func touchesMoved(_ touches: Set<UITouch>,with event: UIEvent?)
override func touchesEnded(_ touches: Set<UITouch>,with event: UIEvent?)
但在此之前你需要添加一个属性。打开 GameScene.swift,添加下列属性:
var isFingerOnPaddle = false
这个属性用于保存玩家是否触摸到了木板上。你在实现模板的拖拽时需要用到它。
现在在 GameScene.swift 中实现 touchesBegan(_:with:) 方法:
override func touchesBegan(_ touches: Set<UITouch>,with event: UIEvent?) {
let touch = touches.first
let touchLocation = touch!.location(in: self)
if let body = physicsWorld.body(at: touchLocation) {
if body.node!.name == PaddleCategoryName {
print("Began touch on paddle")
isFingerOnPaddle = true
}
}
}
这个方法获取了触摸,并找到它在屏幕上的位置。然后用 body(at:) 方法查找该位置是否存在一个物理体。然后,判断触摸位置是否有节点,如果有,这个节点是不是就是我们要找的木板。早先我们已经设置过这个对象的名字——你可以通过 name 属性判断对象是否是我们想要找的对象。如果触摸位置是木板,输出文字消息,然后将 isFingerOnPaddle 方法设置为 true。
Build & run。当你点击木板,你会看到控制台中输出了消息。
现在,来实现 touchesMoved(_:with:) 方法:
override func touchesMoved(_ touches: Set<UITouch>,with event: UIEvent?) {
// 1
if isFingerOnPaddle {
// 2
let touch = touches.first
let touchLocation = touch!.location(in: self)
let prevIoUsLocation = touch!.prevIoUsLocation(in: self)
// 3
let paddle = childNode(withName: PaddleCategoryName) as! SKSpriteNode
// 4
var paddleX = 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)
}
}
- 判断玩家是否已经触摸到了木板。
- 如果是,根据玩家手指的移动更新木板的位置。这需要获取当前触摸位置和上一次的触摸位置。
- 获取木板对应的 SKSpriteNode。
- 用木板当前位置加上两次触摸位置之差。
- 在移动木板之前,限制它的位置,防止它移出屏幕左右两边。
- 根据之前计算的结果,设置木板的位置。
还剩一件事情,就是在 touchesEnded(_:with:) 方法中进行一些清理工作:
override func touchesEnded(_ touches: Set<UITouch>,with event: UIEvent?) { isFingerOnPaddle = false }
这里讲 isFingerOnPaddle 设为 false。这确保了玩家从屏幕上拿走他们的手指,然后又再次点击木板时,木板不会跳到之前触摸的位置。
太棒了!Build & run,小球在屏幕上跳动,你可以用木板来控制它的移动了。
哟呵——这太好玩啦!
制造碰撞
现在,你已经拥有了一只会活蹦乱跳的小球以及一块可以用手指移动的木板,虽然这很有趣,但你需要让玩家在游戏中体会输与赢。当小球碰到屏幕底部而不是木板时,游戏就算输掉了。但如何用 Sprite Kit 来判断呢?
Sprite Kit 可以检测两个物理体之间的碰撞。但是,为了让它能够正常工作,你需要执行几个步骤才行。简单罗列如下。每个步骤的具体做法后面再论:
设置物理体的位掩码:在游戏中,你可以有不同类型的物理体——例如,玩家、敌人、子弹、奖励等等。要唯一识别这些不同类型的物体,每个物体都要设置多个位掩码。包括:
- categoryBitMask: 这个位掩码用于识别物体属于哪一类别。你可以用它定义物体能够和其它物体进行交互。在游戏中,你可以最多设置 32 个自定义 category 掩码。对大部分游戏而言是足够的,每种物体都可以创建一个单独的 category。对于更复杂的游戏,每个物体可以同时属于多个 category。因此通过一些巧妙的设计,使你突破 32 种 category 的限制。
- contactTestBitMask: 设置这个位掩码会让 Sprite Kit 在物体和其它物体(指定某个 category 的)发生碰撞时通知委托对象。默认,所有的位都是空的——发生碰撞时不会发送通知。为了性能起见,你只需要在 contact 掩码中设置真正感兴趣的部分。
- collisionBitMask: 指定那些物体能够和该物体发生碰撞。例如你可以用这个掩码来减少超大物体和超小物体的碰撞,因为这种碰撞对于大物体来说影响微乎其微。你还可以用它来允许两个物体相互穿过。
设置并实现 contact 委托:SKPhysicsWorld 有一个 contactDelegate 属性。当两个物体(contactTestBitMasks 匹配)发生碰撞或结束碰撞时,这个委托对象会被通知。
注意:位掩码?如果你从来没有用过位掩码,别担心!它们初一看很复杂,但真的很好用。
那么什么是位掩码?一个位掩码是一个多位二进制数。比如:1011 1000。没你想那么复杂。
但为什么说它们很有用?这是因为它们允许你从一个二进制数中获取状态信息,以及让你将二进制数中的指定位设置为指定状态。你可以用位运算符 AND 和 OR:它允许你以非常紧凑的方式在单个变量中保存大量信息,但同时还能访问和操作这些信息。
3、2、1,碰:编写代码
首先为不同的 category 创建常量。在 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
定义了 5 个 category。首先将最后一位设为 1 其它位设置为 0。然后用左移操作符将这个位向左移。最终,每个 category 常量只有 1 位是 1,同时这个 1 的位置在二进制数中和其它 category 的 1 的位置区分开来。
目前,你只用得到屏幕底部的 category 和小球的 category,但随着游戏的编写,你会逐渐用到其它 category。
创建完常量,再创建一个横穿屏幕底部的一个物理体。请独自完成这个任务,在创建屏幕四边的“笼子”时,你已经学习了创建的方法。
参考答案
在 GameScene.swift 的 didMove(to:) 方法中添加:
let bottomRect = CGRect(x: frame.origin.x,y: frame.origin.y,width: frame.size.width,height: 1) let bottom = SKNode() bottom.physicsBody = SKPhysicsBody(edgeLoopFrom: bottomRect) addChild(bottom)
现在,重点来了。首先创建 categoryBitMasks。在 didMove(to:)方法中设置游戏对象的 categoryBitMaskds:
let paddle = childNode(withName: PaddleCategoryName) as! SKSpriteNode
bottom.physicsBody!.categoryBitMask = BottomCategory
ball.physicsBody!.categoryBitMask = BallCategory
paddle.physicsBody!.categoryBitMask = PaddleCategory
borderBody.categoryBitMask = BorderCategory
这段代码简单地为之前创建的物理体设置了 categoryBitMask。
然后,同样在 didMove(to:) 方法中设置 contactTestBitMask:
ball.physicsBody!.contactTestBitMask = BottomCategory
然后,将 GameScene 设置为所有物理碰撞的委托。
将这句:
class GameScene: SKScene {
修改为:
class GameScene: SKScene,SKPhysicsContactDelegate {
这正式声明:GameScene 是一个 SKPhysicsContactDelegate (因为它实现了这个协议),它会接收所有指定的物体的碰撞通知。好哒!
现在你需要将 GameScene 设置为 physicsWolrd 的委托。在 didMove(to:) 方法 physicsWorld.gravity = CGVector(dx: 0.0,dy: 0.0) 一句后添加:
physicsWorld.contactDelegate = self
追后,你需要实现 didBegin(_:) 方法来处理碰撞事件。在 GameScene.swift 中添加方法:
func didBegin(_ 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.")
}
}
- 创建两个变量,用于保存发生碰撞的两个物体。
- 检查两个物体哪一个的 categoryBitMask 更小。然后将它们分别存入两个本地变量,这样,categoryBitMask 值小的一个物体总是保存到 firstBody 变量。这会节省一些工作,在处理两个指定 category 的碰撞时。
- 因为之前做过排序,你只需要检查 firstBody 是否是 BallCategory,secondBody 是否是 BottomCategory 就足以知道小球是否和屏幕底部碰上了,因为你已经知道如果 firstBody 是 BottomCategory 的话, secondBody 不可能是 BallCategory(因为 BottomCategory 的值比 BallCategory 大)。目前的处理仅仅是输出一个日志信息。
来试一下你的代码。Build & run,如果你没犯任何错误的话,每当小球没有碰到木板而碰到屏幕底部时,你会看到控制台中输出日志。
OK! 现在最难的工作已经完成——剩下的工作就是添加砖块和游戏逻辑,这将在第二部分进行介绍。
结束
从这里下载完成到这一步骤的示例项目。
如果你有任何问题和评论,请在下面留言!