贪吃蛇Canvas小游戏

以儿时热门游戏贪吃蛇作为互动游戏。用户在游戏中可以赢取随机出现的红包(红包概率出现,且一场仅出现一次),并记录用户分数。使用Canvas实现

以下是我制作整个游戏时,遇到的一些问题与解决方法。

活动需求

以儿时热门游戏贪吃蛇作为互动游戏。用户在游戏中可以赢取随机出现的红包(红包概率出现,且一场仅出现一次),并记录用户分数。效果如下所示。
Image text

github地址:https://github.com/Dullly/CanvasSnake

游戏实现

贪吃蛇绘制思路

计算贪吃蛇方向

在游戏开始时,贪吃蛇默认朝右移动,当用户点击游戏区域,则记录这个点为目标点,整个贪吃蛇朝着新的目标点运动。否则继续按照之前指定的方向继续运动。效果如左边所示:
Image text

1
2
3
4
5
6
7
8
9
10
11
function downFN(e){
switch (gameState) {
case 'play':
target.x = e.offsetX;
target.y = e.offsetY;
snake.lastArm.target(target.x, target.y);
break;
case 'dead':
break;
}
}

当用户按下鼠标时,获取当前按下的位置,改变贪吃蛇前进方向。

根据坐标绘制贪吃蛇

根据计算出的各个坐标点,通过 CanvaslineTo() 方法,将这些点连成线段。当线段短到一定程度时,用户便无法通过肉眼分辨出这些是由一段段线段连接而成,从视觉上达到绘制曲线的结果。
在每一次更新Canvas时,都会清除之前画布然后再根据当前坐标点重新绘制贪吃蛇,在肉眼看起来“贪吃蛇”就和真的一样,动了起来。那么这些坐标是如何计算出来的呢?
Image text

贪吃蛇坐标计算

当我们确定好坐标计算的方法后,便开始每一点坐标的计算。如图所示,贪吃蛇坐标值是由endX()、endY()计算而得。通过当前对象中angle角度值,按照如图所示的公式,可以计算得出该点下一次坐标。
Image text

贪吃蛇描边效果

设计稿中贪吃蛇具有描边效果,如图所示。先画一条较粗的线段放在下面,然后在画一条较细的线段,放在粗线段的上面,从视觉上达到线段描边效果。
设计稿中贪吃蛇具有描边效果,如图所示。因为此贪吃蛇是通过Canvas中的许多小线段拼接而成,并非是一条完整曲线,并且Canvas lineTo() (绘制线段方法)不提供线段描边,虽然可以利用矩形的填充方法可以做到类似于描边效果,但如果使用矩形方法拼接贪吃蛇,需要额外计算与保存坐标点,增加不必要的开销,所以思考如何使用线段实现描边效果。
Canvas中有一个save()restore()方法,作用是保存当前环境的状态和返回之前保存过的路径状态和属性。所以我们可以利用这个方法,先画一条较粗的线段放在下面,然后在画一条较细的线段,放在粗线段的上面,从视觉上达到线段描边效果。
Image text

食物、红包绘制思路

食物绘制

如图所示贪吃蛇食物共有三种,并且每一个食物的分值都会随着食物出现的时间而锐减。
因为食物固定三种,所以将三种食物的位置与颜色储存在数组中,使用 drawImage 绘制食物、fillText绘制食物分值。在每一次更新时,自身生
命周期都会减少,当生命周期为0时,该食物被移除。

食物样式:

Image text

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function Apple(x, y) {
this.x = x;
this.y = y;
this.life = appleLife;
this.rotation = 0;
this.index = ~~(Math.random()*100%3);
}
Apple.prototype.update = function() {
this.life--;
};
Apple.prototype.render = function(context) {
context.drawImage(g_source,appleColor[this.index][0],appleColor[this.index][1],27,27,this.x - appleWidth/2, this.y - appleWidth/2, appleWidth, appleWidth);
if (gameState !== 'dead') {
context.save();
context.fillStyle = appleColor[this.index][2];
context.font = '11px pingfang';
context.textAlign = 'center';
context.fillText(this.life, this.x, this.y + appleWidth+5);
context.restore();
}
};

红包绘制

通过代码实现红包动画
红包的实现方式与食物相同,这里主要讲一下动画的实现,针对红包动画效果,首先使用Canvas绘制实现,但是调试出满意、不生硬的过度效果(ease-in-out等)需要较长的时间,针对红包效果与设计师沟通之后通过雪碧图实现动画。
其原理与CSS3雪碧图动画相同。红包射线动画效果的雪碧图共48帧,约29kb。对于整体资源加载影响不大,这里在动画执行完后在等待30帧时间,作为重复动画中的空白过渡期。

使用代码实现效果(生硬):

Image text

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
context.moveTo(-0, -16);
context.lineTo(-0, -16-this.len);
context.moveTo(0, 16);
context.lineTo(0, 16+this.len);
context.moveTo( - 16, 0);
context.lineTo( - 16-this.len, 0);
context.moveTo(16, 0);
context.lineTo(16+this.len, 0);
context.moveTo( - 16, -16);
context.lineTo( - 16-this.len/2, -16-this.len/2);
context.moveTo(16, 16);
context.lineTo(16+this.len/2, 16+this.len/2);
context.moveTo( 16, -16);
context.lineTo( 16+this.len/2, -16-this.len/2);
context.moveTo(-16, 16);
context.lineTo(-16-this.len/2, 16+this.len/2);

使用雪碧图实现(较生动):

Image text

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Bag_w.prototype.render = function(context) {
if(this.img){
//控绘制的间隔、循环
this.num ++;
if(this.num >=47){
this.n++;
if(this.n >=30){
this.num = 0;
this.n = 0;;
}
}
context.drawImage(this.img, 116*this.num,0,116,116,this.x-29, this.y-29, 58, 58);
}
}

其他的思考

贪吃蛇进食

当贪吃蛇与食物的距离小于一个值时,则 “吃”到了该食物。
针对游戏中贪吃蛇碰撞食物、红包等,无需使用复杂的碰撞检测,因为我们仅需得知贪吃蛇是否到达了食物或者红包周围的一定范围,如果是则认为成功了“吃掉”了食物,否则是没有吃到。 所以仅需计算贪吃蛇上每一点与食物、红包之间的距离,
Distance 函数返回的是两点之间的距离,所以当apple(即食物)与蛇的坐标之间的距离小于一个定值 appleWidth 时,我们则认为蛇吃到了该食物。分值增加,并且将该食物移除。(a为循环当中的索引)

距离判断:

1
2
3
4
5
6
7
8
9
if (distance(redbag, {
x: snake.lastArm.endX(),
y: snake.lastArm.endY()
}) < bagWidth*1.2) {
score += redbag.life;
redbags.splice(a, 1);
getRebdag = true;
addScoreSegments();
}

坐标计算思路

这种方法的思路是在Canvas画布每一次更新时,计算贪吃蛇每个点下一步所在位置。
我们知道了如何画出贪吃蛇之后,就想着如何让“它”动起来。关于贪吃蛇的移动方式大概有以下有两种实现方式:
方法①: 计算每一点位置,时时更新:
贪吃蛇的移动其实就是每一点的移动。当更新的频率到达一定速率时,人眼便会认为这是“动态”的。
吃蛇每个点下一步所在位置,如图所示:
Image text

方法②:增加蛇头、删除蛇尾:
此方法核心是只计算蛇头下一步所处位置,在移动时增加
虽然这一方法计算量小,并且所需储存贪吃蛇信息的数据量也很小,但是这一方法只能适用于每段蛇身长度与移动速度相同的情况。当速度大于蛇身段长时,如图所示,头部每次移动的距离大于一段蛇身长度,每次移动时身体会逐渐分开。反之,当速度小于蛇身段时,贪吃蛇会慢慢重合,最后全部挤在一起。如果是像素风格的贪吃蛇,适用此方法。
Image text

性能取舍

使用Chrome中Call Tree查看在js脚本中各个方法所占资源。可以看出,对于方法①,其中“绘制”(左图)所占资源远大于“计算”(右图)。
因为方法②有一定的局限性,所以首选方法①进行实现,但是担心在低端安卓机会出现帧率不高或者卡顿情况。所以观察方法①中各方法开销。
Call Tree可以观察在整个游戏中,各个资源的占用情况。起初我们担心的是由于计算问题导致游戏卡顿,
如图所示,在之后实际测试中,发现在整个游戏实际操作上无阻塞感,并且通过Chrome控制台查看Call tree,看看调用了哪些函数,发现主要时间都消耗在fillText、drawImage上,并非是坐标点相关计算函数。所以采用方法①
Image text

游戏绘制问题

画布跟随旋转

当我们想要旋转蛇头时,整个画布也不断的旋转。游戏中贪吃蛇头部朝向需要与贪吃蛇前进方向一致。所以不仅仅是画出蛇头就可以了,还需要让其朝向与前进方向时时对齐。
在游戏中已经取得贪吃蛇相对于坐标轴旋转角度,并且Canvas提供旋转方法rotate(),只需将角度传入即可。一切看起来都是这么完美,但是,rotate()方法是将整个画布进行旋转,所以导致一旦贪吃蛇转向时,整个画布都将旋转,便会出现如下情况。

画布旋转:

Image text

实现代码:

1
2
ctx.rotate(snake.sgms[length-1].angle);
ctx.drawImage(g_source, 0,60,30,30,-snakeWidth/2, -snakeWidth/2, snakeWidth, snakeWidth);

这个时候,又到了save()方法上场,保存当前状态。加上save()后效果如图所示:
Image text

实现代码:

1
2
3
ctx.save();
ctx.rotate(snake.sgms[length-1].angle);
ctx.drawImage(g_source, 0,60,30,30,-snakeWidth/2, -snakeWidth/2, snakeWidth, snakeWidth);

我们发现,这时候蛇头在左上角一动不动,因为不仅仅需要用save()保存当前状态,还需要将蛇头移动到相应的位置。
但在这个时候我们发现,蛇头并没有按照我们想象中那样完美的绑定在头部位置,而是随着贪吃蛇转向而出现一定的偏差。然后发现我们将rotatetranslate方法的顺序写反了
Image text

先rotate再translate:

1
2
3
4
ctx.save();
ctx.rotate(snake.sgms[length-1].angle);
ctx.translate(snake.lastArm.endX(),snake.lastArm.endY());
ctx.drawImage(g_source, 0,60,30,30,-snakeWidth/2, -snakeWidth/2, snakeWidth, snakeWidth);

rotatetranslate的顺序相调,贪吃蛇的头部已经完全正常了!
Image text

先translate再rotate:

1
2
3
4
ctx.save();
ctx.translate(snake.lastArm.endX(),snake.lastArm.endY());
ctx.rotate(snake.sgms[length-1].angle);
ctx.drawImage(g_source, 0,60,30,30,-snakeWidth/2, -snakeWidth/2, snakeWidth, snakeWidth);

贪吃蛇拐角问题

如下面视频所示,贪吃蛇在转向的时候“冒出”了尖锐的拐点:
Image text
此问题与 CanvaslineJoin 属性有关,此属性是设置或返回两条线相交时,所创建的拐角类型,前文也说过,贪吃蛇是由许多的小线段拼接而成的。所以当贪吃蛇在转向时,相当于线段交汇,所以会出现这种尖锐拐角,通过设置 ctx.lineJoin = “miter” ctx.miterLimit = 1 可以将控制拐角长度,如果斜接长度超过 miterLimit 的值,边角会以 lineJoin 的 “bevel” (斜角)类型来显示。
Image text
我们将miterLimit 设置为1,再看效果:
Image text

游戏问题

游戏异常卡顿

在所有功能都基本开发完成之后,通过stats.js插件监控游戏帧率,发现游戏在安卓手机上表现异常卡顿。此设备为红米Note3(骁龙650),正常情况下帧率应该为58-60帧左右。
开始怀疑是因为内存泄漏的缘故,但几经排查发现并不是这个原因。最后发现是因为游戏中跑马灯缘故,因为跑马灯不断的在改变 margin-top 值,引起页面一直在重绘,虽然对其单独建立了渲染层,但成效不大,改用translate处理提升也不高。最后解决方案为在进行游戏的时候暂定跑马灯与 Swiper ,在游戏结束后重新开启跑马灯与 Swiper
Image text

定时器导致卡死

游戏中的倒计时功能通过定时器实现,但当定时器内置于游戏逻辑时,再次开始游戏后有一定几率卡死,遂将定时器置于游戏逻辑外,后因为不需要倒计时所以移除了定时器。


游戏性能

游戏帧率

因为iOS中UI渲染过程具有绝对的优先等级,再加之IOS强大的图形处理器,所以我们首先关心游戏在安卓上的表现情况,如果在安卓上表现流畅,在IOS上表现也不会差。
stats.js是一个JavaScript性能监控器。这个类提供了一个简单的信息框,帮助您监控代码的性能。如图所示的深蓝色方框便是stats.js所提供的帧率监控。左边的60FPS为当前渲染的帧率,右侧的60-60分辨是最低帧率-最高帧率。所以可以得出使用一加3T(骁龙821处理器)的手机,在玩贪吃蛇游戏时,最低帧率为60帧,达到了满帧。
关于帧率,人眼在15帧能感受到连续的动画,15帧以下看过去是一张张图片,30帧是曾经的标准游戏动画帧数,但是30帧容易引起视疲劳,后来60帧成了标准。30和60肉眼分不出来,但是公认60帧是最好的效果,120帧的游戏也容易引起视觉疲劳,人眼能接受最平衡的数据量就是60帧了,再高你眼睛也采样不过来。30帧和60帧,你连续游戏几个小时就会感受出来区别了

一加3T(骁龙821)

通过第三方插件stats.js检测游戏帧率,以下数据为游戏在一加3T手机(2016年下半年旗舰机配置)上的表现情况,如图2-1,可以看出游戏运行情况很好,全程60帧,十分的流畅。
Image text

红米Note3(骁龙650)

骁龙650在性能上强于骁龙808,弱于810可以很好的代表2016年中端安卓性能。可以看出,游戏全程帧率在58-60之间,也是十分流畅。
Image text

魅蓝Note3(联发科 P10)

如果说一加3T可以代表2016年安卓旗舰系列,那么拥有Helio P10的魅蓝Note3可以代表2016年上半年千元机的整体实力。虽然在此机型帧率上表现不如前两款机型,但是也有着50-53的帧率,从游戏表现上来说相差无几。
Image text

通过对以上几款安卓机型游戏帧率的分析,可以看出,此款游戏在安卓手机上表现还是比较出色的,在游戏时不会感知到明显的卡顿、延迟等,影响用户体验。

内存监测

作为一个小游戏,监测其是否存在内存泄漏还是很有必要的。首先通过Chrome控制工具查看在游戏过程中,内存的变化情况,如图表示在整个游戏过程中堆栈占用的情况。如果它变得非常陡峭,说明制造了太多的垃圾;如果它一直处于上升,那么则可能发生内存泄漏。
Image text
从图中可以看出,并且再通过Chrome工具添加7张内存快照可以发现,整个游戏无内存泄漏风险。
Image text


思考与总结

Canvas总结

本来自身对于Canvas的理解还不够深入,但是在这一次贪吃蛇游戏之后,对于Canvas更加熟悉,例如在Canvas中,如果存在大量的浮点运算时,效率必然下降很多,所以建议将计算的值取整再绘制,对于需要大量渲染的场景中是非常有用的。又例如save()restore()方法,对于Canvas来说可以说是利器。
对于有图像资源并且需要反复DrawImage时,如果没有做好资源载入控制将带来非常严重的显示问题,
例如画面闪烁,绘制区域无内容。因此,建议在开发中使用数组或JSON等序列化所有的游戏资源,然后将相关资源一次性建立相关对象,存储在内存中,以便在下载完毕后,频繁读取时不必再去读图片资源,而是直接读内存中的资源,这样一来,一是速度快了许多,二是稳定性有了保障。
并对Canvas总结了以下三点:

  • 存在大量浮点计算时,建议取整计算;
  • 对于比较复杂的Canvassave()restore()方法可以说是利器
  • 对于有图像资源并且需要反复DrawImage需做好控制。

DrawImage消耗测试

因为Canvas具备「把图片中的某一部分绘制到 Canvas 上」的能力,所以很多时候,我们会把多个图片资源对象放在一张图片里面,以减少请求数量。这通常被称为「精灵图」。然而,这实际上存在着一些潜在的性能问题。
经测试发现,在绘制相同大小区域的内容时,使用与绘制区域相同大小的图片会比使用精灵图绘制该区域的的开销要小一些。可以认为,两者相差的开销正是「裁剪」这一个操作的造成的。
是用一张200200与200600大小的图进行测试,可发现绘制 10000 次一块 200x200 的矩形区域,花费了 42ms;而如果数据源是一200*600 图片中裁剪出来的 200x200 的区域,绘制 10000 次需要花费 59ms。
所以虽然看上去开销相差并不多,但是 drawImage 是最常用的 API 之一,所以还是有必要进行优化的。优化的思路是,将「裁剪」这一步骤事先做好,保存起来,每一帧中仅绘制不裁剪。
Image text