- 原文链接 : Trigonometry for Games – Sprite Kit and Swift Tutorial: Part 1/2
- 原文作者 : Nick Lockwood
- 译文出自 : 开发技术前线 www.devtf.cn
- 译者 : kmyhy
更新 2015/04/20:升级至 Xcode 6.3 和 Swift 1.2
更新说明:这是我们广受欢迎的教程之一的第三个版本——第一个版本是 Cocos2D 的,由 Matthijs Hollemans 缩写,第二个版本由 Tony Dahbura 升级为 Sprite Kit。最终的版本仍然是 Sprite Kit 的,但升级至 iOS 8 和 Swift。
是否一提到数学就让你恐惧?你是否曾经因为数学成绩不好而想放弃游戏开发这个职业?
不要烦恼——数学其实很有趣,而且也很酷——这两篇教程会证明这一点!
有一个诀窍:作为一个开发者,你其实不需要学习多少数学技能。在我们的职业生涯中的绝大部分计算,其实都用最基本的数学技能就足以应付。
对于编写游戏来说,在你的技能中拥有一些数学技能是有用的。不需要你是阿基米德或者艾萨克.牛顿,但需要知道一些三角学以及一些数学常识,你需要做好心理准备。
在本教程中,你需要学习一些重要的三角函数,以及如何在游戏中使用它们。然后,你需要用学到的知识做一些练习,通过 Sprite Kit 开发一个简单的太空射击游戏。
如果你之前从未使用过 Sprite Kit 或其它游戏开发框架也不要担心——本教程中涉及的数学技能对任何游戏引擎都是有效的。你不需要做任何预习,我会一步一步地开始整个教程。
如果你已经具备一些基本的背景知识,本教程将让加深对理解三角数学的理解,让我们开始吧!
注意:本教程中的游戏使用了加速计,因此你应该使用真实的 iOS 设备以及一个开发者账号。
听起来有点拗口,但三角数学(或简称三角学)的简单定义就是与三角形有关的计算(三角学因此而来)。
你也许不知道,游戏基本上是由三角形构成。例如,一个太空飞船游戏,我们需要计算出飞船之间的距离:
假设你知道每张飞船的 x ,y 坐标,如何计算出二者之间的距离?
从两张飞船的中心画一条连线,构造出一个三角形:
因为我们知道每张飞船的 x,y 坐标,因此,我们可以算出新加的两条线的长度。现在,你已经获得三角形两条边的长,通过三角函数,你可以算出对角线的长度——也就是飞船之间的距离。
注意,这个三角形有一个 90 度的角。因此它是直角三角形(或者正三角形,随便你怎么称呼它),这个教程中会针对这种三角形进行特别的处理。
只要在游戏中能够以直角三角形描述的问题——比如两个对象之间的空间关系——我们都可以用三角学函数进行计算。
总之,三角学是用来计算直角三角形边长和角度的数学。它们比你想象的还要有用。
例如,在这个太空飞行游戏中,可能会发生这些事情:
一只飞船向另一只飞船发射激光束
一只飞船向另一只飞船追去
如果敌人的飞船靠得太紧,播放报警声
诸如此类的,你都会用到三角学!
首先介绍一些理论。别担心,我会尽量简短,已让你尽快接触到代码。一个直角三角形有以下几部分组成:
在上图中,三角形中倾斜的那条边被叫做斜边。它总是对着 90 度角(即直角)的那条边,它是三条边中最长的一条边。
另外两条边叫做邻边和对边,对边是对着三角形某个角的那条边,在这个例子里,也就是位于左下角的角。
如果你从另一个角的角度(例如右上角)来看,则邻边和对边恰恰相反。
α 和 β 是直角之外的两个角。你可以随便命名这些角(任何希腊字母),一般我们将第一个角叫做 α 角,另一个角叫做 β 角。同时,邻边和对边是相对于 α 角而言的。
最酷的一件事情是,你只需要知道其中两个变量,你就可以用三角函数 sin、cos 和 tan 算出其它所有的变量。例如,你知道任何一个角的大小和一条边的长度,你就可以算出其它所有角的大小好边长:
你可以把 sin、cos、tan 看成是系数——如果你知道 α 角和一条边的长度,sin、cos 和 tan 则代表了两条边和角度之间的关系的系数。
以 sin 为例,cos 和 tan 函数就像一个”黑盒子“——将几个数字放到盒子中,它就会返回结果。它们是标准库函数,无论哪种编程语言都会有, Swift 也不例外。
注意:三角函数的作用就像是把一个圆投影到直线上,要使用它们并不需要我们去理解函数是怎么实现的。如果你想知道其中细节,可以在许多站点或视频中找到解释,例如这个站点:Math is Fun
我们来举一个例子。假设已知两只飞船之间的 α 角为 45 度,以及斜边长度为 10。
将上述值代入公式:
sin(45) = opposite / 10
进行等式变形,结果为:
opposite = sin(45) * 10
45 度角的 sin 值为 0.707(截取至 3 位小数),于是上式可变为:
opposite = 0.707 * 10 = 7.07
还记得你在高中的时候学过的一个记住这些函数的小窍门吗:SOH-CAH-TOA(SOH表示:sin 是对边比斜边,依次类推),还有一首顺口溜:Some Old Hippy / Caught Another Hippy / Tripping On Acid,有个老嬉皮士,抓住另一个嬉皮士,陷入了迷幻之中(有可能那个嬉皮士就是一个三角学搞多了的数学家:])。
当你知道角度的时候,上面的公式很有用,但这种情况就不行了——你只知道两条边求它们之间的夹角。这就需要用到反三角函数了,即 arc 函数(这跟自动引用计数毫无关系!)。
角度 = arcsin(对边/斜边)
角度 = arccos(邻边/斜边)
角度 = arctan(对边/邻边)
如果 sin(a) = b,则 arcsin(b) = a。在这些反三角函数中,反切函数 arctan 是最实用的,因为它能够帮你找出斜边(即TOA——对边比邻边)。有时候这些函数也被写成 sin-1,cos-1,tan-1,千万别搞错了。
是不是感觉有点老生常谈?很好,因为理论课还没有上完——在你能够进行三角计算之前还有一些东西需要学习。
有时候,你知道了两条边的长,想求取第三边的长(例如本教程一开始的例子,想计算两个飞船之间的距离)。
这就需要用到三角学的勾股定理了。如果你已经彻底忘光了以前学过的数学课,那么这个公式也许会勾起你的记忆:
a2 + b2 = c2
或者用三角学的专用名词来说:
对边2 + 邻边2 = 斜边2
如果你知道两边之长,用上面的公式通过开方能够很容易计算出第三边。在游戏中经常需要这样做,在本教程中你会反复看到。
注意:要想牢牢记住这个公式,有一个很有趣的方式。在 YouTube 中搜索一首“Pythagoras song”的视频吧,很有意思。
最后,来求夹角。如果我们知道一个非直角的角的大小,则很容易得到另一个夹角的大小。在一个三角形中,所有角的大小之和总是 180 度。对于直角三角形,我们知道其中一个角肯定是 90 °,因此剩下两个角就简单了:
alpha + beta + 90 = 180
简化之后变成:
alpha + beta = 90
剩余两个角之和总是 90 °。如果你知道 α 是多少,肯定能算出 β,反之亦然。
所有这些公式你都需要记住!要用哪一个公式,取决于已知条件。通常,要么已知夹角和一条边的边长,要么已知两条边之长。
好了,理论就学到这里。让我们来做些练习。
接下来几节,你会创建一个基本的 Sprite Kit 项目,这个 App 中有一艘太空飞船会在屏幕上根据加速计来移动。这不会涉及任何三角计算,你如果对 Sprite Kit 非常熟悉了,就像下面这个家伙一样:
那么你可以跳过开头的内容,直接进入“开始三角计算”一节!——在那里,我会为你提供一个开始项目。
但如果你喜欢从头开始编写代码,请继续阅读 :]
首先,确保你安装了 Xcode 6.1.1 或以上版本。因为 Swift 是一个崭新的语言,它的每个版本的语法都会何之前的版本有细微的区别。
打开 Xcode,选择 File\New\Project…,选择 iOS\Application\Game 模板。项目命名为 TrigBlaster,语言选择 Swift,游戏技术设置为 SpriteKit,设备类型设置为 iPhone。然后点击 Next:
编译运行程序。如果一切顺利,你将看到:
从这里下载本教程所需资源。这个压缩文件包含了图片和声音。解压缩,将每张图片拖到 Images.xcassets 文件夹,以备创建精灵时用到。你可以删除/替换默认项目中的 Spaceship 精灵,如果你不想用它的话。
现在来添加声音。将 Sounds 文件夹拖进 Xcode 中,确保选中 Create groups 选项。
好,准备工作已经完成——现在让我们来编写代码!
这是一个简单游戏,你只需要在一个文件中完成绝大部分工作:GameScene.swift。现在,这个文件中包含了一大堆你用不到的代码。游戏运行的方向也不正确,我们先来搞定这个。
在项目导航窗口中点击 TrigBlaster ,打开 Target 设置,选中 Target 列表中的 TrigBlaster。打开 General 标签窗口,在 Deployment Info 一栏的 Device Orientation 下,反选所有方向,只勾选 Landscape Right(译者注:原文是 Left,但图中又是 Right,根据后面的内容看应该是 Right):
运行程序, App 将以横屏方向启动。当前 App 打开了一个空的画面,在 GameViewController.swift 的代码中,这个画面是来自于 GameScene.sks 文件。在 GameScene.swift 代码中,添加了一个 Hello World 标签。
将 GameScene.swift 中的代码替换为:
import SpriteKit
class GameScene: SKScene {
override func didMoveToView(view: SKView) {
// set scene size to match view
size = view.bounds.size
backgroundColor = SKColor(red: 94.0/255, green: 63.0/255, blue: 107.0/255, alpha: 1)
}
override func update(currentTime: CFTimeInterval) {
}
}
运行程序,你将看到一个空的、紫颜色的画面:
让我们来干点稍微有趣的事情,将一艘太空飞船添加到画面中。将 GameScene 类修改为:
class GameScene: SKScene {
let playerSprite = SKSpriteNode(imageNamed: "Player")
override func didMoveToView(view: SKView) {
// set scene size to match view
size = view.bounds.size
backgroundColor = SKColor(red: 94.0/255, green: 63.0/255, blue: 107.0/255, alpha: 1)
playerSprite.position = CGPoint(x: size.width - 50, y: 60)
addChild(playerSprite)
}
...
}
这些代码太常见了,如果你以前用过 Sprite Kit 的话。playerSprite 属性用于保存飞船精灵,并将它放到屏幕的右下角。注意,Sprite Kit 的 y 坐标零点位于屏幕最下边,而不是 UIKit 中的屏幕最上边。我们将 y 坐标设置为 60,这样会将它放到屏幕左下角的FPS(帧率)的上方。
注意:FPS 信息是用于调试的,但我们可以隐藏它,如果你不想看到它的话。在你将游戏提交给 App 商店之前,你可以这样做。
运行程序,你将看到:
要让飞船移动,你需要使用 iPhone 的内置加速计。不幸的是,iOS 模拟器无法模拟加速计,因此从现在起,你就需要在真实物理设备上进行开发了。
注意:如果你不知道如何在设备上安装 App,请看另外一个教程,该教程描述了如何获取和安装证书和设备授权文档,已允许 Xcode 将 App 安装到真实的 iPhone 或 iPad 上。虽然不是强制的,但你必须购买一个苹果开发者证书。
要让加速计能够驱动飞船,我们需要将设备向一边倾斜。这就是为什么我们要在项目设置中将设备方向固定为一个横屏方向的原因,因为当你处于激烈战斗中的时候,屏幕突然发生旋转是一件非常悲剧的事情!
加速计的使用非常简单,因为我们可以使用 Core Motion 框架。要获取加速计数据有两种方式:注册一个通知让加速计以某个周期不断地向 App 发送消息并调用回调方法,或者在我们需要数据时主动拉取数据。苹果建议我们不要使用“推”数据的方式除非有必要(比如进行精确测量或导航服务)。因为这种方式会比较耗电。
你的游戏有一个地方非常适合“拉取”加速计数据:update()方法每一帧都会被 Sprite Kit 调用。你可以在这个方法中获取加速计数据,并以此来移动飞船。
首先,在 GameScene.swift 顶部加入一个导入语句:
import CoreMotion
现在,Core Motion 框架会链接到 App,你可以使用它了。
接着,在类的实现中增加如下属性:
var accelerometerX: UIAccelerationValue = 0
var accelerometerY: UIAccelerationValue = 0
let motionManager = CMMotionManager()
我们用这些属性来存储 Core Motion 管理器和加速计的值。你只需要保存 x 的值和 y 值,z 坐标的值在这个游戏中暂时不需要。
然后,新增两个工具方法:
func startMonitoringAcceleration() {
if motionManager.accelerometerAvailable {
motionManager.startAccelerometerUpdates()
NSLog("accelerometer updates on...")
}
}
func stopMonitoringAcceleration() {
if motionManager.accelerometerAvailable && motionManager.accelerometerActive {
motionManager.stopAccelerometerUpdates()
NSLog("accelerometer updates off...")
}
}
start 方法会检测设备上是否具有加速计硬件,如果是,则开始收集数据。stop 方法则用于关闭加速计监听。
激活加速计的较合适的地方是在 didMoveToView() 方法里面。在这个方法的 addChild(playerSprite) 一行后加入:
startMonitoringAcceleration()
而停止加速计的时机是在类的解析函数里面。在类中增加一个方法:
deinit {
stopMonitoringAcceleration()
}
然后,新增这个方法,每当玩家角色位置发生改变时就调用这个方法读取加速计的值:
func updatePlayerAccelerationFromMotionManager() {
if let acceleration = motionManager.accelerometerData?.acceleration {
let FilterFactor = 0.75
accelerometerX = acceleration.x * FilterFactor + accelerometerX * (1 - FilterFactor)
accelerometerY = acceleration.y * FilterFactor + accelerometerY * (1 - FilterFactor)
}
}
这里进行了过滤处理,目的是为了使加速计返回的数据更平滑,卡顿感更少。如果没有数据,motionManager.accelerometerData 属性有可能为 nil,因此要用 ?. 操作符和 if let … 语法访问 acceleration 属性,以确保当加速计数据为空时 if 语句不会被执行。
注意:加速计负责记录当前施加到它身上的加速度。由于重力的作用iPhone 总是处于加速度的作用下(也因此 iPhone 总是知道哪个方向是屏幕的方向),但由于用户是用手拿着 iPhone(手并永远不会完全稳定在一个地方),因此重力会有细微波动。对于这些细微的波动我们不在乎,但比较大的改变就有可能是用户改变了设备的方向。通过一个简单的低通量过滤,我们可以只获取方向改变信息而过滤掉无关的波动。
现在我们已经让设备方向固定为一个,又如何让玩家的飞船移动呢?
基于物理引擎的移动通常是这样实现的:
首先,基于用户输入(在这里就是加速计数据)改变加速度。
然后,将当前加速度加到飞船的当前速度中去。这会让飞船基于加速度的方向进行加速或减速。
最终,用新的速度改变飞船的位置,使其移动。
在此,我们需要感谢一个伟大数学家艾萨克.牛顿,是他发明了这个位移公式!
我们需要将速度和加速度保存到属性中。玩家位置是不需要跟踪的,因为 SKSpriteNode 已经保存了这个值。
注意:实际上,Sprite Kit 也会记录当前速度和加速度,这要用到 SKPhysicsBody 属性。Sprite Kit 的物理引擎会记录精灵所受的力,并自动更新加速度、速度和位置。但如果你要让 Sprite Kit 的物理引擎来进行这些计算,那你就无法学习三角学了。因此在本教程中,你将自己完成这些数学计算。
在这个类中增加如下属性:
var playerAcceleration = CGVector(dx: 0, dy: 0)
var playerVelocity = CGVector(dx: 0, dy: 0)
最好将飞船移动的速度做一个限制,否则飞船很难操控。不对加速度进行限制的话,将使飞船失控(让可怜的飞行员变成果冻!),因此,让我们来加一点限制。
直接在 import 语句后加入:
let MaxPlayerAcceleration: CGFloat = 400
let MaxPlayerSpeed: CGFloat = 200
这里我们新加了两个常量:最大加速度(400 像素/秒2),以及最大速度(200 像素/秒)。依照 Swift 一般约定,将两个常量的首字母大写,以区别于普通的 let 变量。
在 updatePlayerAccelerationFromMotionManager 方法的 if let … 一句的最后加入:
playerAcceleration.dx = CGFloat(accelerometerY) * -MaxPlayerAcceleration
playerAcceleration.dy = CGFloat(accelerometerX) * MaxPlayerAcceleration
加速计的取值范围一般是 -1 到 +1 之间,因此要获得最终的加速度,需要乘以最大加速度 MaxPlayerAcceleration。
注意:我们在 x 方向上用 accelerometerY 而在 y 方向上用 accelerometerX。这是正确的。注意这个游戏是横屏的,因此 x 方向的加速度是从上到下,y 方向上的加速度是从右到左。
继续。接下来是将 playerAcceleration.x 和 playerAcceleration.dy 用到飞船的速度和位置上,这将放在 update() 方法中进行。这个方法每帧调用一次(即 60 次/秒)。因此这个地方是进行所有游戏逻辑的好地方。
新增一个 updatePlayer() 方法:
func updatePlayer(dt: CFTimeInterval) {
// 1
playerVelocity.dx = playerVelocity.dx + playerAcceleration.dx * CGFloat(dt)
playerVelocity.dy = playerVelocity.dy + playerAcceleration.dy * CGFloat(dt)
// 2
playerVelocity.dx = max(-MaxPlayerSpeed, min(MaxPlayerSpeed, playerVelocity.dx))
playerVelocity.dy = max(-MaxPlayerSpeed, min(MaxPlayerSpeed, playerVelocity.dy))
// 3
var newX = playerSprite.position.x + playerVelocity.dx * CGFloat(dt)
var newY = playerSprite.position.y + playerVelocity.dy * CGFloat(dt)
// 4
newX = min(size.width, max(0, newX));
newY = min(size.height, max(0, newY));
playerSprite.position = CGPoint(x: newX, y: newY)
}
如果你以前编写过游戏(或者学过物理),这些代码看起来会很熟悉。这是它们的大致作用:
将当前加速度加到当前速度上。
加速度以“像素/秒”为单位(实际上是秒2,但这无关紧要)。而 update() 方法执行的频率要远远大于“1次/秒”。因此,我们需要用加速度乘以 δ 时间(每帧所用的时间),即 dt。否则,飞船会比它理论上的速度要快 60 倍!
将飞船的速度限制在 ± MaxPlayerSpeed 之内,如果飞船速度为负值,不得小于 ﹣ MaxPlayerSpeed,如果飞船速度为正,不得大于 + MaxPlayerSpeed。
将当前速度加到位置计算中去。速度的单位是“像素点/秒”,因此需要将它乘以 δ 时间(dt),然后才能加到当前位置中去。
限制飞船的位置不要超出屏幕边沿。我们不想让飞船飞出屏幕以外,然后再也回不来了!
还有一件事情:你需要计算时间差(δ 时间 dt)。Sprite Kit 会重复调用 update() 方法并传入一个当前时间,因此速度计算是 OK 的。
要记录 δ 时间,需要增加一个属性:
var lastUpdateTime: CFTimeInterval = 0
然后将 update 方法修改为:
override func update(currentTime: CFTimeInterval) {
// to compute velocities we need delta time to multiply by points per second
// SpriteKit returns the currentTime, delta is computed as last called time - currentTime
let deltaTime = max(1.0/30, currentTime - lastUpdateTime)
lastUpdateTime = currentTime
updatePlayerAccelerationFromMotionManager()
updatePlayer(deltaTime)
}
让我们看一下是怎么实现的。
用这一次 update() 方法调用的时间,减去上一次 update() 方法调用的时间,得到 δ 时间 dt。为了保险起见,将 dt 限制为最小不得小于 30 分之 1 秒。如果 App 的帧率因为某种原因变得波动较大的时候,飞船不至于在一帧之内突然就飞出屏幕。
调用 updatePlayerAccelerationFromMotionManager() 方法根据加速计的值计算玩家的加速度。
最后,调用 updaePlayer() 方法去移动飞船,将 dt 引入到移动速度的计算中去。
在真实设备上(不要在模拟器上)运行程序。现在你可以通过倾斜设备来控制飞船了:
还剩最后一件事情:在 GameViewController.swift 中,找到这行:
skView.ignoresSiblingOrder = true
修改为:
skView.ignoresSiblingOrder = false
这一句将 Sprite Kit 绘制精灵时的一个优化特性关闭。也就是说绘制精灵时,将按照精灵被加入的先后顺序进行绘制。这一点将在后面用到。
如果你跳过了前面的内容,直接从这一节开始,请在这里下载开始项目。在你的设备上运行程序——你会看到一艘飞船,并可以用加速计来控制它移动。当然,这其中没有使用任何三角学的内容,因此接下来让我们开始这部分的内容!
我们有一个不错的想法——为了减少玩家的困惑——让飞船根据它当前运动的方向旋转,而不是一直将头朝向一个方向:正前方。
要旋转飞船,要先计算出它应该旋转多少度。但你并不知道它是多少,你只有一个速度向量。通过这个向量能够得到一个角度吗?
让我们想一下,我们已知的条件。玩家的速度由两部分组成:一个 x 轴方向上的长度,和一个 y 方向上的长度:
如果你将它们重新排列一下,你就会发现这构成了一个三角形:
这里,邻边(playerVelocity.dx)的长和对边(playerVelocity.dy)的长是已知的。
你已知直角三角形的两边,想知道一个夹角(这符合“已知两条边之长,求角的大小”),因此我们需要用到下列反三角函数之一:arcsin、arccos、arctan。
因为我们求的是已知的两边边长之间的夹角,因此用 arctan 函数即可找出飞船旋转的角度。也就是:
angle = arctan(opposite / adjacent)
Swift 标注库中有一个计算反切的 atan() 函数,但它有几个限制:x 或 y 得到的结果和 -x 或 -y 是一样的,因此对于两个完全相反的速度向量来说,atan() 计算出来的角度是相同的。此外,这个角度也不是你最终想像的那样——你想计算的是实际上是相对于某个轴的相对角度,在 atan() 返回的结果上加上 90 、180 或者 270 度偏移角度后的角度。
你可以写一个四个分支的 if 语句,去计算正确的角度,将速度向量中的变量的符号也就是说向量所处的象限也考虑进去,然后再进行正确的偏移。但我们有一个更简单的解决方法:
对于这个问题,用 atan2() 函数要比用 atan() 函数要简单得多。atan2() 函数使用单独的 x 参数和 y 参数,并能够正确地判断整个旋转角度。
angle = atan2(opposite, adjacent)
在 updatePlayer 方法最后加入这两句:
let angle = atan2(playerVelocity.dy, playerVelocity.dx)
playerSprite.zRotation = angle
注意首先传入 y 坐标。通常我们会写成 atan(x,y),但这错的。记住,第一个参数是对边,也就是这里的 y 坐标,位于我们想计算的角的正对面。
运行程序,进行测试:
呃,有点不对劲。飞船是会转,但它指向的方向不是它正在飞行的方向!
这是因为:飞船精灵的图片是指向上方的,默认的旋转角度是 0 度。但在数学中,0 度并不是指向上的,而是指向右的,即 X 轴的方向:
为了解决这个问题,可以将旋转角度减去 90 度,以便和精灵图片的朝向相一致:
playerSprite.zRotation = angle - 90
你可以测试一下。
不!比刚才还要糟糕了!到底怎么回事?
正常情况下,人们总是习惯于将角度看成是 0-360 度的值。但在数学中,通常用弧度来作为角度的地位,也就是说用 π (希腊字母 pi,读“pie”,但却不能吃)来表达角度。
一个弧度被定义为在圆上的一段长度和圆半径相等的弧所对应的角度。因此,如果要用这个线段(一个弧度长)测量整个圆的长度,就需要用反复测量 2π 次。
注意黄色的线段(半径)和红色的线段(圆弧)是等长。这个圆弧所夹的角度就是一个弧度!
当你用 3-360 °来衡量一个角度时,数学家却将它看成是 0-2π 。绝大部分数学函数都使用弧度,因为计算的时候弧度更方便一些。Sprite Kit 在测量角度时一律使用弧度。atan2() 函数的返回值也是弧度,但你却用它和 90 进行加减。
由于我们将同时使用弧度和度,因此将二者进行相互转换是很有必要的。转换非常简单:因为不管 2π 还是 360° 都是一个圆,π 就等于 180°,从弧度转换为度只需要除以 π 再乘以 180 即可。至于从度转换到弧度,则除以 180 再乘以 π 即可。
在 C 的数学库中(它在 Swift 中是自动包含的)有一个常量 M_PI,就代表了一个 π,类型为 Double。Swift 严格的类型转换规则使得这个常量并不是很好用,很多时候这个值需要被转换成 CGFloat,因此最好重新定义一个常量。在 GameScene.swift 的类的定义之外,在文件顶部添加下列声明:
let Pi = CGFloat(M_PI)
然后定义两个常量,用于在度和弧度之间进行转换:
let DegreesToRadians = Pi / 180
let RadiansToDegrees = 180 / Pi
接下来在 updatePlayer 方法中修改旋转的代码,引入 DegreesToRadians 常量:
playerSprite.zRotation = angle - 90 * DegreesToRadians
运行程序,你将看到飞船终于正确地转向了。
我们的飞船现在可以用加速计来控制移动了,同时我们通过三角计算让它在飞行的同时保持正确的方向。这开了一个很好头。
让飞船在屏幕边沿卡住不动并不是一个很好的做法。我们它替换成:当它飞到屏幕边缘时,让它反弹回来!
首先将 upatePlayer() 方法中的这几行删除:
// 4
newX = min(size.width, max(0, newX))
newY = min(size.height, max(0, newY))
替换为:
And replace them with the following:
var collidedWithVerticalBorder = false
var collidedWithHorizontalBorder = false
if newX < 0 {
newX = 0
collidedWithVerticalBorder = true
} else if newX > size.width {
newX = size.width
collidedWithVerticalBorder = true
}
if newY < 0 {
newY = 0
collidedWithHorizontalBorder = true
} else if newY > size.height {
newY = size.height
collidedWithHorizontalBorder = true
}
这段代码片段飞船是否飞到了屏幕的边沿,如果是,将一个布尔变量设置为 true。当这样的碰撞发生后会怎样?让飞船从边缘弹回,你可以直接将速度向量和加速度向量取反。在 updatePlayer() 方法中继续添加:
if collidedWithVerticalBorder {
playerAcceleration.dx = -playerAcceleration.dx
playerVelocity.dx = -playerVelocity.dx
playerAcceleration.dy = playerAcceleration.dy
playerVelocity.dy = playerVelocity.dy
}
if collidedWithHorizontalBorder {
playerAcceleration.dx = playerAcceleration.dx
playerVelocity.dx = playerVelocity.dx
playerAcceleration.dy = -playerAcceleration.dy
playerVelocity.dy = -playerVelocity.dy
}
如果碰撞发生,将加速度和速度反向,让飞船从墙上弹开。
运行程序,进行测试。
呃,弹是会弹了,只不过看起来有点过于灵敏了。问题是你并不想让飞船像一只橡皮球一样弹来弹去——每次碰撞后它都会消耗掉一些能量,因此经过碰撞之后速度会比之前的要小。
另外定义一个常量,就放在 let MaxPlayerSpeed: CGFloat = 200 之后:
let BorderCollisionDamping: CGFloat = 0.4
现在,将 updatePlayer 方法中刚才新加的代码修改为:
if collidedWithVerticalBorder {
playerAcceleration.dx = -playerAcceleration.dx * BorderCollisionDamping
playerVelocity.dx = -playerVelocity.dx * BorderCollisionDamping
playerAcceleration.dy = playerAcceleration.dy * BorderCollisionDamping
playerVelocity.dy = playerVelocity.dy * BorderCollisionDamping
}
if collidedWithHorizontalBorder {
playerAcceleration.dx = playerAcceleration.dx * BorderCollisionDamping
playerVelocity.dx = playerVelocity.dx * BorderCollisionDamping
playerAcceleration.dy = -playerAcceleration.dy * BorderCollisionDamping
playerVelocity.dy = -playerVelocity.dy * BorderCollisionDamping
}
现在,我们将加速度和速度乘以了一个衰减系数 BorderCollisionDamping。这样就可以让能量在碰撞后有所损失。当飞船撞上屏幕边沿之后只保留原来速度的 40%。
如果你有兴趣,可以修改 BorderCollisionDamping 的值,看看效果会有什么不同。如果你将值改成大于 1 的数,则飞船甚至可以从碰撞中获得能量!
你会注意到还有一个小问题:如果你将飞船瞄准屏幕底部,让它反复不停地撞向屏幕边沿,则它会在向上和向下的方向之间打转。
用 arctan 函数计算 x 和 y 组件之间的夹角是 OK 的,但这个 X 和 Y 值必须足够大。在这里,由于衰减系数的存在,速度被降低到接近于 0。当我们用 atan2() 计算飞船小的 x 和 y 值时,一个很小的波动就会导致算出的角度出现非常大的改变。
一个办法是当速度变得很低时,就不要改变角度了。嗯,是该打个电话问候下我们的老朋友毕达哥拉斯(勾股定理的发明者)了。
事实上我们保存的并不是飞船的 speed(快慢)。我们保存的是飞船的 velocity (速度),它是一个向量(关于 speed 和 velocity 的区别,请看这里),速度有两个组件构成,一个 x 方向上的速度,一个 y 方向上的速度。但为了表达最终这个飞船的速度有多快(比如它是否慢到不需要飞船转向),我们需要将速度的 x 组件和 y 组件合并成一个单个的标量值。
这就是前面我们讲过的“已知三角形两边之长,求第三边之长。”
如图所示,飞船真正的速度是——它每秒钟在屏幕上移动的像素——即屏幕上三角形的斜边,它又是由 x 方向上的速度和 y 方向上的速度构成。
使用毕达哥拉斯公式(勾股定理)就是:
真实速度 = √(playerVelocity.dx2 + playerVelocity.dy2)
从 updatePlayer() 中删除以下代码:
let angle = atan2(playerVelocity.dy, playerVelocity.dx)
playerSprite.zRotation = angle - 90 * DegreesToRadians
替换成以下代码:
let RotationThreshold: CGFloat = 40
let speed = sqrt(playerVelocity.dx * playerVelocity.dx + playerVelocity.dy * playerVelocity.dy)
if speed > RotationThreshold {
let angle = atan2(playerVelocity.dy, playerVelocity.dx)
playerSprite.zRotation = angle - 90 * DegreesToRadians
}
运行程序。现在飞船在碰到边缘后的转向变得稳定了。如果你奇怪 40 这个值是怎么来的,我的回答是“经验值”。在代码中通过 “NSLog()” 语句打印飞船撞到墙上的速度值,然后不停地调整这个值,一直到你觉得可以就行了。
但是,解决一个问题的同时又会带来别的问题。让飞船慢慢减速,直至停止。然后翻转设备,让飞船转向并向另一个方向飞行。
如果是在之前,你会看到一个漂亮的转向动画。但因为我们添加了防止飞船在低速下改变方向的代码,现在的转向会变得非常突然。这只是一个小问题,但这个问题关系到我们能否制作出一个好的 App 和游戏。
解决办法是不要立马将方向切换到新的角度,而是在每一帧逐步“混合渗入”新角度和旧角度。这种方式不但重新生成了转向动画而且仍然能够防止飞船在低速下转向。“混合渗入”听起来很神奇,但实际上却不难实现。但是它需要你记录下飞船每帧的角度,因此我们要在 GameScene 类中新增一个属性:
var playerAngle: CGFloat = 0
将 updatePlayer() 中的转向代码修改为:
let RotationThreshold: CGFloat = 40
let RotationBlendFactor: CGFloat = 0.2
let speed = sqrt(playerVelocity.dx * playerVelocity.dx + playerVelocity.dy * playerVelocity.dy)
if speed > RotationThreshold {
let angle = atan2(playerVelocity.dy, playerVelocity.dx)
playerAngle = angle * RotationBlendFactor + playerAngle * (1 - RotationBlendFactor)
playerSprite.zRotation = playerAngle - 90 * DegreesToRadians
}
playerAngle 变量包含了用混合系数乘以新角度和上一帧的角度。也就是说新的角度只占飞船实际转向的 20% 的份额。随着时间的增长,越来越多的新角度被累加进去,直到飞船最终指向了正确的方向。
运行程序,测试飞船从一个方向转到另一个方向时不会再显得突兀。
现在,飞出几个圆环,反时针和顺时针都试一试。你会看到在圆环的某些点上,飞船会突然反方向旋转 360°。这种现在总是出现在圆环上的某几个位置。这是怎么回事?
atan2() 返回一个 +π 到 -π (+180°到-180°)之间的角度。也就是说如果当前角度接近 +π 时,并在转动过程中转过了一小点,那么他会反过来转到 -π(反之亦然)。
这两个位置实际上是同一个位置( -180 和 +180 在圆上是同一个位置),但混合算法还不够智能,没有意识到这点——它认为角度整个改变了 360 度(2π 弧度),因此飞船做了反方向旋转 360°。
要解决这个问题,需要知道什么时候角度超过了阀值,并适当地调整 playerAngle。在 GameScene 类中添加一个新属性:
var previousAngle: CGFloat = 0
然后再一次修改旋转代码为:
let speed = sqrt(playerVelocity.dx * playerVelocity.dx + playerVelocity.dy * playerVelocity.dy)
if speed > RotationThreshold {
let angle = atan2(playerVelocity.dy, playerVelocity.dx)
// did angle flip from +π to -π, or -π to +π?
if angle - previousAngle > Pi {
playerAngle += 2 * Pi
} else if previousAngle - angle > Pi {
playerAngle -= 2 * Pi
}
previousAngle = angle
playerAngle = angle * RotationBlendFactor + playerAngle * (1 - RotationBlendFactor)
playerSprite.zRotation = playerAngle - 90 * DegreesToRadians
}
这里,我们判断当前和之前的角度之差,看是否超过了这个阀值:0 到 π(180°)。
运行程序。这样飞船的转向就不再有任何问题了。
我们有了一个很好的开始——我们拥有了一艘能够灵活飞行的飞船。但这艘飞船的日子未免也太舒服、太一帆风顺了。给它添点刺激怎么样?我们将为它增加一个敌人:一挺炮台!
在 GameScene 类中加入两个属性:
let cannonSprite = SKSpriteNode(imageNamed: "Cannon")
let turretSprite = SKSpriteNode(imageNamed: "Turret")
You’ll set these sprites up in didMoveToView(). Place this code before the setup for playerSprite, so that the spaceship always gets drawn after (and therefore in front of) the cannon:
我们将在 didMoveToView() 方法中加入这两个角色。将代码放到创建 playSprite 之前,以便在飞船出现之前炮台就已经存在了:
cannonSprite.position = CGPoint(x: size.width/2, y: size.height/2)
addChild(cannonSprite)
turretSprite.position = CGPoint(x: size.width/2, y: size.height/2)
addChild(turretSprite)
注意:还记得我们之前写的 skView.ignoresSiblingOrder=false 一句吗?这句代码让精灵按照它们添加到场景的先后顺序绘制。虽然还可以用别的方式来决定精灵绘制的顺序——比如使用 zPosition 属性——但我们采用的是最简单的方法。
炮台由两部分构成:一个固定不动的底座,以及一个会旋转瞄向玩家的炮塔。运行程序,你会看到一座全新的炮台坐落在屏幕的中央。
给炮台一个靶子吧!
我们想让炮台的炮塔随时都能指向玩家。要达到这个目的,我们需要计算出炮塔和玩家之间的角度。
这个计算和让飞船转向前进方向的计算差不多。不同的是这个三角形不是用飞船的速度来构成,而是用飞船和炮台之间的连线来构成:
我们仍然可以用 atan2() 来计算这个角度。添加一个新方法:
func updateTurret(dt: CFTimeInterval) {
let deltaX = playerSprite.position.x - turretSprite.position.x
let deltaY = playerSprite.position.y - turretSprite.position.y
let angle = atan2(deltaY, deltaX)
turretSprite.zRotation = angle - 90 * DegreesToRadians
}
deltaX 和 deltaY 变量表示了玩家和炮塔之间的距离。将这两个值代入到 atan2() 中,就可以得到它们之间的夹角。
同前次一样,我们需要将这个角度偏转到 X 轴方向(90°),以使炮塔的方向正确。注意,atan2() 只会返回一个由斜线和 0 度线构成的夹角,而不是三角形的内角。
然后来调用这个方法。在 update() 方法中的最后一句加上:
updateTurret(deltaTime)
运行程序,炮塔会自动对着飞船。很简单是吧?这就是三角学的威力!
挑战:实际上真正的炮台是不会瞬移的——它实际是预判目标下一个位置在哪里。它总是追赶着目标,略略地尾随着飞船的位置。
要实现这个,我们可以用新角度和老角度进行“混合”,正如我们先前在飞船转向的过程中所做的一样。混合系数越小,炮塔瞄准飞船所需要的时间就越长。你可以试一下,看能否独立实现这个功能。
### 加入血槽
在第二部分,你将实现玩家向炮台开火的功能,而炮台也可以给飞船造成损坏。要显示二者剩余的生命值,我们需要为角色添加血槽。让我们开始吧。
在 GameScene.swift 中添加如下常量:
let MaxHealth = 100
let HealthBarWidth: CGFloat = 40
let HealthBarHeight: CGFloat = 4
在 GameScene 类中加入如下新属性:
let playerHealthBar = SKSpriteNode()
let cannonHealthBar = SKSpriteNode()
var playerHP = MaxHealth
var cannonHP = MaxHealth
在 didMoveToView() 方法中,在 startMonitoringAcceleration() 一句前插入:
addChild(playerHealthBar)
addChild(cannonHealthBar)
cannonHealthBar.position = CGPoint(
x: cannonSprite.position.x,
y: cannonSprite.position.y - cannonSprite.size.height/2 - 10
)
playerHealthBar 和 cannonHealthBar 都是 SKSpriteNode 对象,但我们没有为它们指定任何图片。相反,我们将用 Core Graphics 动态地为它们绘制血槽。
注意,我们将 cannonHealthBar 放到炮台稍下一点的位置,但却没有指定 playerHealthBar 所在的位置。因为炮台不会动,只需要设置一次它的位置就可以了。
而飞船是在不停运动着的,我们必须随时修改 playerHealthBar 的位置。这个动作应当在 updatePlayer 中完成。在这个方法的最后加入:
playerHealthBar.position = CGPoint(
x: playerSprite.position.x,
y: playerSprite.position.y - playerSprite.size.height/2 - 15
)
剩下是就是绘制血槽自身了。在这个类中新加一个方法:
func updateHealthBar(node: SKSpriteNode, withHealthPoints hp: Int) {
let barSize = CGSize(width: HealthBarWidth, height: HealthBarHeight);
let fillColor = UIColor(red: 113.0/255, green: 202.0/255, blue: 53.0/255, alpha:1)
let borderColor = UIColor(red: 35.0/255, green: 28.0/255, blue: 40.0/255, alpha:1)
// create drawing context
UIGraphicsBeginImageContextWithOptions(barSize, false, 0)
let context = UIGraphicsGetCurrentContext()
// draw the outline for the health bar
borderColor.setStroke()
let borderRect = CGRect(origin: CGPointZero, size: barSize)
CGContextStrokeRectWithWidth(context, borderRect, 1)
// draw the health bar with a colored rectangle
fillColor.setFill()
let barWidth = (barSize.width - 1) * CGFloat(hp) / CGFloat(MaxHealth)
let barRect = CGRect(x: 0.5, y: 0.5, width: barWidth, height: barSize.height - 1)
CGContextFillRect(context, barRect)
// extract image
let spriteImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
// set sprite texture and size
node.texture = SKTexture(image: spriteImage)
node.size = barSize
}
这段代码绘制了一个血槽。首先设定填充色和边框色,然后创建图形上下文,绘制两个方框:一个用作血槽的边框,它总是固定大小,另一个是血条,它是会变的,要看生命的点数。这个方法从上下文中返回一个 UIImage 并赋给 Sprite 的 texture 属性。
我们需要调用这个方法两次,一次是针对玩家对象,一次是针对炮台。因为绘制血槽的代价相对昂贵(Core Graphics 绘图不使用硬件加速),因此我们不想在帧刷新时绘制。相反,我们只应该在玩家或者炮台的生命值被改变的时候绘制。暂时,我们只调用它一次,用于显示血槽满血的状态。
在 didMoveToView 方法最后加入:
updateHealthBar(playerHealthBar, withHealthPoints: playerHP)
updateHealthBar(cannonHealthBar, withHealthPoints: cannonHP)
```
运行程序,现在玩家和炮台都有了血槽:
![](http://cdn4.raywenderlich.com/wp-content/uploads/2014/12/HealthBars-480x269.png)
### 用三角学进行碰撞检测
暂时,飞船直接从炮台身上飞过不会导致任何后果。假设让飞船在和炮台发生碰撞后造成一定的伤害,则效果要更刺激(和更真实)一些。现在可以把你扔到碰撞检测范围内试一试了(不好意思,开个玩笑了 :])。
这里,有许多游戏开发者会说:“我需要使用物理引擎!”。当然,你可以用 Sprite Kit 的物理引擎来做这个,但要自己实现碰撞检测其实一点都不难,尤其是如果你的精灵使用了简单的圆形建模时。
检测两个圆形是否相交其实很简单:你只需要计算二者之间的距离(*咳咳* 勾股定理),然后判断是否小于二者半径之和(或者两个半径)。
![](http://cdn5.raywenderlich.com/wp-content/uploads/2013/03/Collision-detection.png)
在 GameScene.swift 顶部加入两个新常量:
```swift
let CannonCollisionRadius: CGFloat = 20
let PlayerCollisionRadius: CGFloat = 10
"se-preview-section-delimiter">
这是炮台和玩家的碰撞环的大小。查看一下精灵位图,炮台图片的大小实际上要比这里指定的值要略大(25 像素),不过保留一点缓冲空间是好的,我们不准备让这个游戏过于苛求,否则玩家就毫无乐趣可言了。
事实上,飞船也根本不是圆形也没有关系。对于各种形状的精灵来说,使用圆形模拟都是不错的,而且这样做还有一个好处,即使三角计算更加简单。这里,飞船的直径约 20 像素(直径是半径的两倍)。
新增一个方法用于碰撞检测:
func checkShipCannonCollision() {
let deltaX = playerSprite.position.x - turretSprite.position.x
let deltaY = playerSprite.position.y - turretSprite.position.y
let distance = sqrt(deltaX * deltaX + deltaY * deltaY)
if distance <= CannonCollisionRadius + PlayerCollisionRadius {
runAction(collisionSound)
}
}
"se-preview-section-delimiter">
首先算出两个精灵间的 x 和 y 距离,将 x 和 y 当成是直角三角形的两条边就可以算出斜边,这就是二者间的直线距离。
如果这个距离小于两个碰撞半径之和,播放生效。这个地方会报一个错误,因为我们还没有实现声效代码——耐心一点,待会实现。
在 update() 最后添加:
checkShipCannonCollision()
<div class="se-preview-section-delimiter">div>
在 GameScene 类顶部新加一个属性:
let collisionSound = SKAction.playSoundFileNamed("Collision.wav", waitForCompletion: false)
<div class="se-preview-section-delimiter">div>
运行程序,将飞船飞到炮塔上方测试碰撞逻辑是否正确。
注意当碰撞发生时,声效播放起来就没完没了。因为当飞船飞过炮台时,会检测到多次碰撞,一个接一个。不仅仅是一个碰撞,而是每秒 60 次碰撞发生了,而每次碰撞都会播放一次声效。
碰撞检测只是一方面的问题,另外一方面的问题是碰撞反应。我们不但要在碰撞时播放声效,也想有一个物理反应——飞船会从炮台上弹开。
在 GameScene.swift 文件顶部添加一个常量:
let CollisionDamping: CGFloat = 0.8
<div class="se-preview-section-delimiter">div>
然后在 checkShipCannonCollision() 的 if 语句内加入以下语句:
playerAcceleration.dx = -playerAcceleration.dx * CollisionDamping
playerAcceleration.dy = -playerAcceleration.dy * CollisionDamping
playerVelocity.dx = -playerVelocity.dx * CollisionDamping
playerVelocity.dy = -playerVelocity.dy * CollisionDamping
"se-preview-section-delimiter">
就像我们让飞船从屏幕边沿弹开一样。运行程序进行测试。
如果飞船在撞上炮台时飞得很快,这个方法没有什么问题。如果速度很慢,哪怕它从反方向弹开,飞船仍然会有一段时间处于碰撞半径之内,甚至再也无法离开。显然,这个办法也有问题。
如果不将速度取反来弹开飞船,则我们可以通过改变飞船的位置让它离开碰撞半径,真正地将飞船从炮台身上推开,
这需要计算炮台和飞船之间的向量,幸运的是,为了计算二者之间的距离,我们已经在前面计算过这个了。那么,如何利用距离向量去移动飞船?
这个向量由一个 deltaX 和一个 deltaY 构成,并且指向了正确的方向,但它的长度是不对的。我们需要的长度是碰撞半径和当前长度之差——这样,我们将可以将这个长度加到飞船当前位置,飞船就不再和炮台发生交叠了。
当前向量的长度是 distance,而我们需要将它的长度变成:
CannonCollisionRadius + PlayerCollisionRadius – distance
如何改变一个向量的长度?
办法是使用“向量规范化”。通过将向量的 x 和 y 分别除以向量长度(用勾股定理),就可以对这个向量进行规范化。规范化向量之后,向量的长度就变成了 1。
然后,将 x 和 y 乘以上面计算出来的长度,就得到飞船需要移动的距离。在上几行代码后面加入:
let offsetDistance = CannonCollisionRadius + PlayerCollisionRadius - distance
let offsetX = deltaX / distance * offsetDistance
let offsetY = deltaY / distance * offsetDistance
playerSprite.position = CGPoint(
x: playerSprite.position.x + offsetX,
y: playerSprite.position.y + offsetY
)
"se-preview-section-delimiter">
运行程序,你将发现飞船能够从炮台上正确地弹开了。
除了碰撞逻辑,我们还需要让飞船和炮台“掉一些血”,并刷新血槽。在 if 语句中加入:
playerHP = max(0, playerHP - 20)
cannonHP = max(0, cannonHP - 5)
updateHealthBar(playerHealthBar, withHealthPoints: playerHP)
updateHealthBar(cannonHealthBar, withHealthPoints: cannonHP)
<div class="se-preview-section-delimiter">div>
运行程序,飞船和炮台发生碰撞后都会损失一些生命点。
为使效果更好看,我们可以让飞船在碰撞后发生一些旋转。这些旋转是额外的,不影响飞行的飞行;仅仅是使碰撞效果更显眼一点(飞行员头会更晕)。在 GameScene.swift 顶部加入一个新常量:
let PlayerCollisionSpin: CGFloat = 180
<div class="se-preview-section-delimiter">div>
设置旋转的速度为每秒半圈就足够了。在 GameScene 类中加入一个新属性:
var playerSpin: CGFloat = 0
<div class="se-preview-section-delimiter">div>
在 checkShipCannonCollision() 中,在 if 语句中加入:
playerSpin = PlayerCollisionSpin
<div class="se-preview-section-delimiter">div>
Finally, add the following code to updatePlayer(), immediately before the line playerSprite.zRotation = playerAngle - 90 * DegreesToRadians:
然后,在 updatePlayer() 中,就在 playerSprite.zRotation = playerAngle - 90 * DegreesToRadians 一句之前加入:
if playerSpin > 0 {
playerAngle += playerSpin * DegreesToRadians
previousAngle = playerAngle
playerSpin -= PlayerCollisionSpin * CGFloat(dt)
if playerSpin < 0 {
playerSpin = 0
}
}
playerSpin 用于表示碰撞偏移过程中飞船偏移的角度,不计算速度的影响。偏移角度会随时间递减,因此飞船在一秒后停止偏移。在碰撞偏移过程中,我们修改 previousAngle 的值,使其和偏移角度匹配,这样飞船才不会在偏移结束时突然转到一个新的角度。
运行程序,查看飞船碰撞偏移的效果。
这里是教程中使用到的完整示例项目。
一切都是三角形!通过三角函数我们处理移动、旋转和碰撞侦测的问题,从而使我们的精灵具备了生命!
我们不得不承认,其实这些并不难学习。数学,如果将它用到有趣的事情上比如制作游戏是,就不会那么索然无味了!
但我们还有更多的内容需要学习:在本教程的第二部分,你将在游戏中加入导弹,学习更多关于 sin 和 cos 的知识,学些更多在游戏中三角学的不同用途。
声明:游戏中使用的图片来自于 Kenney Vleugels,声音来自于 freesound.org。