0%

Canvas小游戏初探之Flappy Bird

当你玩Flappy Bird的最高分只能达到3分的时候…当然是选择自己写一个啦!


故事并没有这么潇洒啦!其实是这样的,我一直觉得,Chrome在断网情况下出现的小恐龙游戏是一个非常不错的设计,对用户断网的不快心情起到了非常好的安慰剂效果。在某次聊天中我顺口提到了这只小恐龙,对方也顺口一提——“你也可以自己写一个”,于是记性很好的我就总惦记着这回事儿。当终于有空的时候,我照着w3schools的Game Tutorial开始学习,学着学着就发现demo和flappy bird更像,于是就在此基础上完成了我的第一个Web小游戏!撒花~至于Chrome的小恐龙么,下次一定!

在完善代码的过程中,我解决了好几个bug,当然…都是我自己写出来的bug。有些问题的发现和解决不太容易,常常是盯着想了很久才发现问题出在哪儿,所以就把这些心路历程都记录一下,希望对你有帮助。

对了,项目的地址在这里,也可以在这里在线玩会儿。

代码实现

概述

游戏的核心在于动画的实现,要实现动画就要一帧一帧地将所有需要的内容画上去,因此可以使用setTimeInterval()来一帧帧地刷新,每次刷新都要清除画布、适当地移动一些元素的位置、重新绘制,这样它们就动起来了。

整个游戏分为四个主要场景:等待时、小鸟飞行时、小鸟下坠时、游戏结束时。不同场景用不同的计时器进行刷新,由于要对场景进行切换,所以需要使用clearInterval()在特定的条件下取消上一场景,再用另一个setInterval()开启新一场景。

元素的绘制使用canvas的drawImage(),元素的移动则只需修改其x坐标或y坐标即可。

元素绘制

地面的移动

为了让底部图片能够一直向左移动,采用一种特殊方法:绘制时从x=0绘制并排连续的两张图片,当第一张图片完全移出画面时,再从x=0处绘制一遍。

柱子的处理

柱子初始化为数组,每隔一段时间就需要添加靠右的柱子并移除画布外的柱子。上下柱子是两个component,其高度和间隔为一定范围内的随机数。为保持柱子的宽高比,绘制的柱子的实际高度是一样的,只是通过修改y的值来调整可视高度。

小鸟的动作和重力模拟

小鸟的扑棱动作被分解为三部分,按照时序切换图片即可。
重力模拟则用物理上学的v=v0+gt公式,设置gravity=0.3speedGravity=0,分别代表重力和由重力产生的向下的速度。每次刷新都让坐标y加上speedGravity构成新的坐标,而speedGravity则需要加上固定值gravity来加速。
有了重力,小鸟就可以借助修改speedGravity来完成向上跳的动作。每次点击,将speedGravity修改为一个负值,则可迅速得到向上的位移。

分数的计算和展示

分数为小鸟经过的柱子个数除以2(因为我把上下分开了),由于在移动过程中画布外的柱子被obstacles给移除了,因此不能直接用所有柱子数减去右方柱子数来计算。一种比较巧妙的做法是,在每一帧查看每个柱子右端是否和小鸟左端在同一竖线上,如果有则将“得分柱子数”加1。这种做法能成功的原因在于,画面一直在刷新,每个柱子至多有一次机会能满足条件,而该条件刚好既能判断小鸟是否经过了该柱子,又能作为计数来使用。
分数的展示则要对数字进行分割,使用相应的图片替换即可。

碰撞检测

包括碰上柱子和碰上地面的检测。
碰柱子的检测要注意除了常规碰撞外,还有小鸟飞到画布外面的碰撞检测。由于柱子不是无限长度的,因此当小鸟飞到画布外的柱子之上时,常规碰撞会失效。我想了挺久,最开始是类似上面分数计算的判断,没用。后来发现一种有用的方法:如果刚好碰上的那一帧无法检测,那我就检测它的下一帧,即此时小鸟的x处于柱子的xx+width之间,没想到还真的可以。

碰上地面的检测则直接判断y+height是否大于阈值即可,不过为了让小鸟最多下降到“地面”而非“地下”,需要一些额外的设置。

场景绘制

在每个场景下,如果包含需要移动/变化的元素,则所有出现的元素都要update(),因为每一次移动/变化都会进行画布清除和重绘。而需要移动/变化的元素需要在update()的基础上通过坐标修改来进行自己的移动/变化。

开场等待时

需展示(update())的元素有:背景图、地面、小鸟。
需要额外变化的元素有:一直左移的地面、循环上下移动的小鸟。

小鸟飞行时

需展示(update())的元素有:背景图、柱子、地面、小鸟、分数。
需要额外变化的元素有:一直左移的柱子和地面、受重力下降的小鸟、不断变化的分数。

小鸟下坠时

需展示(update())的元素有:背景图、柱子、地面、小鸟、分数。
需要额外变化的元素有:一直下坠的小鸟。

游戏结束时

由于没有需要移动/变化的元素,因此所有需要展示的元素都不需要update(),只需新增一个重新开始按钮即可。

场景切换

初始化 -> 开场等待

  • 触发条件:load
  • 需要做的工作:
    • 修改鼠标点击事件触发的函数
    • 开始开场等待场景

开场等待 -> 小鸟飞行

  • 触发条件:鼠标点击
  • 需要做的工作:
    • 停止等待场景
    • 修改鼠标点击事件触发的函数
    • 开始小鸟飞行场景

小鸟飞行 -> 小鸟下坠/游戏结束

  • 触发条件:撞到柱子/地面
  • 需要做的工作:
    • 停止小鸟飞行场景
    • 当撞到柱子时,开始小鸟下坠场景;当撞到地面时,开始游戏结束场景

小鸟下坠 -> 游戏结束

  • 触发条件:撞到地面
  • 需要做的工作:
    • 停止小鸟下坠场景
    • 开始游戏结束场景
    • 修改鼠标点击事件触发的函数

游戏结束 -> 开场等待

  • 触发条件:点击按钮
  • 需要做的工作:
    • 停止游戏结束场景
    • 修改鼠标点击事件触发的函数
    • 开始开场等待场景

遇到的问题

错误1

  • 现象
    撞到柱子后背景和柱子还是在移动。

  • 不解之处
    取消updatePlay()的定时器并开启updateFlop()的定时器,前者会将元素重绘并移动,但后者只是纯粹的重绘,为什么x坐标还会左移?

  • 如何找到bug
    component里加了个draw()方法,当位置无需变化的情况下调用draw()而非update()居然可以了。

  • 原因
    updatePlay()里面的moveLeft()不是x--而是speedX=-1,因此即使停了updatePlay()定时器,这些元素的speedX还是-1。

  • 解决办法
    要么让moveLeft()不要有副作用,要么就记得及时修改状态!

错误2

  • 现象
    触底的小鸟不是停在地面而是更往下了一些,导致小鸟显示不完整。

  • 不解之处
    hitBottom()的判断并没有错误,为什么它的y会比阈值y来得更大呢?

  • 如何找到bug
    调整gravity大小或flop的下降数值,有时候会让小鸟在底部的位置发生变化,居然还不一样的吗?

  • 原因
    当hitBottom时,小鸟的y可能是不同的值。比如说阈值是300,触发hitBottom的值可能是301、310等等,所以就会出现结束的y的值不一样的效果。

  • 解决办法
    y值大于阈值的时候直接将y设定为阈值。

错误3

  • 现象
    想让过柱子得分的阈值x和消除冗余障碍的阈值x不一样,改成如下写法后,当跳过一个柱子后就卡住了…而且电脑风扇一直猛转…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 之前让小鸟在最左边,刚好可以在x<0的时候把两个事情都做了
for (var i = 0; i < this.obstacles.length; i++) {
if (this.obstacles[i].x + this.obstacles[i].width <= 0) {
this.count += 0.5;
i--;
} else {
this.obstacles[i].update();
this.obstacles[i].moveLeft();
}
}

// 改写成这样
for (var i = 0; i < this.obstacles.length; i++) {
if (this.obstacles[i].x + this.obstacles[i].width <= this.cube.x) {
if (this.obstacles[i].x + this.obstacles[i].width <= 0) {
this.obstacles.shift();
}
this.count += 0.5;
i--;
} else {
this.obstacles[i].update();
this.obstacles[i].moveLeft();
}
}
  • 不解之处
    我就多加了个判断条件,怎么就卡住不动了?

  • 如何找到bug
    换了几种遍历的写法以及得分计算的写法后,发现得分显示错误,得分并没有在每经过一个障碍物的时候+1,而是随着每一帧画面一直快速增加…不过直到这时我还不清楚为什么…直到我看了别人的代码中关于得分的判断条件是等于某个值,我才意识到应该是得分计算出错了。

  • 原因
    之前的判断是——因为小鸟的位置和画布左端相近,所以我直接在删除冗余obstacles处顺便更新scoreobstaclesshift走两个就说明他们已经出画面了,那我的score就+1。但一旦把二者位置分开并多加一个判断就不行了,原因在于score的计算方法出错了,不能用“柱子右边小于小鸟左边”这个条件来累计,因为我们是每一帧都要去判断一下,所以在柱子一旦开始往小鸟左边走的时候,该柱子一直都是满足条件的,如果这么加的话,每个左边儿的柱子的每一帧都会加上去,自然就会一直增加了。

  • 解决办法
    正确的判断经过的方法是“柱子的右边等于小鸟的左边”…不过可能还是要考虑向左移动速度和刷新频率能否让二者刚好相等的问题。

错误4

  • 现象
    在判断hitBottom后的stopPlaying()drawRestart(),但是画面上并没有出现restart图片。

  • 不解之处
    为啥不行?

  • 如何找到bug
    直觉告诉我,画的restart可能被别的覆盖了,于是加了两个log测试一下。当小鸟下降时一直在打印222(一帧一帧地),在碰地瞬间打印111后又打印了222,那就破案了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function update() {
for (var i = 0; i < myGameArea.obstacles.length; i++) {
if (myGameArea.cube.hitWith(myGameArea.obstacles[i])) {
myGameArea.stopPlaying();
myGameArea.startFlopping();
}
}

if (myGameArea.cube.hitBottom(myGameArea.canvas)) {
myGameArea.stopPlaying();
myGameArea.drawRestart();
console.log(111);
}
myGameArea.clearCanvas();
myGameArea.updateBackground();
myGameArea.updateCube();
myGameArea.updateObstacles();
myGameArea.updateBase();
myGameArea.updateScore();
myGameArea.frameno++;
console.log(222);
}
  • 原因
    说明stopPlaying()之后下面的代码继续跑直到跑完(这很正常!),还是把其他元素update了一遍,因此覆盖了画上去的restart。

  • 解决办法
    stopPlaying()后return,让它后面的代码不跑就行了。

错误5

  • 现象
    restart是出现了,但是小鸟没有掉到最下端,而是有点悬停在上面。

  • 不解之处
    和错误3的现象差不多,但是通过错误3的解决方案已经解决了,咋还有呢?

  • 如何找到bug
    再去看了看hitBottom()代码。

  • 原因
    hitBottom()只是修改了y等于阈值,但是并没有去刷新帧,而之前之所以能成功,是因为在解决错误4之前那里没有return,它检测到hitBottom并修改y值后会再刷新一次所有的内容!巧了吧!

  • 解决办法
    要在hitBottom之后再刷新一次小鸟,但由于canvas不能单独刷新一个元素,而应该将canvas清零再重新画(否则就会拖影),因此做了如下改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function updatePlay() {
for (var i = 0; i < myGameArea.obstacles.length; i++) {
if (myGameArea.cube.hitWith(myGameArea.obstacles[i])) {
myGameArea.stopPlaying();
myGameArea.startFlopping();
}
}
if (myGameArea.cube.hitBottom(myGameArea.canvas)) { // 这里判断了
myGameArea.stopPlaying();
}
myGameArea.clearCanvas();
myGameArea.updateBackground();
myGameArea.updateCube();
myGameArea.updateObstacles();
myGameArea.updateBase();
myGameArea.updateScore();
myGameArea.frameno++;
if (myGameArea.cube.hitBottom(myGameArea.canvas)) { // 这里才开始画restart
myGameArea.drawRestart();
}
}

function updateFlop() {
if (myGameArea.cube.hitBottom(myGameArea.canvas)) { // 这里判断了
myGameArea.stopFlopping();
}
myGameArea.clearCanvas();
myGameArea.updateWhenFlop();
if (myGameArea.cube.hitBottom(myGameArea.canvas)) { // 这里才开始画restart
myGameArea.drawRestart();
}
}

错误6

  • 现象
    由于柱子不是无限长度的,所以有一个bug——当一直点鼠标直到小鸟飞到非常上面,也就是说小鸟在所有上柱子的上面时,不会触发hitWith(),游戏仍然会继续。为解决bug,在hitWith()中新增了一个判断。在测试中,小鸟一直飞到上面后会如预期地掉下来并出现restart按钮(非常完美),但是一旦点击restart按钮后,画面就会一闪一闪地,交替出现两个画面:游戏结束绘制restart的画面和waiting的画面。

  • 不解之处
    这个bug我解决了一天…感觉所有触发的机制都对,逻辑也对,但就是不行。

  • 如何找到bug

    • 首先是怀疑drawRestart()地方是不是有变量没有还原回初始化的状态。
    • 然后怀疑是不是不该用错误5的那种解决办法。
    • 然后在updateFlop()里加了个log,发现鸟飞上天的情况下,即使已经画面中停止下坠了,console仍然一直在log东西,wth??
    • 然后在updateFlop()的hitBottom语句里面加了个log,和上面一样,即使画面中已经停止下坠了,console仍然一直在log东西,这就更令人迷惑了,你既然一直在hitBottom,那stopFlopping()就会一直执行啊,咋还在刷新flop画面呢??
    • 然后在stopFlopping()里加了个log,和上面一样,即使画面中已经停止下坠了,console仍然一直在log东西,what??你确实一直在stopFlopping(),怎么就停不下来呢??
    • 后来去放缓flop速度,在正常情况下测试startFlopping()stopFlopping()功能,都没问题。
    • 契机在于,我在hitWith()内的飞上天的情况下写了个console.log("whoops"),在飞上天情况下碰撞了,它输出了两次"whoops";而如果在hitWith()内的另一种正常碰撞返回true的情况下,它只会输出一次。
  • 原因
    正常的碰撞情况会触发一次hitWith()就结束了,飞上天的碰撞情况下会触发两次hitWith()才结束,再去看updatePlay()代码的hitWith判断内的内容:stopPlaying()startFlopping(),在里面log会发现会输出两次。那么bug就找到了,由于hitWith()会在飞上天情况下被触发两次,所以就会进行两次stopPlaying()startFlopping(),第二次的startFlopping()返回的flopTimer会覆盖第一次的flopTimer,导致即使clearInterval(flopTimer)也只能停掉第二个计时器,停不了第一个计时器。

  • 解决办法

    • 最开始想,那我的hitWith()就在飞天的情况下第一次不返回true,第二次才返回true,用一个外部变量来判断是第一次还是第二次。不过这样不好。
    • 后来想到,既然是设置了两次计时器,那事实上应该只设置一次的,所以最好就是,在设置计时器的时候先判断一下。不过还得注意,restart的时候,计时器还得初始化一下,不然restart后就设置不了计时器了。

一些其他方面的收获

hexo部署方法

因为想让我的Flappy Bird可以有在线玩耍的地址,于是想到把它部署到博客上,因此学了下如何新建一个菜单项&让博客特定页不被所选主题给渲染。

新建菜单项:

  • 在主题内的_config.yml中找到menu并按照格式添加菜单项
  • 在source下新建同名文件夹,使用命令hexo new page "name"
  • 如果切换成了别的语言模式,记得去themes/next/languages/xx.yml添加对应的菜单项显示名称

让特定内容无需渲染:

  • 在主题外的_config.yml中找到skip_render并按照格式添加需要忽略的路径

移动端适配方法

在博客部署完小游戏,睡前临时起意用手机访问了该地址,发现触碰屏幕并不能触发click事件,遂在第二天去研究了移动端适配。发现一些小技巧,也发现各端不统一真是一个很麻烦的问题。

  • 触碰屏幕并不是不会触发click事件,而是不会触发xxx.onclick = xxx这种写法的click,如果写成xxx.addEventListener("click", xxx)就可以了
  • 但是移动端的click事件会有300ms延迟的问题,目前已有一些解决办法:用诸如zepto/FastClick之类的库封装好的click、禁止缩放(包括官方的touch-action: manipulation/user-scalable=no和一些hack)
  • iOS和Android有许多不同的地方,大多数情况下iOS都是比较难搞定的一方

移动端测试方法

查到了一些方法,不过我发现了另一个方便的工具!可以用VScode的Live Server插件,只要电脑和手机处在同一个局域网,就可以访问它给的地址来进行Web端和移动端的调试了,而且还有热部署功能!用来调试一些简单的页面时用它就可以了。

域名绑定方法

想捣鼓一个个人域名,加上前段时间申请了Github的Student Pack,先是去namecheap找域名申请未成功,遂去name找了个域名,如愿地获得(白嫖)了一年的域名使用权嘿嘿!有一些需要完成的操作:

  • 为域名添加几条解析内容
    • 对于name上来说,页面在MYDOMAINS -> Domain Details -> Manage DNS Records
    • 添加解析1:TYPE=CNAME,HOST=@,ANSWER=xxx.github.io
    • 添加解析2:TYPE=CNAME,HOST=www,ANSWER=xxx.github.io
  • 设置CNAME
    • xxx.github.io的仓库内根目录下添加CNAME文件,内容为域名
  • 设置个人域名
    • xxx.github.io的仓库的Setting -> Custom Domain内填入域名

用Safari访问时出现了反复redirect导致无法访问的问题,大概是由于安全方面的原因,我将Setting内的Enforce HTTPS勾选上就可以正常访问了。
现在你就可以通过 https://chenyang.works 来找我啦!