手把手教你写一个经典躲避游戏
审核:nightcat

前言
因为作者只是个普普通通的页面仔,并不是从属于游戏行业的开发者。平时会写一些小游戏也只是兴趣使然,脑子里经常会蹦出一些小点子。所以很多知识也只是自己摸索拼拼凑凑来的。
故本文仅在于抛砖引玉,向大家介绍我是如何从零到一,一步一步完成一款能游玩的页面小游戏。如果你本是游戏行业的开发者或是打算步入游戏行业的开发者,建议阅读专业性更强的书籍和学习专业的游戏框架与游戏知识。
提前致谢。
阅读门槛
多少得会点 Typescript
多少知道点 Canvas
多少看过一点阮一峰老师的 ES6 教程
多少还记得一点高中数学
成品图

qq 录屏有点问题,加速了而且色彩也闪来闪去的,文末有在线体验链接,感兴趣的可以去体验一下。
前戏
🔧 初始化开发环境
环境其实不重要,我们只需要一个 canvas 标签和一个支持 typescript 的环境就好了。我这里选用的是最简单迅速的打包工具 Parcel。不需要任何额外配置,直接开箱即用👍。

然后就可以开始引入我们的游戏主体对象了

这里不直接使用 index.ts 来编写游戏内容是为了后续方便增加 UI 界面。通过传递 canvas 组件和配置宽高来 new 一个游戏对象,后续对游戏进程的管理、对画布的渲染都会在这里面实现。

这里随便加了个浅灰色的背景,测试下能否正常渲染

WOW,出现了!这样我们的第一步,开发环境就布置好了。(毫无技术含量 = =
🖼 画布介绍
画布其实就是 <Canvas> 元素,我们可以用它创造了一个上下文,也就是上上图代码中的 ctx,通过调用 ctx 上的 api,我们就可以在画布上绘制出想要展示的内容了。
解决高清屏下模糊的问题
在创建画布时需要考虑的一个点是 DPR 问题,即设备像素比。例如上上图中的代码,我们将 600x600 的画布渲染在一个 600px x 600px 的元素上,在高清屏(DPR >= 2)的场景下,会出现模糊的现象。具体感兴趣为什么模糊的可以自行搜索。总而言之言而总之,要解决在高清屏模糊的问题,我们得将画布等比例放大。

这样在 DPR = 2 的场景下,Canvas 也不会出现模糊的现象😀。

让画布动起来
游戏游戏,不会动那还算游戏吗。所以我们接下来得让画布动起来,这里主要用到的一个 api window.requestAnimationFrame 来告知浏览器尽可能的流畅地(每秒 60 帧)运行我们的游戏。额外需要注意的点是每次重新绘制前都需要先清空画布。

这样我们的画布就以每秒 60 帧的速度在刷新了(虽然现在只有个灰色背景看不出差别。
性能优化
一、多画布渲染
如果你的背景足够复杂,可以考虑单独起一个画布渲染背景。这样就可以不用每秒都需要重新绘制 60 次背景。因为我们这次做的游戏是纯色的背景,所以就单个画布渲染就完事了。
二、离屏渲染
如果你游戏画面很花里胡哨,游戏画面出现了帧数不足的卡顿情况。可以考虑离屏渲染,离屏渲染的原理是创建一个离屏 Canvas 当缓存区,提前把需要重复绘制的内容缓存起来,从而减少 API 调用的损耗,提高渲染效率。具体感兴趣的可以去搜搜,我也没用过。
🧚♀️ 精灵 Sprite
精灵实际上就是一个对象,画布上的每一个独立元素都可以看作是精灵。精灵可以包含位置、形状、行为等各种属性。说再多也没代码来得直观。

这样就实现了一个最基础的精灵抽象类了,它包含了一个元素最基本的位置信息,同时提供了两个方法供画布渲染和更新精灵信息。我们之后的精灵实现都会继承该抽象类开发。
正片
🔫 实现子弹精灵
首先我们要确认一个子弹精灵应该有的属性,除了位置外,还需要子弹的半径和颜色以及移动方向和移动速度。
因为子弹都是随机的,所以子弹的位置半径等都应该是在一个范围内随机生成的。具体的游戏设计上我是这样设定的:
子弹在屏幕外生成,并向目标附近的一定范围移动
子弹半径越大,移动速度则越慢
子弹飞出屏幕外时移除,保持屏幕的子弹数量一定
确定好游戏设定后就可以开始敲代码了,首先得先确定好子弹精灵的功能范围,我们只需要给子弹精灵一个位置,一个大小,还有一个目标。而子弹精灵则需要实现根据目标生成对应的移动方向和移动速度。

子弹的移动方向和移动速度我们先暂时留个 TODO,先把子弹的位置半径等属性搞了。还有,为了后续游戏更容易维护,我们把所有游戏配置相关的数值,统一放在 config 里管理。

接下来就可以按设计一步一步实现就完事了:
首先先生成一个随机的子弹半径

然后再随机生成子弹的位置,这里我们在四个方向的屏幕外的边缘,随机位置生成一个子弹

因为我们还没做玩家精灵,所以先暂时 mock 一个目标。并且搞个数组来添加子弹,后续得控制这个数组的长度来控制屏幕上的弹幕密度,最后方法就是这样了:

至此子弹的位置和半径就有了,接下来实现移动方向和移动速度,回到我们的子弹精灵。首先我们得根据半径算出我们的移动速度,因为是半径越大速度越慢,所以用最大的速度去减半径在半径范围内的比例乘以速度的范围:

速度有了,然后现在得将我们的速度分成水平速度和垂直速度。
首先科普下大伙儿平时都不会用到的方法 Math.atan2 ,这个方法可以获得两个点的角度。贴一下 mdn 的概述:
Math.atan2() 返回从原点(0,0)到(x,y)点的线段与 x 轴正方向之间的平面角度(弧度值),也就是 Math.atan2(y,x)
所以假设我们的目标是原地 (0, 0) 那子弹的坐标就是 (子弹 x - 目标 x, 子弹 y - 目标 y)。

这样我们就能获取到角度了(这里顺便把目标也随机偏移了下,不然直勾勾的就往目标去就很僵硬)
有了角度之后,简单运用一下高中的三角函数知识,就能很轻松的把我们的速度分成水平速度和垂直速度了。

最后再把绘制子弹和更新子弹的方法随便写一下

记得加上游戏每次渲染后还得更新一下,然后把子弹渲染和子弹更新给加上。

最后我们再修改一下更新逻辑,得控制屏幕中的弹幕密度在一个固定的值。都加上后子弹精就大功告成了!

芜湖!一次成功,弹幕出来了!

😃 实现玩家精灵
玩家精灵相对来说属性上会简单很多,老规矩直接上游戏设定:
玩家形状为三角形▲,方向总是朝着移动方向
可以通过键盘 wsad 和 ↑↓←→ 操控
首先第一步,在开始游戏时,初始化玩家精灵

然后第二步开始画三角形,x 和 y 是三角形的重心,再设定一个重心到三个角的距离 d ,然后我们就可以算出三个点的坐标了
A: (x, y - d)
B: (x - Math.cos(30deg) * d, y + Math.sin(30deg) * d)
C: (x + Math.cos(30deg) * d, y + Math.sin(30deg) * d)

之后照着公式加上代码后,保存看看~

有了,一个小三角形就出来了。

因为需要三角形面向移动方向,所以我们还得加上旋转角度,因为 rotate 默认是基于 (0, 0) 点旋转的,而我们需要基于三角形重心进行旋转,所以我们先使用 translate 进行偏移,偏移到重心旋转完再移动回去,最后别忘了将旋转复位。

接下来可以处理玩家控制移动了。首先我们得支持斜着移动,例如左上右上等等,总共八个方向,所以我们加个字段表示玩家目前向哪个方向移动。
这里我们采用二进制的方法,用 0001 代表上,0010 代表下,0100 代表左,1000 代表右。而且方向和方向可以组合(例如 0101 代表左上),这样我们就能用一个数字表示八个方向了。

我们只需要在按下按键的时候或( | )一下对应的位数,再松开按键的时候再与( & )一下对应的位数取反(~)。就能轻松记录当前前进的方向了。

之后再更新的时候,再按方向去更新位置和旋转角度就大功告成了。


别忘了还有边缘检测,避免玩家跑到区域外。

保存代码,让我们测试一下!

有了!瞧这灵活的小箭头,但是现在碰到子弹没发生什么事,离完成就差最后一步了!
💥 碰撞检测
判断三角形是否与圆形碰撞,我们需要判断两种情况,一种是圆心在三角形中,则发生碰撞。另一种则需要判断圆心到三条边的距离是否小于半径,如果是则发生碰撞。

第一种比较好判断:圆心是否在三角形的路径内。所以我们得把之前绘制三角形路径的代码单独提取一下,并且之后还会用到几个角,所以把几个角的获取也单独提取成一个方法:

然后我们需要用 isPointInPath 判断一下圆心是否在这个路径内就可以了:

接下来第二种判断就比较复杂了:判断圆心到三条边的距离,这里需要用到向量的知识:
设三角形的边 AB 向量为 v1,角到圆心的 AO 向量为 v2,我们需要求得 AO 在 AB 向量上的投影 AC 长度为 u。

根据向量的点乘公式:

然后我们再将 v1 进行单位化(归一化),既

然后根据三角函数知识,已知 |v2|cosθ 就是我们需要的投影 u,赶紧用代码实现一下:

这里投影 u 也有三种情况(对应下图 123):
第一种是在 A 点左边时 u 是负数,最近的点为 A 点
第二种是在 B 点右边时投影超出边的长度,最近的点为 B 点
第三种就是圆正好在边的正上方,最近的点为 C 点

得到圆心距离边最近的点后,用过两点距离公式算出距离,再判断距离是否小于圆心来检测是否碰撞:

然后在更新子弹时,去判断是否射中玩家了(记得游戏结束后再渲染一次,否则会导致画面停留在碰撞前的一刻,看起来像是 BUG)


测试之后,发现不对劲,因为之前玩家精灵旋转用的是 canvas 自带的 API rotate 旋转的,而之后碰撞检测用的确是未旋转的三角形去判断,所以会出现明明没接触也触发碰撞的情况。
解决办法就是将 rotate 旋转改成实打实的三角形三个角旋转,这里需要用到转轴公式:


搞定,赶紧跑起来试试

耶!已经可以正常游玩了,但是这样干巴巴玩也没意思,接下来我们最后完善一下,加上分数计数。
🏁 计数
因为这就是一个坚持时间长短的游戏,所以我们用秒数来当做成绩。这块没什么难度,就不细说了,需要注意的一点是记录时间不能简单的就取时间戳,因为切换浏览器 tab 时游戏是 rAF 会自动暂停的,然后分数还会一直算。
🕹️兼容移动端
这段是本文写完后加的,考虑到现在很多人都是用手机刷文章,所以决定加上移动端支持。这里有两种实现方案
移动到玩家触碰的位置
增加虚拟摇杆
因为如果使用方案一,玩家的手指会很遮挡到视野,导致游戏体验很差,所以决定采用方案二,加个虚拟摇杆。
摇杆的相关配置项:

实现上其实也很简单,就是在玩家精灵多加个参数,可以选择控制方式,如果是使用触摸控制,则加入摇杆,我们这里默认是将摇杆中心设定在左下角

然后判断如果是触摸控制,则监听触摸事件

然后加个字段记录下手指按住的地方即可

值得注意的是,当我们触摸位置在摇杆中心的时候,玩家是不移动的,这样游戏可操作性就高很多。所以我们加个 getter 方便后续判断:

然后在更新玩家位置时,再根据控制方式不同区分处理,计算手指触碰位置与摇杆中心的角度就是玩家移动的角度:

最后我们再把摇杆绘制到屏幕上就完成了,具体实现也很简单,就是画两个圆,一个是大的背景圆,一个是玩家目前移动方向的摇杆圆。

大功告成!花了不到半个小时完成了兼容移动端,所以一个完善的代码结构和清晰的代码逻辑是非常重要的,能使后续的维护和功能迭代也变得很轻松。

片尾总结
总的来说实现还是很简单的,不算写文的时间做一个这个小游戏差不多一天就能完成。目前来说代码质量还有很大的优化空间,为了方便阅读理解,有多重复的逻辑计算没有提取出来。
思维拓展
目前只是实现了最基本的功能,如果想要拓展,有很多方向可以做。
例如可以增加关卡设计,因为子弹速度子弹密度都是可以动态配置的。或者增加增益道具,例如玩家加速,缩小玩家大小来降低被撞的几率。
还有能和朋友一起玩比自己一个人玩更有趣,可以再加个玩家精灵分别用 wsad 和方向键控制,就能实现本地对战了(印象中四五年前我就做过,两个箭头碰撞还会硬直旋转一秒,增加互动性)。
也可以加个虚拟摇杆🕹️兼容移动端,而且游戏本体就依赖一个 canvas,把 ui 界面整漂亮点再移植到小程序,也许分分钟就火了呢:-D
仓库链接:https://github.com/HZFE/dodger-game

版权声明: 本文为 InfoQ 作者【HZFEStudio】的原创文章。
原文链接:【http://xie.infoq.cn/article/b251b2bca4277a4e66678a2dc】。文章转载请联系作者。
评论