你可能还没意识,其实游戏里是充满三角的。举个例子,想象你有一个飞船游戏,然会你要计算那些飞船之间的距离。
你有每艘飞船的X,Y坐标位置,但你怎么找到它们的距离呢? 很简单,你可以像这样从一艘飞船的中心画一条线到另一艘飞船,就像这样:
用勾股定理(原文对勾股定理的解释好长,我觉得没必要吧...)
总结来说,三角学就是你能够用来计算三角形的角度和边的长度的数学。人们常常会用到。
举个例子来说,在飞船游戏里你可能会要实现:
- 一艘飞船向另一艘飞船的方向发射激光
- 让一艘飞船向着另一艘飞船的方向移动
- 敌方飞船靠的太近的时候发出危险警告
所有的这些都是你可以用三角学来实现的。
(此处略过勾股定理衍生的sin,cos,tan,arcsin,arccos,arctan三角函数介绍)
创建项目
Swift是新语言,语法变得频繁,所以确保你用的是Xcode6.1.1或者之后的版本。
然后,创建一个SpriteKit的项目,使用Swift语言。
我们把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) { } }
然后把飞船添加到scene里把,如下修改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) } ... }
如果你之前玩过SpriteKit的话,这些都是非常基础的。playerSprite就是飞船的sprite,被放置在屏幕的右下方。记住一点,在SpriteKit的坐标里,下方才是Y=0,而UIKit里上方Y=0。
跑一下程序,如下~
为了移动飞船,在这儿你将会用到Iphone的加速计。很遗憾,在模拟器上不能用加速计,所以你得在真机上做测试。
你通过倾斜设备来调用加速计。这就是我们刚才限制设备让它只能是Left Landscape状态的原因。如果你在倾斜的时候屏幕自动旋转了那还玩毛。
由于有Core Motion的存在,使用加速器变得非常简单。有两种方法可以得到加速计的数据:
- 设定一定频率让加速计回调传来数据
- 在需要的时候征用数据
Apple建议不要把数据一股脑塞进你的程序里,除非你对时间要求非常精确(比如说导航仪和测量工具),因为那样做电池会消耗得很快。
你的游戏已经有一个合乎逻辑的按一定频率调用数据的地方了,update()方法在游戏帧数每次刷新的时候都被调用。
首先,添加下面的代码到GameScene.swift里:
1 |
|
接着,添加下面的属性:
1 2 3 |
|
你需要这些属性来追踪加速计的数据。你仅仅只需要追踪x和y轴的信息,z轴在这个游戏里用不到。
接着,添加下面的方法:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
上述方法,让加速计在可以用的情况下开启和关闭。
didMoveToView()是一个适合开启加速计的地方。在addChild(playerSprite)下面添加代码:
1 |
|
对于停止加速计,合适的地方是一个类型的deinit方法:
1 2 3 |
|
接着,添加下述方法用来让飞船改变位置:
1 2 3 4 5 6 7 |
|
fiter的加入是很有必要的,这样处理一下而已让你得到的数据更加平滑。motionManager的accelerometerData在数据还未能得到的时候是nil,所以在用if let来确保只在有数据的时候再进行下面的计算。
(更多关于缓速过滤的知识,看一下Low-pass filter)
现在你有了设备的旋转角度信息,那么你怎么来让飞船随之运动呢?
基于物理运动的游戏通常像这样实现:
- 首先,由物理设备传输进来一些加速计的数据
- 其次,你给飞船的速度加上一个新的加速度。这让物体基于加速计的角度能加速和减速
- 最后,你把得到的新的速度给予飞船,让它能够移动。
(这种模拟是牛顿总结的)
你需要更多的属性来追踪飞船的速度和加速度。由于SKSpriteNode已经帮你追踪了sprite的位置,所以你就省去这一步骤了。
(注:其实如果用SKSpriteNode的SKPhysicsBody属性,就能自动追踪和更新Sprite的速度和加速度,但是如果你让SpriteKit做所有的工作,我们还学个屁三角学!所以,在这个教程里,你要自己干一些数学)
给类型添加下面的属性:
1 2 |
|
最好我们给飞船设定一些速度的限制,不然会比较麻烦。不加限制的加速度会难很控制。所以我们添加下面几行代码:
1 2 |
|
这定义了两个限制:最大加速度和最大速度。
现在在updatePlayerAccelerationFromMotionManager:的if let下面添加这样的代码:
1 2 |
|
加速计提供了从-1到1的数,乘以最大值就可以把数速度限制在范围之内了。
你几乎就快完成了。最后一步就是把playerAcceleration.dx和playerAcceleration.dy的值传给飞船了。你要通过update()方法来实现这一步。这个方法每帧调用一次(每秒钟60帧),所以这是一个更新数据的好地方。
添加updatePlayer()方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
如果你之前做过游戏的话,上述代码你估计很熟悉,以下按步骤解释了上面的代码的作用。
- 把当前的加速度赋给速度。加速度是被用point/s的方式来表示的(事实上,每秒里面会被均分,所以不用担心)。然而,update()方法的执行频率远大于一秒一次。为了弥补这个不同,我们给它加了一个delta time的系数。如果没有这个的话,飞船会比它应该的速度快60倍
- 把速度限制在最大最小速度的范围之内
- 速度乘以时间得到应该移动的距离,从而得到现在飞船应该在的位置
- 把新的坐标限制在Scene的bounds之内
除此以外,你需要在不同于update()的更新频率下更新飞船状态,所以你需要追踪update的间隔时间,为此,添加一个新的属性:
1 |
|
替换update()的实现为如下:
1 2 3 4 5 6 7 8 |
|
你通过currentTime减去lastTime来算出deltaTime。安全起见,让deltaTime和1/30取最大值。这样的话,即便app的帧率因为某些原因下降,飞船也要等到下一次屏幕刷新的时候才被重新计算位置。
updatePlayerAccelerationFromMotionManager()方法被调用来通过加速计数据计算加速度。
最终,updatePlayer()将会被调用来更新飞船的位置,用得到的deltaTime来计算速度。
用真机跑一下你的程序吧。你现在可以通过旋转设备来让飞船运动啦!
最后一件事:打开GameViewController.swift找到:
1 |
|
改成:
1 |
|
这会禁用一个渲染上的优化,但这同时保证了sprite会被按照它们被添加进来的顺序被绘制。这在之后会有用。
开始三角学!
如果你跳过了上面直接进入这一节,这里是当前的项目。
到这里你还没用到任何三角学的东西。
如果我们能让飞船对着它飞向的方向的话而不是一直向上的话,就很棒了。
为了旋转飞船,你需要知道需要旋转到的方向。但你只知道速度的矢量,如何从这个矢量上得到速度的方向呢?
想想你知道的东西吧,勾股定理!
重新组织一下:
在updatePlayer()下面添加这两行
1 2 |
|
跑一下程序你会发现:
看起来不是很对啊。虽然飞船旋转了,但是它对的方向不是它移动的方向。
原因如下:飞船的图片原始状态下飞船是朝上的,而没有旋转的时候默认的角度是0°,两者并不契合。所以,计算angle的时候添加代码:
1 |
|
真机测试一下,咦?情况怎么感觉更糟了,缺了什么?
弧度,角度和点
通常人们试图把角度的值限定在0-360°之间。在数学领域,角度通常被用弧度来定义,π是弧度的单位。
弧度被定义为绕着单位圆一周所走的距离,如果你完整地走完一个单位圆,走过的距离是2π。
注意图中黄色线(半径)和红色线(弧)的距离是一样的。这个让两者一致的角度我们就叫它一个单位弧度。正如你把角度想象成0-360之间那样,数学领域把弧度视为0-2π之间的值。很多电脑的函数运算用到弧度,因为在计算的时候弧度比角度更好用。SpriteKit同样也用弧度来计算sprite的旋转,之前用的atan2()函数返回一个弧度值。
既然你将会同时用到角度和弧度,有一个在两者之间转化的方法就好了。这个转化方法非常简单:既然2π相当于360°,那么π就是180°,所以弧度向角度转化的方法就是先除以π再乘上180;角度转化到弧度就是先除上180再乘上π。
C语言的数学库(在Swift里自动使用)有一个常量:M_PI,代表π。Swift对于类型的严格管理让你在用这个常量的时候非常不方便(这是个Float,而你通常要用的是CGFloat),所以你可以定义自己的常量。在GameScene.swift里添加下面这一行:
1 |
|
现在定义另外两个常量来让弧度和角度的转化更加方便
1 2 |
|
最后,用定义好的常量重构updatePlayer的代码:
1 |
|
重新跑一下程序你就会发现现在的飞船方向正常了。
墙上反弹
你已经让飞船根据加速计让飞船移动并且用三角学来让飞船的方向保持在速度方向。这是一个很好的开始。
让飞船在屏幕边缘卡住并不是一个好的解决方案。取而代之,你可以给它设计一个反弹动画!
首先,删除updatePlayer()里的这两行代码:
1 2 3 |
|
替换成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
检查飞船是不是撞到了屏幕的边缘,如果是的话,设置bool类型为true。那么之后呢?为了制造弹射的效果,你可以简单地反转速度和加速度。为updatePlayer添加下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
如果bool类型是true的话,你就反转当前方向的速度和加速度。
跑一下程序试试吧。
哇,反弹奏效了,但是它看上去有那么点太有力了。问题来了:你不能指望一个飞船反弹时候还能有和之前一样的速度。
为此,我们再定义一个新的常量。
1 |
|
现在,替换刚才你添加的代码为:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
你现在把速度和加速度乘上了一个阻尼常量:BorderCollisionDamping。这个常量代表了你冲撞之后还剩下多少能量。上述代码中,你的飞船在冲撞反弹之后还保留了40%的能量。如果你把这个数字设定成大于1的数,那么飞船在冲撞之后还能获取额外的能量。
你可能会注意到一个问题:当你把屏幕竖起来,飞船不停地在屏幕底部下降上升,你会看到飞船的速度越来越慢,飞船的方向慢慢地变得不动了。
用atan2()通过x轴和y轴的向量大小来找飞船方向看起来还不错,但这只适用于当飞船的速率:矢量x,y都是较大值的时候。当atan2()用于非常小的值的时候,得到的弧度变化会非常小。
解决这个问题的一个方法是让速度在非常小的时候不改变方向。这听上去是一个呼叫你的老朋友毕达哥拉斯的好理由。
但是现在你并不存储飞船的速度(标量),而是速率(矢量)。你可以通过速率来计算出速度。
简而言之:
1 |
|
删掉之前的代码:
1 2 |
|
替换成:
1 2 3 4 5 6 |
|
重新跑一下程序,你就会看到飞船在边缘地带的时候稳定了许多。如果你想知道40这个数从哪里来,回答是:经验。你可以用NSLog()打印一些速度出来,然后你就可以自己总结总结了。
混合角度,形成柔和的旋转
显然,编程的时候搞定一件事通常会影响到别的。如果你把飞船的速度降下来,直到它停止,这时候你往飞船方向的反方向旋转设备。飞船不会向之前那样用一个优美的动画掉头了,由于你刚才限制飞船在速度很慢的时候不会掉头,这个动画消失了。虽然这只是个小细节,但是这是让你做出伟大游戏的小细节。
解决的办法是让飞船角度不要马上变化,而是让这个变化混合进一系列连续帧当中。这样的重构同样能防止飞船在速度极快的时候不旋转。“混合”这个词听上去有点花哨,但是其实很好实现,它需要你在游戏刷新的时候持续追踪飞船角度,所以你给GameScene添加一个新的属性:
1 |
|
在updatePlayer()里放上新的实现
1 2 3 4 5 6 7 8 |
|
变量playerAngle用一个混合参数RotationBlendFactor同时乘上旧的角度和新的角度。简而言之,这意味着新的角度仅仅提供飞船角度的20%旋转。随着时间慢慢过去,更多的角度被赋予给飞船,飞船于是慢慢变到新的角度。
现在尝试分别顺时针和逆时针旋转你的飞船看看。你会注意到飞船的某些时候突然旋转了360°。而且这一变化总是在同一地点上发生。发生了什么?atan2()返回一个介于+π到-π之间的值(介于-180到180之间的角度)。这意味着如果当前的弧度十分接近+π的话,如果它再变大一点,就立马变成-π了。
-π和+π这两个点在圆中其实是一个点,但是你的混合算法并不能意识到这一点,它会认为你旋转了360°。
解决这个问题,你需要在到当角度跨过阈值的时候,手动调节飞船的角度。添加一个新的属性吧:
1 |
|
然后再一次修改旋转的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
现在你在角度大于π和小于-π的时候都让它们返回到既定范围之内。
跑一下程序。这一次你的飞船完美完成旋转!
用三角学找到你的目标
这是一个很好的开始-你有一个顺滑旋转的飞船!但是现在这个飞船还是无忧无虑的状态,让我们给它添加一个敌人:一门大炮!
在GameSceje里添加两个新的属性吧
1 2 |
|
在didMoveToView()里设置这些sprite:
1 2 3 4 |
|
(注:还记得你之前设置skView.ignoresSiblingOrder = false吗?这确保了sprite你能按照被添加进parent的顺序被绘制。别的方法也能达成这个效果-比如说更改zRotation-但是这是最简单的方法)
这个大炮包含了两个sprite,固定的基底和会对准飞船的炮。跑一下程序,你会看到一个崭新的炮台出现在屏幕中央:
现在给这个炮台一个目标!
你需要让炮台永远对着飞船。为了让它工作,你需要找出当前炮台和飞船之间的角度。
这和你调整飞船的朝向的操作基本差不多。不同在于,这次的三角形不再是来自飞船速率的矢量,取而代之的时两个sprite的中心距离:
同样的,你能有atan2()函数来计算两者之间的角度,添加如下的代码:
1 2 3 4 5 6 |
|
用deltaX和deltaY来表示两个sprite之间x和y轴的距离。你用这两个值来得到两者之间的角度。
在以前,你需要用一个补偿值(90°)来矫正sprite。记住,atan2()计算的时相对于0°基线角度。而不是在三角形中的角度。最后你可以添加并且调用新的方法,在update()方法里调用它
updateTurret(deltaTime)
跑一下程序。炮台现在永远都会瞄准飞船了。看吧,很简单吧,这就是三角学的强大!