在chrome浏览器的断网页面,按空格键或者向上键会出现一个小恐龙跑酷小游戏,这个2D小游戏在设计上精致小巧,在代码上也只有三千多行,思路清晰严谨,很有学习价值
demo
在非断网情况下,可以通过chrome://dino 进行访问,源代码在source面板中无法显示,可以前往这里下载。在这篇文章中异名会梳理2D游戏的制作思路,主要包括游戏的mainloop主循环和实例的update更新、帧图的动态绘制和切换、帧率的控制、游戏对象的运动控制、碰撞检测的实现等
游戏循环
循环是游戏的心跳,是一个定时回调,每隔一段时间去更新游戏的逻辑,比如处理用户的交互,更新游戏的状态,绘制动画等等
mainloop() { this .clearCanvas() // 清除画布 // 处理逻辑.... window .requestAnimationFrame(this .mainloop.bind(this )); }
在rAF没出现之前,大家使用setTimeout和setInterval来触发视觉的变化,但是这两个api在时间的精准控制上有缺陷。因为「定时器属于异步任务,它必须等到同步任务执行完毕之后,以及异步队列里面的任务清空之后才轮到自己执行,它的实际执行时机一般都比设定的时间晚」 ,这就说明了它不能精准地按照一定的时间间隔去执行。还有一点就是「定时器的调用间隔和屏幕绘制频率不一致」 ,显示器的频率一般都默认是60Hz(1s绘制60次),每次绘制的时间差是16.7ms(1000/60≈16.7),因为定时器的调用间隔和屏幕频率不一致,所以下面这种情况就一定会出现
settimeout
红色叉叉那里就丢帧了,下面通过一个更清晰的例子来说明:
这也是为什么以前大家把setInterval的间隔设置为1000/60的原因,但是这本质上是硬件的差异,只要换个硬件,定时器的执行步调和屏幕的刷新步调不一致就一定会产生丢帧。这也就是rAF的最大优势,它是「由系统来决定回调函数的执行时机,系统每次绘制之前会主动调用 rAF 中的回调函数」 ,它能够确保回调函数是按照系统的绘制频率来调用,无论是60Hz还是50Hz,只要画面刷新就会调用回调函数,它就解决了步调统一以及回调频率可靠这两个问题。但是因为是系统主动调用,所以需要我们自己去做时间管理,raf的回调第一个参数是一个时间戳,但是在实践上一般我们自己计时
mainloop() { const now = performance.now() const deltaTime = now - (this .time || now) this .time = now this .clearCanvas() // 清除画布 // 处理逻辑... window .requestAnimationFrame(this .mainloop.bind(this )) }
在源码中,这里还做了一个严谨的设计,它在非游戏中的时候会暂停mainloop循环并且清除rAF,再次游戏的时候会再次触发mainloop,所以这里还做了一个加锁
scheduleNextUpdate: function ( ) { if (!this .updatePending) { this .updatePending = true this .raqId = requestAnimationFrame(this .update.bind(this )) } }
画面绘制
游戏基于canvas来绘制,游戏的图片资源只有一张base64格式的精灵图,如下
sprite
游戏的对象都在这张精灵图中,我们先从精灵图中把地面绘制出来。这里面涉及到的知识点是canvas的创建、画面清除,以及drawImage的应用。通过drawImage我们可以裁剪精灵图中某一部分的图像,并绘制到画布中,drawImage一共有9个参数context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height) 分别是精灵图、裁剪区域的坐标,裁剪的区域大小,在画布上放置图像的位置坐标,在画布上放置图像的大小。简单拆分一下任务:
核心代码如下
// 下载资源 loadImage() { return new Promise ((resolve, reject ) => { const img = new Image() img.src = "精灵图的base64" img.onload = () => { window .imageSprite = img resolve(img) } img.onerror = () => { reject() } }) }// 绘制画布 initCanvas() { const canvas = document .createElement('canvas' ) canvas.width = CANVAS_WIDTH canvas.height = CANVAS_HEIGHT document .body.appendChild(canvas) this .canvas = canvas this .ctx = canvas.getContext('2d' ) }// 二次绘制的时候清除画布 this .ctx.clearRect(0 , 0 , CANVAS_WIDTH, CANVAS_WIDTH, CANVAS_HEIGHT)// 绘制地面 this .ctx.drawImage(window .imageSprite, 2 , 54 , 600 , 12 , this .xPos, this .yPos, 600 , 12 )
同样利用context.drawImage可以把精灵图里面的其他对象也绘制画布上,组合出游戏里面的对象
绘制画面
动画和帧频控制
游戏中的每个实例都有update的方法, update在每次主循环中都会执行,在这个小恐龙游戏中每个实例的update都被直接地调用,如果需要更好地解耦和维护可以使用订阅发布等模式
mainloop () { // ... ground.update() trex.update() } ground.update = function () { // ... context.drawImage() // 更新绘制 }
动画就涉及到更新频率,如果像上面那样每次循环的时候都去绘制,mainloop一秒会执行60次,但是绘制的内容更新并没有这么频繁,所以我们需要做时间管理。「游戏中的帧频可以分为两种,一个是序列帧的帧频,一个是游戏的全局帧频」 。比如恐龙就是由指定的序列帧动画展示的,它一共有5种状态,其帧动画参数定义如下
Trex.animFrames = { WAITING : { // 等待状态下的序列帧 frames: [44 , 0 ], // 每一帧的起点位置 msPerFrame: 1000 / 3 // 绘制的频率 }, RUNNING : { // 奔跑状态下的序列帧 frames: [88 , 132 ], // 每一帧的地点位置 msPerFrame: 1000 / 12 // 绘制的频率 }, CRASHED : { frames : [220 ], msPerFrame : 1000 / 60 }, JUMPING : { frames : [0 ], msPerFrame : 1000 / 60 }, DUCKING : { frames : [264 , 323 ], msPerFrame : 1000 / 8 } };
拿奔跑状态来说,它是由两张图片按12Hz的频率来更新的,每一帧的耗时是1000/12,我们在update的时候做一个计时:
class Trex { constructor (ctx) { this .ctx = ctx this .currentAnimFrames = Trex.animFrames['RUNNING' ].frames this .msPerFrame = Trex.animFrames['RUNNING' ].msPerFrame this .currentFrame = 0 this .timer = 0 } update(dt) { this .timer += dt // 更新当前帧序号 if (this .timer >= this .msPerFrame) { this .currentFrame = this .currentFrame == this .currentAnimFrames.length - 1 ? 0 : this .currentFrame + 1 ; this .timer = 0 ; } // 绘制当前帧图 const sx = this .currentAnimFrames[this .msPerFrame] this .ctx.drawImage(img,sx,sy,swidth,sheight,x,y,width,height) } }
另外一种动画就是非序列帧动画,比如地面的运动,因为没有指定的帧频所以它的运动频率就是全局的帧频
const FPS = 60 // 设定全局的帧频为60 ground.update(dt) { // 根据全局的帧频计算速度 const increment = Math .floor(speed * (FPS / 1000 ) * dt); this .xPos -= increment // 绘制当前帧图 const x = this .xPos this .ctx.drawImage(img,sx,sy,swidth,sheight,x,y,width,height) }
给小恐龙加上序列帧动画以及给跑道加上位移之后效果如下:
run
值得注意的是,在小恐龙游戏中没有对主循环做帧频控制,每一次循环的时候都会执行清除画布和画面重绘操作,如果遇到需要可控帧频的场景主循环就可能会产生过度绘制或者丢帧的情况了
用户交互和运动状态
小恐龙游戏中的用户交互主要是跳和下蹲,监听用户按键事件,根据键码去切换小恐龙的状态和处理位置信息。这里有两个小逻辑,在蹲的时候因为帧图的大小有变化需要做宽高的切换;在跳的时候因为游戏是变速运动,所以也根据游戏的当前速度做了一个关联 我们把仙人掌加上之后,游戏的核心交互流程就已经实现出来了:
碰撞检测
小恐龙里面使用的是矩形检测,每个碰撞体都是一个矩形,游戏循环的时候判断每个矩形是否重叠就知道是否碰撞了。
collision_boxs
因为物体是不规则的形状,所以像左上图那样只有两个矩形是做不到精准地描述物体的边界的。「在游戏中,为了简化每一帧中的计算计算量,只有当这两个外矩形相碰的时候,才会去遍历每个对象下的细分矩形」 ,比如右上图小恐龙和仙人掌都分别用了四个矩形来描述它们的边界,当外矩形重叠的时候,内部矩形才开始遍历判断重叠,下面这个过程图很好地把这个过程演示了出来:
collision
碰撞盒子以及恐龙的碰撞盒子定义: 矩形重合判断 在mainloop中进行碰撞检测:
结尾
上面就已经把小恐龙的核心功能过了一遍,剩下的一些小功能堆叠和细节的完善,就不再展开。异名以往都是通过游戏引擎或者互动框架来开发游戏,这还是第一次生撸,引擎封装带来的开发体验和自己从零开发是不一样的,这也是前段时间异名的小困惑,高度封装就代表底层的隐藏,开发一段时间之后很快就会遇到概念上的困惑,甚至你的理解和真实的情况完全相反,虽然他们的表现一致,这次跟着代码敲完一次之后,异名对2D游戏的制作思路也有了更清晰的理解。