简化版的Flappy Bird开发过程(不使用第三方框架)

 

.0 开始之前

  之前曾经用Html5/JavaScript/CSS实现过2048,用Cocos2d-html5/Chipmunk写过一个Dumb Soccer的对战游戏,但没有使用过原生的Canvas写过任何东西,为了加深对Canvas的学习,就心血来潮花了将近一天的时间利用原生Canvas实现了一个简化版的flappy bird,下面就总结一下开发的过程。

  在正式开前,对于没有使用本地服务器的开发者来说,建议下载一个firefox来进行测试或使用IE 10,因为firefox和IE 10对本地文件的访问限制较低,在本地无服务器环境调试的时候,在firefox浏览器中不容易碰到因为跨域而无法获取文件的问题。如果能够在本地搭建一个HTTP服务器那就更好了,基本不会碰到类似的错误。

.1 构造世界

.1.1 html页面

  首先需要在index.html中加载所需要的脚本,设置canva标签,具体代码如下:

<!doctype html>
<html>
<head>
    <style>
        .game_frame {
            margin: 20px auto;
            width: 240px;
            height: 400px;
        }
    </style>
</head>
<body>
    <div class='game_frame'>
        <canvas class='game_box' id='game_box' name='game_box' width='240px' height='400px'></canvas>
    </div>
    <script src="game.js" type="text/javascript"></script>
</body>
</html>

  利用将canvas嵌入到一个div标签中,利用div标签来控制canvas的位置,目前是将canvas居中。

  需要注意的是,必须要在canvas标签内部设置样式,否则Javascript中所绘制的图像的比例很发生严重失真(图像被拉伸,变形)。目前,暂时不考虑自适应屏幕大小的问题,首先把游戏实现了。

   另外,脚本的加载最后放在body末尾,以免脚本获取元素的时候html页面并未加载完成。其实,更好的方法是利用window.onload设置页面加载完成后的动作,保证Javascript脚本不会在元素未加载完成的时候去读取元素。

.1.2 game.js脚本

  为了避免污染全局变量,在game.js中定义一个World对象,World负责游戏中的所有元素操控(创建、销毁、控制)、动画帧的循环、碰撞检测等工作,是整个游戏运作的引擎。下面是World中的所有方法和属性:

var World = {
// 保存Canvas
theCanvas : null,

// 游戏是否暂停
pause: false,

// 初始化并运行游戏
init : function(){},

// 重置游戏
reset: function(){},

// 动画循环
animationLoop: function(){},

// 绘制背景
BGOffset: 0,    // scroll offset
backgroundUpdate : function() {},

// 更新元素
elementsUpdate: function(){},

// 碰撞检测
collisionDectect: function(){},
hitBox: function ( source, target ) {},
pixelHitTest: function( source, target ) {},

// 边界检测
boundDectect: function(){},

// 创建烟囱
pipesCreate: function(){},

// 清除烟囱
pipesClear: function(){},

// 小鸟出界检测
isBirdOutOfBound: function(callback){},
};

  通过这些方法,World就可以运行游戏,实现对游戏中“小鸟”和“烟囱”的控制。

.1.3 游戏的初始化:World.init

   游戏通过World.init来初始化游戏并运行。利用Html5 canvas实现的游戏或动画的原理都是一样的,即以特定的时间间隔不断地更新canvas画布上的图像以实现动画。因此,游戏初始化时候必须要做的就是下面几件事情:

  • 获取DOM中的canvas元素;
  • 通过canvas元素获取context;
  • 进入动画循环(animationLoop);

  在我们的game.js写下这样的代码:

 1 World.init = function(){
 2     var theCanvas = this.theCanvas = document.getElementById('game_box');
 3     this.ctx = theCanvas.getContext('2d');
 4     this.width = theCanvas.width;
 5     this.height = theCanvas.height; 6     this.bird = null;
 7     this.items = [];
 8     this.animationLoop();
 9 },

  除了保存canvas元素及context以外,还将canvas画布的长宽也保存下,并创建了bird对象和 items数组以保存游戏中的元素。然后就进入了animationLoop这个动画循环。

 

.1.4 动画循环

  动画循环以特定的时间间隔运行,负责更新游戏中每个元素属性,并将所有元素在canvas中绘制出来。一般游戏会以60fps或30fps的帧率运行,显然60fps的帧率需要更大的运算量,而画面也更为流畅,现在就暂时使用60fps帧率,那么每帧图像的时间间隔为1000ms/60 = 16.7ms。另外,要注意的是元素的绘制顺序,一定要首先将背景绘制出来,然后再绘制其他游戏元素,否则这些元素就会被背景图所覆盖。由于帧间隔比较短,因此animationLoop中所允许的函数应当尽可能快。下面看看代码:

 1 animationLoop: function(){
 2 
 3     // scroll the background
 4     this.backgroundUpdate();
 5 
 6     // detect elements which is out of boundary
 7     this.boundDectect();
 8 
 9     // detect the collision between bird and pipes
10     this.collisionDectect();
11 
12     // update the elements
13     this.elementsUpdate();
14 
15     // next frame
16     if(!this.pause){
17         setTimeout(function(){
18             World.animationLoop();
19         }, 16.7)
20     }
21 }

  animationLoop中的工作顺序是:

  • 绘制背景;
  • 边界检测,对出界的元素进行处理;
  • 碰撞检测,执行相应的处理;
  • 绘制游戏中的元素;
  • 设置下一帧的定时;

  在这里使用了setTimeout来设置下一帧的定时,而不是使用setInterval来实现一次性的定时操作,最重要的原因是为了保持帧率的稳定。如果使用setInterval来定时,那么可能会出现由于当前animationLoop处理时间较长(超过16.7ms),导致下一帧处理 定时已经到来了而处于等待状态,等当前animationLoop处理完成后,立即执行下一帧的处理,这样使得帧间隔被压缩,出现明显的帧率不稳的状态。如果使用setTimeout,即使当前处理时间较长,帧处理完成到下一帧的间隔也肯定是固定,而帧间隔时间会大于16.7ms。这样虽然帧率会降低,但可以降低跳帧这种帧率波动较大的事件出现。

 

.1.5 绘制背景

  在为游戏世界添加元素以前,先为这个世界创造一个背景。为此,从网上下载了一个flappy元素包,里面有一张整合了所有图片元素的atlas图集,为了提高游戏的加载速度,决定使用这种图集,尽管显式图片的时候可能稍微麻烦一点,游戏加载速度的提高效果很值得我们这么做。

  首先,是atlas.png图片的加载,一般有两种方式在html页面中使用标签进行加载,这样图片就会在DOM的构建过程中加载完成,此时我们设置该标签的长宽为0,令其不占据文本流的空间,同时也不会显示出来:

  index.html:

1 <img src="atlas.png" id='atlas' style='visibility:hidden' width="0" height="0">

  game.js:

1 var image = document.getElementById('atlas');

 

  另一种是在Javascript脚本中动态加载,加载时间更加灵活:

1 var image = new image();
2 image.src = 'atlas.png';
3 imgge.onload = function(){
4            // wait for the loading
5 };

  无论在哪种方式下都要等待图片加载完成才能使用图片,否则会出错。动态加载会使得图片运行更加脚本运行流程变得更复杂,由于在这个游戏中只需要加载一张图片,因此采用第一种方法。

  图片加载完成以后,只要在backgroundUpadte函数中将其绘制出来,即可以顺利完成背景的绘制:

1 backgroundUpdate : function() {
2     var ctx = this.ctx;
3     ctx.drawImage(image, 0, 0, 288, 512, 0, 0, 288, 512);
4 },

  drawImgae的第2个到第5个参数分别表示,目标图像在图源中的x坐标y坐标以及图源中图像的宽度长度,最后四个参数是目标图像在画布中的x坐标y坐标以及在画布中的宽度长度

  然而,一个静态的背景图太简陋了,缺乏活力,所以我们要令背景图卷动起来。实现原理非常简单,只需要将背景图绘制的x轴偏移量随着时间改变而改变。但由于我们所拥有的背景图太窄了,需要将其会绘制两次拼接出一张较宽的图片,实现的代码图下所示:

1 backgroundUpdate : function() {
2     var ctx = this.ctx;
3     this.BGOffset--;
4     if(this.BGOffset <= 0) {
5         this.BGOffset = 288;
6     }
7     ctx.drawImage(image, 0, 0, 288, 512, this.BGOffset, 0, 288, 512);
8     ctx.drawImage(image, 0, 0, 288, 512, this.BGOffset - 288, 0, 288, 512);
9 },

  这时,图片还没有开始动,因为我们的世界还没有初始化!!!为了让世界在页面加载完成后初始化,在index.html的body末尾中嵌入脚本。

1 <script>
2     window.onload = function(){
3     console.log('start');
4     World.init();
5 }
6 </script>

  此时,用浏览器打开页面,就可以看到我们所创造的新世界了。

 

.2 在世界中添加元素

  世界创造完成以后,就可以往世界里面添加元素了。游戏世界里面每个元素都可能不一样,有不同的大小、形状、属性、图片,但这些元素也有一些共性,如它们都需要有记录大小、位置、移动速度的属性,还需要有在元素中渲染该图片的方法。这里有些属性和方法是特有的,如大小属性,渲染方法,但同时这些元素也有共有的属性,如设置位置、速度的方法等。为此,我们将创建一个名Item函数对象,利用这个函数对象的prototype来保存一些公有的方法和属性,再创建Bird类和Pipe类来创建构造“bird”和“pipe”对象。

.2.1  基本元素:Item类

  世界中每个元素都需要有的基本属性是:大小、位置、速度、重力(重力加速度),而这些属性每个具体的元素对象都可能不相同,因此它们不设置在prototype上,只在对象本身上创建。而prototype上有的是设置这些属性的方法,还有一个叫generateRenderMap的方法。这个方法是用来生成用于像素碰撞检测的数据的,暂时先不写。

 1 /*
 2 *    Item Class
 3 *    Basic tiem class which is the basic elements in the game world
 4 *@param draw, the context draw function
 5 *@param ctx, context of the canvas
 6 *@param x, posisiton x
 7 *@param y, posisiton y
 8 *@param w, width
 9 *@param h, height
10 *@param g, gravity of this item
11 */
12 var Item = function(draw, ctx, x, y, w, h, g){
13     this.ctx = ctx;
14     this.gravity = g || 0;
15     this.pos = {    x: x || 0,
16                 y: y || 0
17             };
18     this.speed = {    x: 0,        // moving speed of the item
19                 y: 0
20                     }
21     this.width = w;
22     this.height = h;
23     this.draw = typeof draw == 'function' ? draw : function(){};
24     return this;
25 };
26 
27 Item.prototype = {
28     // set up the 'draw' function
29     setDraw : function(callback) {
30         this.draw = typeof draw == 'function' ? draw : function(){};
31     },
32 
33     // set up the position
34     setPos : function(x, y) {
35         // Handle: setPos({x: x, y: y});
36         if(typeof x == 'object') {
37             this.pos.x = typeof x.x == 'number' ? x.x : this.pos.x;
38             this.pos.y = typeof x.y == 'number' ? x.y : this.pos.y;
39         // Handle: setPos(x, y);
40         } else {
41             this.pos.x = typeof x == 'number' ? x : this.pos.x;
42             this.pos.y = typeof y == 'number' ? y : this.pos.y;
43         }
44     },
45 
46     // set up the speed
47     setSpeed : function(x, y) {
48         this.speed.x = typeof x == 'number' ? x : this.speed.x;
49         this.speed.y = typeof y == 'number' ? y : this.speed.y;
50     },
51 
52     // set the size
53     setSize : function(w, h) {
54         this.width = typeof width == 'number' ? width : this.width;
55         this.height = typeof height == 'number' ? height : this.height;
56     },
57 
58     // update function which ran by the animation loop
59     update : function() {
60         this.setSpeed(null, this.speed.y + this.gravity);
61         this.setPos(this.pos.x + this.speed.x, this.pos.y + this.speed.y);
62         this.draw(this.ctx);
63     },
64 
65     // generate the pixel map for 'pixel collision dectection'
66     generateRenderMap : function( image, resolution ) {}
67 }    

  内部属性的初始化有Item函数实现,里面有设置简单的默认初始化。更加完善的初始化方法是首先检测输入参数的类型,然后再进行初始化。

  gravity影响的是垂直方向的速度,即speed.y。而speed在每一次元素更新(动画循环)的时候,影响pos属性,从而改变元素的位置。

  update这个公有方法通过setSpeed、setPos改变元素的速度和位置,并调用draw方法来将元素绘制在canvas画布上。

  元素初始化的时候,必须从World中获得draw方法,否则元素的图像是不会绘制到canvas画布上的。而绘制所需要的context也是从World中获取的,在初始化的时候获取,并保存到内部变量中。

  添加Item对象

  通过Item来创建一个对象,就可以向世界中添加一个元素,在World.init中添加代码:

 1 World.init = function(){
 2     ...
 3 
 4     var item = new Item(function(ctx){
 5         ctx.fillStyle = "#111111";
 6         ctx.beginPath();
 7         ctx.arc(this.pos.x, this.pos.y, this.width/2, 0, Math.PI*2, true);
 8         ctx.closePath()
 9         ctx.fill()
10     }, this.ctx, 50, 50, 10, 10, 0.2);
11     this.items.push(item);    // 将元素放入到管理列表中
12 
13     ...
14 }    

  通过上述代码,就可以往World中添加一个圆点。但此时世界中仍然不会显示圆点,那是因为World.elementsUpdate还没有实现。该方法需要遍历世界中的所有元素,调用元素的update方法,通过元素的update方法调用draw方法从而实现元素在画布上的绘制。

1 World.elementsUpdate = function(){
2     // update the pipes
3     var i;
4     for(i in this.items) {
5         this.items[i].update();
6     }
7 }

  刷新页面之后,就会看到一个小圆点在做自由落体运动。

  

 

.2.2 继承:extend函数

  在创建其他类型的元素前,先来看看要如何实现类的继承。

  为何要使用继承?

  在游戏中的元素都存在共性,它们都有记录大小、位置、速度的属性,也都需要有设置大小、位置、速度的属性,还必须要有一个提供给World.elementsUpdate方法调用的更新元素属性、在画布上绘制元素图像的接口。通过类的继承,在创建不同类型的时候就可以将来自早已定义好的基类——Item类——的属性或方法继承下来,简化了类的创建,同时也节省了实例占用的空间。

  如何实现类的继承?

  要实现类的继承,最主要的是应用了constructor和prototype。在子类构造器函数中,通过调用Parent.constructor.call(this)就可使用基类构造器为子类构造内部属性和方法;通过Child.prototype = Parent.prototype就可以继承基类的prototype,这样子类的实例对象就可以直接调用基类prototype上的代码。JavaScript里实现类继承的方法非常多,不同的方法能够产生不同的效果,更多详细的说明请翻阅相关的参考书,如《JavaScript面向对象编程指南》,《JavaScript设计模式》等。

  extend函数

  在这里,我们采用一个简单的extend函数来实现继承。

 1 /*
 2 *    for deriving a new Class
 3 *    Child will copy the whole prototype the Parent has
 4 */
 5 function extend(Child, Parent) {
 6      var F = function(){};
 7      F.prototype = Parent.prototype;
 8      Child.prototype = new F();
 9      Child.prototype.constructor = Child;
10      Child.uber = Parent.prototype;
11 }

  这个函数干了下面一些事情:

  • 创建一个空函数对象F作为中间变量;
  • 中间变量获取Parent的prototype;
  • 子类从中间变量中继承原型并更新原型中的构造器,此时子类的原型和基类的原型虽然包含相同的属性和方法,但是已经两个独立的原型了,不会相互影响;
  • 最后创建一个内部uber变量来引用Parent原型;

  该方法参考自《JavaScript面向对象编程指南》。使用这个方法,会复制基类原型链并继承之,并不会继承基类的内部属性和方法(this.xxxx)。这样做的原因是,尽管子类和基类可能会有共同的元素,但是初始化构造时要执行的参数不一样,有些元素可能拥有更多内部属性,有些内部属性可能已经被一些子类元素抛弃了,但原型链上的公有方法则是子类想继承的。

  利用内部uber属性引用基类原型链的原因在于,子类有可能需要重载原型链上的公有方法,这样就会把原有继承而来的方法覆盖掉,但有时又需要调用基类原有的方法,因此就利用内部属性uber保留对基类原型链的引用。

.2.3 主角:Bird类

  这个世界的主角是Bird,尽管它是主角,但它也是这个世界的元素之一,与Item类一样拥有记录大小、位置、速度的内部属性,它将会继承来自Item类原型链上设置内部属性的方法,当然也有一个更重要的与众不同的fly方法。

  但首先,要获取atlas中小鸟的图源参数。为了方便起见,创建一个对象将其记录下来。

1 var atlas = {};
2 atlas.bird =[
3     {    sx: 0, sy: 970, sw: 48, sh: 48 },
4     {    sx: 56, sy: 970, sw: 48,sh: 48 },
5     {    sx: 112, sy: 970, sw: 48, sh: 48 },
6 ]

  atlas.bird中记录了atlas图左下角三只黄色小鸟的信息,分别是表示三种状态。目前暂时用atlas.bird[1]展示小鸟的滑翔状态。

 

 1 /*
 2 *                                Bird Class
 3 *
 4 *    a sub-class of Item, which can generate a 'bird' in the world
 5 *@param ctx, context of the canvas
 6 *@param x, posisiton x
 7 *@param y, posisiton y
 8 *@param g, gravity of this item
 9 */
10 var Bird = function(ctx, x, y, g) {
11     this.ctx = ctx;
12     this.gravity = g || 0;
13     this.pos = {    x: x || 0,
14                     y: y || 0
15                 };
16     this.depos = {    x: x || 0,        // default position for reset
17                     y: y || 0
18                 };
19     this.speed = {    x: 0,
20                     y: 0
21     }
22     this.width = atlas.bird[0].sw || 0;
23     this.height = atlas.bird[0].sh || 0;
24 
25     this.pixelMap = null;            // pixel map for 'pixel collistion detection'
26     this.type = 1;                    // image type, 0: falling down, 1: sliding, 2: raising up
27     this.rdeg = 0;                    // rotate angle, changed along with speed.y
28     
29     this.draw = function drawPoint() {
30         var ctx = this.ctx;
31         ctx.drawImage(image, atlas.bird[this.type].sx, atlas.bird[this.type].sy, this.width, this.height,
32                              this.pos.x, this.pos.y, this.width, this.height);                                                    // draw the image
33     };
34     return this;
35 }
36 
37 // derive fromt the Item class
38 extend(Bird, Item);
39 
40 // fly action
41 Bird.prototype.fly = function(){        
42     this.setSpeed(0, -5);
43 };
44 
45 // reset the position and speed 
46 Bird.prototype.reset = function(){
47     this.setPos(this.depos);
48     this.setSpeed(0, 0);
49 };
50 
51 // update the bird state and image
52 Bird.prototype.update = function() {    
53     this.setSpeed(null, this.speed.y + this.gravity);
54     this.setPos(this.pos.x + this.speed.x, this.pos.y + this.speed.y);    // update position
55     this.draw();
56 }

  Bird的构造器基本上Item一样,特别的在于它的宽度和长度由图像的大小决定,而它在内部定制了draw方法,用于将小鸟的图像绘制到画布上。draw方法中调用了跟绘制背景时一样的drawImage方法,只不过图源信息从atlas.bird中获取,暂时默认小鸟以滑翔状态显示。

  Bird多了两个方法,分别是reset和fly。reset用于重置小鸟的位置和速度;而fly则是给小鸟设置一个向上的速度(speed.y),让其向上飞一下。

  此外Bird还“重载”了update方法。现在看来,这个方法跟Item中的没有什么区别,但由于它是世界的主角,后来会为它添置更多的动画等,所以预先在这里“重载”了。

  要注意的是,extend函数需要在定义Bird的prototype方法之前,否则新定义的方法会被Item类的prototype覆盖掉

  在世界中添加小鸟

  现在,就可以往世界里添加小鸟了,在World.init中添加如下代码:

1 World.init = function(){
2      ...
3      this.bird = new Bird(this.ctx, this.width/10, this.height/2, 0.15);
4      ...
5 }

  此时,类封装的好处就显示出来了,由于Bird类已经将小鸟的构造过程封装好,创建小鸟实例的时候只需要传入context并设置位置及重力参数,创建过程变得极为简便。

  除此以外,还需要在World.elementsUpdate中添加代码,让动画循环把小鸟图像绘制在画布上:

 1 World.elementsUpdate = function(){
 2     // update the pipes
 3     var i;
 4     for(i in this.items) {
 5         this.items[i].update();
 6     }
 7 
 8     // update the bird
 9     this.bird.update();
10 },

  刷新页面,就可以在游戏世界中看到一只只会自由落体的小鸟了。

  控制小鸟

  一只只会自由落体的小鸟显然是不好玩的,为此要在世界中添加控制小鸟的方法。简单地,我们让键盘按下任何键都会使小鸟往上飞,需要在World.init中添加代码:

1 World.init = function(){
2   ...
3    (function(that){
4         document.onkeydown = function(e) {
5                 that.bird.fly();
6         };
7    })(this);
8    ...
9 }

  通过document.onkeydown设置按键按下时的回调函数,进而调用bird.fly使其往上飞。在这里使用了闭包来传递World的this对象,因为执行回调的时候上下文会改变,需要使用闭包来获取定义回调函数时的上下文中的对象。除此之外,如果需要指定某个按键来控制小鸟的动作,则可以通过回调函数的参数e来得到被按下按键对象的keycode。然而,不同内核的浏览的keycode保存的位置不一样,如webkit中是e.event.keycode,而Netscape中则是e.keycode。要解决这个兼容性问题,可以先利用navigator.appName来判断浏览器类型来采用不同方式获取keycode,或者直接在e对象中搜索keycode。

  重新刷新页面,按下键盘上任意一个按键,就可以让小鸟往上飞了。

.2.4 反派:Pipe类

  有了主角,就要有反派,世界才会充满乐趣。而在这里,我们的反派就是那些长长短短的烟囱们。尝到Bird创建的甜头后,我们用同样的方法来构造一个Pipe类。

  首先还是得有图源的参数,采用与Bird类似的方式来保存,在这里只选用图集中绿色的两根烟囱。

1 atlas.pipes = [
2     { sx: 112, sy: 646, sw: 52, sh: 320 },    // face down
3     { sx: 168, sy: 646, sw: 52, sh: 320 }    // face up
4 ]

  Pipe类代码:

 1 /*
 2 *                        Pipe Class
 3 *
 4 *    a sub-class of Item, which can generate a 'bird' in the world
 5 *@param ctx, context of the canvas
 6 *@param x, posisiton x
 7 *@param y, posisiton y
 8 *@param w, width
 9 *@param h, height
10 *@param spx, moving speed from left to right
11 *@param type, choose to face down(0) or face up(1)
12 */
13 var Pipe = function(ctx, x, y, w, h, spx, type) {
14     this.ctx = ctx;
15     this.type = type || 0;
16     this.gravity = 0;                    // the pipe is not moving down
17     this.width = w;
18     this.height = h;
19     this.pos = {    x: x || 0,
20                     y: y || 0
21                 };
22     this.speed = {    x: spx || 0,
23                     y: 0
24     }
25 
26     this.pixelMap = null;                // pixel map for 'pixel collistion detection'
27     
28     this.draw = function drawPoint(ctx) {
29         var pipes = atlas.pipes;
30         if(this.type == 0) {            // a pipe which faces down, that means it should be on the top
31             ctx.drawImage(image, pipes[0].sx, pipes[0].sy + pipes[0].sh - this.height, 52, this.height, this.pos.x, 0, 52, this.height);
32         } else {                        // a pipe which faces up, that means it should be on the bottom
33             ctx.drawImage(image, pipes[1].sx, pipes[1].sy, 52, this.height, this.pos.x, this.pos.y, 52, this.height);
34         }
35 
36     return this;
37 }
38 
39 // derived from the Item class
40 extend(Pipe, Item);

  Pipe类的定义同样不复杂,由于Pipe的长度会随机变化,而且有面朝上和面朝下两种形态,因此构造器保留长宽参数并设置有类型参数。在这里假定Pipe不能上下移动,因此speed.y设置为0,同时只能初始化Pipe在x轴上的移动速度。

  Pipe的draw方法也使用与Bird类似的方式,区别在于要根据烟囱类型来选择绘制方式和参数。

  在世界中随机地添加烟囱

  为了给世界增加趣味性,需要随机地在世界中创建烟囱,为此在World.pipesCreate写下代码:

 1 pipesCreate: function(){
 2     var type = Math.floor(Math.random() * 3);
 3     var that = this;
 4     // type = 0;
 5     switch(type) {
 6 
 7         // one pipe on the top
 8         case 0: {
 9             var height = 125 + Math.floor(Math.random() * 100);
10             that.items.push( new Pipe(that.ctx, 300, 0, 52, height, -1, 0));                        // face down
11             break;
12         }
13         // one pipe on the bottom
14         case 1: {
15             var height = 125 + Math.floor(Math.random() * 100);
16             that.items.push(new Pipe(that.ctx, 300, that.height - height, 30, height, -1, 1));        // face up
17             break;
18         }
19         // one on the top and one on the bottom
20         case 2: {
21             var height = 125 + Math.floor(Math.random() * 100);
22             that.items.push( new Pipe(that.ctx, 300, that.height - height, 30, height, -1, 1) );    // face up
23             that.items.push( new Pipe(that.ctx, 300, 0, 30, that.height - height - 100, -1, 0) );    // face down
24             break;
25         }
26     }
27 }

  pipesCreate中使用Math.random随机设置烟囱类型(仅一只烟囱在上面;仅一只烟囱在下面;上下都有烟囱;),并随机设置烟囱的长度。要注意的是,Math.random只会产生0~1的随机小数,乘上所需随机范围的最大值后就可以获取这个范围中0~max的随机小数,如果要获取整数则需要使用Math.floor或Math.round来去除小数部分。更多的使用技巧请参考相关的JavaScript书籍。

  每创建一个Pipe实例,都需要将其存入World.items中,由World.elementsUpdate来对所有的烟囱进行统一更新和绘制。

  完成随机创建方法以后,在World.init中添加定时器来调用创造烟囱的方法:

1 World.init = function(){
2        (function(that){
3         setInterval(function(){
4             that.pipesCreate();
5         }, 2000)
6     })(this);
7 }

  同样的,需要使用闭包了传递World本身,否则定时函数无法获取this.pipesCreate方法。由于在pipesCreate中创建的Pipe都设置的固定的初始位置,Pipe以固定的速度向左移动,因此Pipe实例之间的距离就通过定时器的时间间隔来控制。当然,时间间隔越短,烟囱间距离就越窄,那么游戏的难度就加大了。

  处理出界的元素

  现在World.pipesCreate会不断创建烟囱对象并保存到World.items中,即使出界了也没有做任何处理,那么不再出现的烟囱对象会一直累积下来,一点点地消耗内存。因此,需要对出界的烟囱来进行处理。

  而对于Bird实例而言,Bird掉落到世界下部时,如果没有任何操作,那么小鸟就会永远地掉落下去,很难再飞上来了。因此必须对小鸟的出界进行检测和处理。

  要记住,canvas画布中,原点在左上角,X轴方向从左向右,而Y轴方向从上向下。

  检测及处理出界元素的代码:

// boundary dectect
World.boundDectect = function(){

    // the bird is out of bounds
    if(this.isBirdOutOfBound()){
        this.bird.reset();
        this.items = [];
    } else {
        this.pipesClear();
    }
},

// pipe clearance
// clear the pipes which are out of bound
World.pipesClear = function(){
    var it = this.items;
    var i = it.length - 1;
    for(; i >= 0; --i) {
        if(it[i].pos.x + it[i].width < 0) {
            it = it.splice(i, 1);
        }
    }
};

// bird dectection
World.isBirdOutOfBound = function(callback){
    if(this.bird.pos.y - this.bird.height - 5 > this.height) {    // the bird reach the bottom of the world
        return true;
    }
    return false;
};

  当检测到烟囱的位置越过了画面的左界的时候,就将该烟囱实例清除。这里使用了Array.splice方法,要注意的是,移除Array的时候会改变Array的长度和被移除元素后面元素的位置,因此在这里使用从后往前的遍历方式。

  当小鸟位置超过画面下界时,利用World.items = []清除所有烟囱,并重置小鸟的位置。

  在刷新一下页面,试着任由小鸟自由落体至画面底部,就会看到小鸟会被重置。

.3 碰撞检测

  到目前为止,游戏的基本元素都已经添加完毕了,但你会发现一个问题:无敌的小鸟像超级英雄一样穿越所有烟囱,反派仅仅起到装饰的作用。这是因为,我们还没有添加碰撞检测功能。

.3.1 边框碰撞检测

  尽管每个元素的形状都不一定是方方正正的,但是我们在创建元素的时候都为这个元素设置了长度和宽度,利用这个隐藏的边框,就可以实现边框检测。检测方法非常简单,只要检测两个框是否有重复部分即可,实现手段就是坚持两个框的边界距离是否相互交错。类似的算法在leetcode上面有算法题,都可以用来借鉴。

  注意的是pos表示的是边框左上角的坐标。

1 World.hitBox = function ( source, target ) {
2     return !(
3         ( ( source.pos.y + source.height ) < ( target.pos.y ) ) ||
4         ( source.pos.y > ( target.pos.y + target.height ) ) ||
5         ( ( source.pos.x + source.width ) < target.pos.x ) ||
6         ( source.pos.x > ( target.pos.x + target.width ) )
7     );
8 }

  边框检测极其简单且快速,但是其效果是,小鸟还没有碰到烟囱就就会判定为碰撞已发生。那是因为小鸟的图像不仅没有填满这个边框,还拥有不规则的形状。因此边框检测只能用做初步的碰撞检测。

.3.2 像素碰撞检测

  根据精细的检测方式是对两个元素的像素进行检测,判断是否有重叠的部分。

  但是,像素碰撞检测需要遍历元素的像素,运算速度比较慢,如果元素较多,那么帧间隔时间内来不及完成检测任务。为了减少碰撞检测的耗时,可以先利用边框检测判断那些元素之间有可能发生碰撞,对可能发生碰撞的元素使用像素碰撞检测。

 1 // dectect the collision
 2 Wordl.collisionDectect = function(){ 3     for(var i in this.items) {
 4         var pipe = this.items[i];
 5         if(this.hitBox(this.bird, pipe) && this.pixelHitTest(this.bird, pipe)) {
 6             this.reset();
 7             break;
 8         }
 9     } 
10 };

  游戏里,只需要检测小鸟和烟囱之间的碰撞,因此只需要拿小鸟和烟囱逐个做检测,先进行边框碰撞检测,然后进行像素碰撞检测,以提高运算效率。检测到碰撞以后,调用World.reset来重置游戏或进行其他操作。

1 World.reset = function(){
2     this.bird.reset();
3     this.items = [];
4 }

  下面来看看像素碰撞检测。尽管减少了像素碰撞检测的调用次数,但每次像素碰撞检测的运算量仍然非常大。将如两个图像各包含200个像素,那么逐个像素进行比较就需要40000次运算,显然效率低下。

  仔细想想,图像发生碰撞时只有边缘发生碰撞就可以。那么只要记录图像的边缘数据,然后检查两幅图像边缘是否重合判别碰撞,降低需要运算的像素点数量从而降低运算量。然而边缘检测及边缘重合的算法并不简单,当中会出现许多问题。

  在这里,我们打算将边框碰撞检测应用到像素碰撞检测当中。首先,需要将原图像进行稀疏编码,即将原图像的分辨率降低,这样就相当于将一个1pixel的像素点编程一个由更多像素点组成的方形小框。

  然后,把这些小框的数据保存到每个元素的pixelMap中,这样一来,在进行碰撞检测的时候,就可以看元素图像看作是多个边框组合而成的图像,我们要做的只需要检测组成两个元素的小框之间有没有发生碰撞。

  像素检测算法的实现

World.pixelHitTest = function( source, target ) {    
    // Loop through all the pixels in the source image
    for( var s = 0; s < source.pixelMap.data.length; s++ ) {
        var sourcePixel = source.pixelMap.data[s];

        // Add positioning offset
        var sourceArea = {
            pos : {
                x: sourcePixel.x + source.pos.x,
                y: sourcePixel.y + source.pos.y,
            },
            width: target.pixelMap.resolution,
            height: target.pixelMap.resolution
        };
 
        // Loop through all the pixels in the target image
        for( var t = 0; t < target.pixelMap.data.length; t++ ) {
            var targetPixel = target.pixelMap.data[t];
            // Add positioning offset
            var targetArea = {
                pos:{
                    x: targetPixel.x + target.pos.x,
                    y: targetPixel.y + target.pos.y,
                },
                width: target.pixelMap.resolution,
                height: target.pixelMap.resolution
            };
            /* Use the earlier aforementioned hitbox function */
            if( this.hitBox( sourceArea, targetArea ) ) {
                return true;
            }
        }
    }
},

  resolution是指像素点放大的比例,如果为4,则是将1 pixel 放大为4X4 pixel 大小的边框。该算法是从原始的pixelMap中读取每个小框,并构造一对Area对象(方形边框)传递给World.hitBox方法进行边框碰撞检测。

  pixelMap的构造

  而pixelMap 的构造则需要用到context.getImageData方法。

  本地环境下,getImageData在IE 10或firefox浏览器下能够顺利运行,如果是在Chrome下则会产生跨域问题。除非使用HTTP服务器来提供web服务,否则需要更改chrome的启动参数--allow-file-access-from-files才能够使用getImageData来获取本地图片文件的数据。

   getImageData是从canvas画布的指定位置获取指定大小的图像数据,因此如果存在背景的话,背景的图像数据也会被截取。因此需要创建一个临时的canvas DOM对象,在上面绘制目标图像,然后再从临时画布上截取图像信息。

  Bird类的pixelMap:(在Bird类的draw方法中添加代码)

 1 var Bird = function(){
 2   ...
 3   this.draw = function(){
 4     ...
 5     // the access the image data using a temporaty canvas
 6         if(this.pixelMap == null) {
 7             var tempCanvas = document.createElement('canvas');        // create a temporary canvas
 8             var tempContext = tempCanvas.getContext('2d');
 9             tempContext.drawImage(image, atlas.bird[this.type].sx, atlas.bird[this.type].sy, this.width,  this.height,
10                                          0, 0,  this.width,  this.height);    // put the image on the temporary canvas
11             var imgdata = tempContext.getImageData(0, 0, this.width, this.height); // fetch the image from the temporary canvas
12             this.pixelMap = this.generateRenderMap(imgdata, 4);        // using the resolution the reduce the calculation
13         }
14     ...
15   }
16   ...
17 }

   Pipe类的pixelMap:(类似地在draw方法中添加代码)

 1  var Pipe = function(){
 2    ...
 3    this.draw = function(){
 4      ...
 5      if(this.pixelMap == null) {        // just create the pixel map from a temporary canvas
 6             var tempCanvas = document.createElement('canvas');
 7             var tempContext = tempCanvas.getContext('2d');
 8             if(this.type == 0) {
 9                 tempContext.drawImage(image, 112, 966 - this.height, 52, this.height, 0, 0, 52, this.height);
10             } else {                    // face up
11                 tempContext.drawImage(image, 168, 646, 52, this.height, 0, 0, 52, this.height);
12             }
13             var imgdata = tempContext.getImageData(0, 0, 52, this.height);
14             this.pixelMap = this.generateRenderMap(imgdata, 4);
15         }
16      ...
17    }
18    ...
19 }

  无论是Bird类还是Pipe类,都使用从Item类中继承而来的generateRenderMap方法

// generate the pixel map for 'pixel collision dectection'
//@param image, contains the image size and data
//@param reolution, how many pixels to skip to gernerate the 'pixelMap'
Item.generateRenderMap = function( image, resolution ) {
    var pixelMap = [];

    // scan the image data
    for( var y = 0; y < image.height; y=y+resolution ) {
        for( var x = 0; x < image.width; x=x+resolution ) {
            // Fetch cluster of pixels at current position
            // Check the alpha value is above zero on the cluster
            if( image.data[4 * (48 * y + x) + 3] != 0 ) {
                pixelMap.push( { x:x, y:y } );
            }
        }
    }
    return {
        data: pixelMap,
        resolution: resolution
    };
}

  resolution决定小框的大小,也决定了每行每列跳过的像素点数量。当检测到一个像素点的alpha通道值不为0,就将其保存到pixelMap中即可。

  此时刷新一下页面,你会发现小鸟再也不是无敌的了。

.4 增添动画效果

   基本的Flappy Bird基本完成了,然而小鸟只能以滑翔的姿态运动,没有有扇动翅膀的动作,显得没有生气。为此,我们可以给Bird类添加动画效果,让小鸟向上飞的时候会扇动翅膀,同时头部朝上;向下坠落的时候则头部朝下,以俯冲的姿态运动。

  这时候,之前提到了“重载”Bird.update方法的意义就来了。

 1 // update the bird state and image
 2 Bird.prototype.update = function() {    
 3     this.setSpeed(null, this.speed.y + this.gravity);
 4     
 5     if(this.speed.y < -2) {            // raising up
 6         if(this.rdeg > -10) {
 7             this.rdeg--;            // bird's face pointing up
 8         }
 9         this.type = 2;
10     } else if(this.speed.y > 2) {    // fall down
11         if(this.rdeg < 10) {
12             this.rdeg++;            // bird's face pointing down
13         }
14         this.type = 0;
15     } else {
16         this.type = 1;
17     }
18     this.setPos(this.pos.x + this.speed.x, this.pos.y + this.speed.y);    // update position
19     this.draw();
20 }

  当小鸟速度speed.y小于-2(飞起来初速度是-4)时,就减少其旋转角度rdeg让其脸逐渐朝上并更改图片显示状态为2(向下拍翅膀);

  当小鸟速度speed.y大于2(下落时速度>0)时,就增加其旋转角度rdeg让其脸逐渐朝下并更改图片显示状态为0(翅膀上拉,成俯冲姿态);

  速度在-2和2之间时,就维持滑翔状态。

  旋转小鸟

  增加更新小鸟属性的方法后,还需要更新Bird.draw,否则旋转的效果是不会显示出来的。

 1 var Bird = function(){
 2   ...
 3   this.draw = function(){
 4     ...
 5     ctx.save();                                    // save the current ctx
 6     ctx.translate(this.pos.x, this.pos.y);        // move the context origin 
 7     ctx.rotate(this.rdeg*Math.PI/180);            // rotate the image according to the rdeg
 8     ctx.drawImage(image, atlas.bird[this.type].sx, atlas.bird[this.type].sy, this.width, this.height,
 9                              0, 0, this.width, this.height);                                                    // draw the image
10     ctx.restore();                                // restore the ctx after rotation
11     ...
12   };
13   ...
14 };

  使用context.rotate旋转图像前,需要先保存原来的context状态,将画布的原点移动到当前图像的坐标,接着根据Bird.rdeg旋转图像,然后绘制图像。使用drawImage绘制图形时,需要将目标坐标改为(0,0),因为此时画布原点坐标以及移动到了Bird.pos上的。绘制完成后恢复context的状态。这样,就能够实现小鸟的身体倾斜了。

  刷新一下页面,小鸟的动画特效就完成了。

  至于碰撞特效之类的动画特效,就由大家自己自由发挥了,在这里,仅仅将最简单的Flappy Bird 游戏功能实现。

.5 总结

   游戏耗时一天完成,期间在碰撞检测部分花费了大量的时间查阅资料和测试,找到合适的方法后又在getImageData折腾了好久解决本地调试的跨域问题和截取不含背景的图像数据的问题。但总算是完成了这个简化版的游戏。

  目前,简化版本没有任何菜单、按键、显示文本等,日后会考虑继续把这部分功能完善。此外,有部分代码写的并不够精简,结构也不够清晰,编程技术有待磨练。

  跟之前使用Cocos2d-html的开发经历对比,不使用任何框架的开发难度提高了不少,尤其是在动画循环、元素绘制、碰撞检测这些部分花了不是功夫。不过,之前看过的JavaScript编程相关的书籍帮了我不少忙。有关的读书笔记都存在自己的Evernote里面,哪天再找机会整理整理把它们发到博客上来。

  总之,这次的开发经历让我学到了不少知识,对canvas的了解也更深了,希望未来有一天能够开发一个自己的游戏,而不是去重复实现别人的游戏。

  GitHub源码地址  

参考

  碰撞检测算法参考:http://benjaminhorn.io/code/pixel-accurate-collision-detection-with-javascript-and-canvas/

posted @ 2015-07-29 12:54 elcarim 阅读(...) 评论(...) 编辑 收藏