开发者必读:游戏中的三角学(上)

2015年10月16日 13:05 0 点赞 0 评论 更新于 2025-11-21 19:12

想着要做数学了,你是不是浑身都冒冷汗呢?是不是因为搞不定数学就打算放弃游戏开发者的职业生涯了?千万别这样,数学其实很有趣,这个教程会证明给你看。

我想分享一个小秘密:作为App开发者,其实并不需要了解很多数学知识。在职业生涯中,我们要做的绝大多数数学工作和日常生活中用到的数学差不多。

在做游戏时,在技能树上点一些数学技能会非常有用。你不需要像牛顿和阿基米德那样聪明,但对三角学的理解加上一些常识,会让你在游戏开发之路上走得更远。

在这个教程里,你将学习一些在游戏开发中可以运用的三角学方法,然后在一个用Sprite Kit开发的射击游戏里应用这些知识并进行实践。如果你没用过SpriteKit或者使用的是其他游戏引擎,也不用担心,本教程中介绍的数学知识在任何游戏引擎里都适用。你不需要任何基础知识,教程会一步步耐心地教你。如果你已有一些基本知识,这个教程会让你对三角学的认识更上一层楼。那我们开始吧!

开篇:一切都是关于三角形

听起来可能有点夸大其词,但简单来说,三角学就是关于三角形的计算。你可能没意识到,游戏里其实充满了三角形。举个例子,想象有一个飞船游戏,你要计算飞船之间的距离。你知道每艘飞船的X、Y坐标位置,但怎么计算它们之间的距离呢?很简单,你可以从一艘飞船的中心画一条线到另一艘飞船,然后使用勾股定理。

总结来说,三角学是用于计算三角形的角度和边的长度的数学,在游戏开发中经常会用到。例如,在飞船游戏里,你可能需要实现以下功能:

  • 一艘飞船向另一艘飞船的方向发射激光。
  • 让一艘飞船向着另一艘飞船的方向移动。
  • 当敌方飞船靠得太近时发出危险警告。

所有这些功能都可以用三角学来实现。(此处略过勾股定理衍生的sin、cos、tan、arcsin、arccos、arctan三角函数介绍)

创建项目

由于Swift是新语言,语法更新频繁,所以要确保使用的是Xcode 6.1.1或之后的版本。然后,创建一个SpriteKit项目,使用Swift语言。

下载本教程的资源包,里面包含图片和音效资源。解压后,将图片文件拖到你的Image.xcassets中,用于制作Sprite。可以删除Image.xcassets里默认的飞船照片,不再需要它。

接着添加声音,你可以直接把声音文件拖拽进Xcode,但要确保选择“Create groups”。

初步准备完成,让我们开始写代码吧!

起航

因为这是一个简单的游戏,你将在GameScene.swift文件里完成大部分工作。目前,这个Swift文件里有很多你不需要的内容,而且整个游戏的方向也不对,我们先来解决这些问题。

转换设备到水平方向

打开target设置面板,选择TrigBlaster这个target。然后,在Deployment Info里将设备方向设置为只允许LandScape Left ,这样App就会在水平方向运行。当前App在GameViewController.swift里通过GameScene.sks加载一个空的scene,屏幕中央有一个“Hello world”的文本。

我们把GameScene.swift的内容替换为:(此处原文未给出具体替换内容)

运行程序,你会发现除了紫色背景,什么都看不到。接下来,把飞船添加到scene里,修改GameScene如下:(此处原文未给出具体修改内容)

如果你之前使用过SpriteKit,这些操作应该很基础。playerSprite是飞船的sprite,被放置在屏幕的右下方。要注意,在SpriteKit的坐标系统中,下方是Y = 0,而在UIKit里上方是Y = 0。

运行程序,效果如下:(此处原文未给出具体效果描述)

为了移动飞船,你将使用iPhone的加速计。遗憾的是,在模拟器上无法使用加速计,所以需要在真机上进行测试。你可以通过倾斜设备来调用加速计,这就是我们限制设备只能处于Left Landscape状态的原因,避免倾斜时屏幕自动旋转。

由于有Core Motion的存在,使用加速器变得很简单。获取加速计数据有两种方法:

  • 设定一定频率让加速计回调传来数据。
  • 在需要的时候征用数据。

Apple建议,除非对时间要求非常精确(如导航仪和测量工具),否则不要把大量数据塞进程序,因为这样会消耗大量电池。

你的游戏已经有一个合理的按一定频率调用数据的地方,即update()方法,它会在游戏每帧刷新时被调用。

首先,在GameScene.swift里添加以下代码:(此处原文未给出具体代码)

接着,添加以下属性:(此处原文未给出具体属性)

你需要这些属性来追踪加速计的数据,只需要追踪x和y轴的信息,z轴在这个游戏中用不到。

然后,添加以下方法:(此处原文未给出具体方法)

这个方法用于在加速计可用时开启和关闭它。didMoveToView()是开启加速计的合适位置,在addChild(playerSprite)下面添加代码:(此处原文未给出具体代码)

对于停止加速计,合适的位置是一个类型的deinit方法:(此处原文未给出具体代码)

接着,添加以下方法来改变飞船的位置:(此处原文未给出具体方法)

加入fiter很有必要,这样可以让得到的数据更加平滑。motionManager的accelerometerData在数据未获取到时为nil,所以使用if let确保只在有数据时进行后续计算。(更多关于缓速过滤的知识,可查看Low - pass filter)

现在你已经获取了设备的旋转角度信息,如何让飞船随之运动呢?基于物理运动的游戏通常按以下步骤实现:

  1. 物理设备传输加速计的数据。
  2. 给飞船的速度加上一个新的加速度,使物体能根据加速计的角度加速和减速。
  3. 将新的速度赋予飞船,让它移动。

这种模拟是牛顿总结的。你需要更多属性来追踪飞船的速度和加速度,由于SKSpriteNode已经帮你追踪了sprite的位置,所以可以省去这一步。(注:如果使用SKSpriteNode的SKPhysicsBody属性,就能自动追踪和更新Sprite的速度和加速度,但为了学习三角学,在本教程里需要自己进行数学计算)

给类型添加以下属性:(此处原文未给出具体属性)

最好给飞船设定速度限制,否则会难以控制。不加限制的加速度会使飞船难以操控,所以添加以下代码:(此处原文未给出具体代码)

这定义了两个限制:最大加速度和最大速度。

现在在updatePlayerAccelerationFromMotionManager:的if let下面添加代码:(此处原文未给出具体代码)

加速计提供的数值范围是 - 1到1,乘以最大值可以将速度限制在范围内。

你快完成了,最后一步是将playerAcceleration.dx和playerAcceleration.dy的值传给飞船,通过update()方法实现。这个方法每帧调用一次(每秒60帧),是更新数据的好时机。

添加updatePlayer()方法:(此处原文未给出具体方法)

如果你之前做过游戏,上述代码应该很熟悉,下面按步骤解释代码的作用:

  • 把当前的加速度赋给速度。加速度以point/s的方式表示(实际上每秒会被均分,不用担心)。由于update()方法的执行频率远大于一秒一次,为了弥补这个差异,添加了一个delta time的系数,否则飞船会比正常速度快60倍。
  • 把速度限制在最大最小速度的范围内。
  • 速度乘以时间得到应该移动的距离,从而确定飞船现在的位置。
  • 把新的坐标限制在Scene的bounds之内。

此外,需要在不同于update()的更新频率下更新飞船状态,所以要追踪update的间隔时间,为此添加一个新属性:(此处原文未给出具体属性)

替换update()的实现为:(此处原文未给出具体实现)

通过currentTime减去lastTime算出deltaTime,为安全起见,让deltaTime和1/30取最大值。这样,即使App的帧率因某些原因下降,飞船也要等到下一次屏幕刷新时才重新计算位置。

调用updatePlayerAccelerationFromMotionManager()方法通过加速计数据计算加速度,最终调用updatePlayer()方法更新飞船的位置,使用得到的deltaTime计算速度。

用真机运行程序,现在你可以通过旋转设备让飞船运动了!

最后一件事:打开GameViewController.swift,找到:

skView.ignoresSiblingOrder = true

改成:

skView.ignoresSiblingOrder = false

这会禁用一个渲染上的优化,但能保证sprite按照添加顺序绘制,在后续会有用。

开始三角学!

如果你跳过前面的内容直接进入这一节,这里是当前的项目。到目前为止,你还没有用到任何三角学知识。

如果能让飞船朝着它飞行的方向,而不是一直向上,那就太棒了。为了旋转飞船,你需要知道旋转的方向,但你只知道速度的矢量,如何从矢量得到速度的方向呢?想想勾股定理!

重新组织一下:(此处原文未给出具体组织内容)

在updatePlayer()下面添加两行代码:(此处原文未给出具体代码)

运行程序,你会发现:(此处原文未给出具体发现内容)

看起来不太对,虽然飞船旋转了,但方向不是它移动的方向。原因是飞船图片原始状态是朝上的,而未旋转时默认角度是0°,两者不契合。所以,在计算angle时添加代码:(此处原文未给出具体代码)

真机测试,情况似乎更糟了,缺了什么呢?

弧度,角度和点

通常人们会把角度的值限定在0 - 360°之间。在数学领域,角度常用弧度来定义,π是弧度的单位。弧度被定义为绕单位圆一周所走的距离,完整走完一个单位圆,走过的距离是2π。

注意图中黄色线(半径)和红色线(弧)的距离相等,这个让两者一致的角度称为一个单位弧度。就像把角度想象成0 - 360之间的值一样,数学领域把弧度视为0 - 2π之间的值。很多电脑的函数运算使用弧度,因为在计算时弧度比角度更方便。SpriteKit同样使用弧度来计算sprite的旋转,之前使用的atan2()函数返回的是弧度值。

既然会同时用到角度和弧度,就需要一个在两者之间转换的方法。转换方法很简单:因为2π相当于360°,所以π就是180°,弧度转角度是先除以π再乘上180,角度转弧度是先除以180再乘上π。

C语言的数学库(在Swift里自动使用)有一个常量M_PI,代表π。由于Swift对类型管理严格,使用这个常量不太方便(它是Float类型,而通常需要的是CGFloat类型),所以可以定义自己的常量。在GameScene.swift里添加以下代码:(此处原文未给出具体代码)

现在定义另外两个常量,让弧度和角度的转换更方便:(此处原文未给出具体常量)

最后,用定义好的常量重构updatePlayer的代码:(此处原文未给出具体代码)

重新运行程序,你会发现飞船的方向正常了。

墙上反弹

你已经让飞船根据加速计移动,并使用三角学让飞船朝向速度方向,这是一个很好的开始。但让飞船在屏幕边缘卡住不是一个好的解决方案,我们可以为它设计一个反弹动画!

首先,删除updatePlayer()里的两行代码:(此处原文未给出具体代码)

替换为:(此处原文未给出具体代码)

检查飞船是否撞到屏幕边缘,如果是,将bool类型设置为true。接下来,为了制造弹射效果,可以简单地反转速度和加速度。为updatePlayer添加以下代码:(此处原文未给出具体代码)

如果bool类型为true,就反转当前方向的速度和加速度。

运行程序,反弹成功了,但看起来有点太有力了。问题在于,不能指望飞船反弹后还保持和之前一样的速度。为此,我们定义一个新的常量:(此处原文未给出具体常量)

现在,替换刚才添加的代码为:(此处原文未给出具体代码)

你将速度和加速度乘上了一个阻尼常量BorderCollisionDamping,这个常量代表冲撞后剩余的能量。上述代码中,飞船在冲撞反弹后保留了40%的能量。如果将这个数字设置为大于1的数,飞船在冲撞后还能获取额外的能量。

你可能会注意到一个问题:当把屏幕竖起来,飞船在屏幕底部不断下降上升,速度会越来越慢,方向也慢慢固定。用atan2()通过x轴和y轴的向量大小来确定飞船方向,在飞船速率(矢量x、y较大)时效果不错,但当atan2()用于非常小的值时,得到的弧度变化会非常小。

解决这个问题的方法是让速度很小时不改变方向,这就需要用到勾股定理。但目前你存储的是飞船的速率(矢量),而不是速度(标量),可以通过速率计算速度。

简而言之:(此处原文未给出具体内容)

删掉之前的代码:(此处原文未给出具体代码)

替换为:(此处原文未给出具体代码)

重新运行程序,你会看到飞船在边缘地带更稳定了。如果你想知道40这个数的来源,答案是经验。你可以用NSLog()打印一些速度出来,自己总结规律。

混合角度,形成柔和的旋转

显然,编程时解决一个问题可能会影响其他方面。如果你降低飞船速度直到它停止,然后向飞船方向的反方向旋转设备,飞船不会像之前那样优美地掉头。由于之前限制了飞船在速度很慢时不掉头,这个动画消失了。虽然这只是个小细节,但却是制作伟大游戏的关键。

解决办法是让飞船角度不要马上变化,而是将变化混合到一系列连续帧中。这样的重构还能防止飞船在速度极快时不旋转。“混合”听起来很复杂,其实很好实现,需要在游戏刷新时持续追踪飞船角度,所以给GameScene添加一个新属性:(此处原文未给出具体属性)

在updatePlayer()里添加新的实现:(此处原文未给出具体实现)

变量playerAngle使用一个混合参数RotationBlendFactor同时乘以旧的角度和新的角度。简而言之,新的角度只提供飞船角度20%的旋转。随着时间推移,更多角度被赋予飞船,飞船慢慢转向新的角度。

现在分别顺时针和逆时针旋转飞船,你会发现飞船有时会突然旋转360°,而且总是在同一地点发生。这是因为atan2()返回的值介于 + π到 - π之间(即 - 180到180之间的角度)。如果当前弧度接近 + π,再变大一点就会变成 - π。 - π和 + π在圆中是同一个点,但混合算法没有意识到这一点,会认为旋转了360°。

解决这个问题,需要在角度跨过阈值时手动调节飞船的角度。添加一个新属性:(此处原文未给出具体属性)

然后再次修改旋转的代码:(此处原文未给出具体代码)

现在,当角度大于π或小于 - π时,让它们回到既定范围。

运行程序,这次飞船能完美完成旋转!

用三角学找到你的目标

这是一个很好的开始,你有了一个能顺滑旋转的飞船!但现在飞船还是无忧无虑的状态,我们给它添加一个敌人:一门大炮!

在GameSceje里添加两个新的属性:(此处原文未给出具体属性)

在didMoveToView()里设置这些sprite:(此处原文未给出具体设置内容)

(注:还记得之前设置skView.ignoresSiblingOrder = false吗?这确保了sprite能按照添加顺序绘制。也可以通过其他方法实现,如更改zRotation,但这是最简单的方法)

这个大炮包含两个sprite,固定的基底和会对准飞船的炮。运行程序,你会看到一个新炮台出现在屏幕中央。

现在给炮台设定一个目标!你需要让炮台始终对准飞船,为此要找出当前炮台和飞船之间的角度。这和调整飞船朝向的操作基本相同,不同之处在于,这次的三角形是由两个sprite的中心距离构成,而不是飞船速率的矢量。

同样,可以使用atan2()函数计算两者之间的角度,添加如下代码:(此处原文未给出具体代码)

用deltaX和deltaY表示两个sprite之间x和y轴的距离,通过这两个值得到它们之间的角度。

以前,需要用一个补偿值(90°)来矫正sprite。要记住,atan2()计算的是相对于0°基线的角度,而不是三角形中的角度。最后,可以添加并调用新的方法,在update()方法里调用它:

updateTurret(deltaTime)

运行程序,炮台现在会始终瞄准飞船了。看吧,这就是三角学的强大之处!

原文:Trigonometry for Games – Sprite Kit and Swift Tutorial: Part 1/2,译者:Dev端

作者信息

洞悉

洞悉

共发布了 3994 篇文章