手写经典游戏 - FlappyBird

一、FlappyBird简介

FlappyBird(飞扬的小鸟)是由越南独立游戏开发者Dong Nguyen所开发的一款经典小游戏,如果你没有听说过这款游戏,那么请看下图:
在这里插入图片描述
我想大部分人都有一种熟悉的感觉吧。

这款游戏最初发行于移动端,后来又出现了web版的FlappyBird,技术上主要基于HTML5的canvas(画布)及其相关api。今天我们就要揭秘web端一个简单版本的FlappyBird,它仅有一百多行代码,但对我们了解canvas游戏开发却大有帮助。

温馨提示:本游戏除了使用HTML5的canvas标签外,所有代码都是基于原生js的,所以你不会有任何额外的学习负担,这将是一次原汁原味的前端游戏开发体验!

好了,让我们开始吧。

二、技术铺垫

本游戏所依赖的底层技术主要有两个,一个是canvas,另一个是基于setInterval的动画原理(这里也可以用HTML5的requestAnimationFrame来替代,它的性能更好,不过本文为了不增加学习负担,选择了经典的setInterval实现)。

1. canvas(画布)

canvas是由HTML5推荐的一个新的HTML标签,意为“画布”。

使用canvas标签,你可以在网页中创建一块空白的区域,然后使用canvas提供的js接口进行图形绘制。理论上来说,canvas可以绘制出任何形状,如文字、图标、图片,甚至极其复杂的地图等等。

为了便于理解,我们举个简单的例子,用canvas绘制一个多彩的三角形:
在这里插入图片描述
代码如下:

<canvas id="canvas" height="200" width="300" style="border: 1px solid #e6e6e6;">抱歉您的浏览器无法使用canvas控件</canvas>
<script>
	var canvas = document.getElementById("canvas");  // 获取画布
	if( canvas.getContext ){
	    var ctx = canvas.getContext("2d");   // 获取画布的上下文对象,它相当于“画笔”
	    ctx.beginPath(); // 开始一次路径绘制
	    ctx.strokeStyle = "#ff0000"; // 设置画笔颜色为红色
	    ctx.moveTo(150, 50);  // 将画笔移动到(150,50)
	    ctx.lineTo(50, 150);  // 产生一条到(50,150)位置的路径
	    ctx.stroke();  // 对上述路径描边,即绘制该直线

        ctx.beginPath();  // 原理同上,绘制绿色边
	    ctx.strokeStyle = "#00ff00";
	    ctx.moveTo(50, 150);
	    ctx.lineTo(250, 150);
	    ctx.stroke();

	    ctx.beginPath();  // 原理同上,绘制蓝色边
	    ctx.strokeStyle = "#0000ff";
	    ctx.moveTo(250, 150);
	    ctx.lineTo(150, 50);
	    ctx.stroke();
    }
</script>

理解了canvas的简单使用之后,我们回到FlappyBird,它主要用到了canvas的两个功能:

  1. 绘制图片
  2. 绘制文本

首先来看如何用canvas绘制一张图片,它非常简单:

    var image = new Image();
    image.src = "./images/tap.png";
    image.onload = function(){
        ctx.drawImage(image, 10, 10);
    }

绘制结果如下:
在这里插入图片描述
代码的含义是,从位置(10,10)开始绘制这张图片,宽高没有设定,默认等于原图片的宽高。

drawImage还可以传入更多的参数,它共有三种语法:

  1. context.drawImage(img,x,y);
  2. context.drawImage(img,x,y,width,height);
  3. context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height);

我们的游戏中主要用到了第二和第三种语法。

第二种语法需要传入图片元素img、图片的绘制位置x、y,以及需要绘制的宽高width和height。这种语法在使用单个大图时会用到(如绘制背景图)。

第三种语法用于从“雪碧图”中裁切图片。由于游戏中使用了大量的小图片,我们可以将这些小图片压缩成一张类似下面的大图(它就被称为“雪碧图”,也叫“精灵图”):
在这里插入图片描述
这样可以大大减少请求的数量,有利于提升游戏性能。

语法中的各个参数的含义为:

  1. img:要绘制的图片元素
  2. sx,sy:裁切位置的坐标
  3. swidth,sheight:裁切的宽高
  4. x,y:绘制的位置坐标
  5. width,height:绘制的宽高

对于上面的图片,drawImage(this.image, 0,500, 150,800, 0,300, 150,200)将以该图片为对象,从图片的(0,500)位置开始裁切,裁切宽度为150像素,高度为800像素,这样就得到了图片中向上的管道(即三个管道中最左侧那个)。然后把裁切结果从canvas的(0,300)处开始绘制,绘制的宽度为150像素,高度为200像素。这就把向上的管道绘制到了画布的对应位置上。

使用canvas绘制文本也非常简单,如:

    ctx.font = 'bold 35px Arial'; // 设置文本样式
    ctx.fillText("Hello", 100, 100); // 文本为“Hello”,位置为(100,100)
    ctx.stroke();  // 进行文本绘制

就可以绘制出以下文本:
在这里插入图片描述

2. 基于setInterval的动画实现

实际上,关于这个问题,我在之前的文章中有过专门的介绍(请参考使用原生js实现简单动画效果)。所以在这里我将只基于当前场景进行一定的简介,详细的讲解放在后面的具体实现部分。

在介绍之前需要指出的一点是,绝大部分网页游戏都可以看做是可交互的动画,FlappyBird也不例外。

前文我们以一个div自动变宽的动画为例,讲解了如何使用原生js实现一个简单的动画效果。我们之所以没有将其称为游戏,是因为它几乎不具备交互能力(如你所见,它只能响应你点击启动按钮的事件)。

假如这个动画可以响应键盘或鼠标事件(如敲击键盘,或鼠标点击),并且可以根据不同的按键变换不同的形状(或其他任何提前设定的行为),我们是不是就可以将其称为一个“游戏”了?

确实是这样。

所以FlappyBird就是一个可以响应键盘或鼠标事件的动画!不同的是,之前我们需要在动画的每一帧里计算div的宽度,并修改div的样式;而在这里,我们要在每一帧里绘制一个场景,通过连续的场景绘制产生动画效果。

在游戏开始之前,我们需要把每个需要绘制的物体(如管道、鸟、地面、背景图等)按照绘制顺序依次保存在一个数组中,并进行第一次绘制,得到游戏的初始画面。

当按下回车键,游戏开始。我们会启动一个setInterval定时器,并传入一个drawall函数。setInterval每过去一帧,drawall函数就会执行一次,它的任务就是将数组中的物体全部重绘一次,以形成一帧的场景。此外,drawall函数绘制完一帧后,还有一个非常重要的任务,就是计算下一帧这些物体的位置。

在计算实体的位置时,需要进行碰撞检测。一旦鸟碰撞到管道或者地面,立即判定为失败。

在任何一帧内,如果玩家按下了空格键,我们就修改鸟的纵坐标的值,使其被绘制到更高的位置,产生“飞起”效果。

这样,随着setInterval连续绘制实体,就形成了游戏效果。

三、代码实现

1. 定义画布

在开始编写真正的游戏逻辑之前,我们需要先在页面上放置一张画布,它是整个游戏的有效区域:

<!DOCTYPE html>
<html>
<head>
    <title>Flappy Bird</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body style="text-align: center;">
    <canvas id="canvas" width="400" height="600">
        Your browser does not support canvas! 
    </canvas>
    <script>
 		...  // 我们要编写的游戏逻辑
	</script>
</body>

这样你就会在屏幕的正中央得到一张宽400像素,高600像素的画布,我们的游戏就在这个画布内展开。下面的代码全都位于上述script标签内。

2. 初始化参数

接下来,我们要定义与游戏相关的一些初始化参数:

	var ctx;  // 画布上下文对象,即“画笔”
    var cwidth = 400;  // 画布宽度
    var cheight = 600; // 画布高度
    // 存储所有需要绘制的物体,如背景图、管道、鸟等
    var objects = [];
    // 当前绘制的bird的序号,我们的素材中有三种形态的鸟,需要确定当前bird处于哪种形态
    var birdIndex = 0;
    //以下三个参数用于计算bird的下落速度
    var ver1 = 10;
    var ver2;
    var gravity = 2;
    // 上下管道的间距
    var pipe_height = 200;
    //管道每帧的移动速度,随着得分上升,这个速度会加快
    var velocity = 10;
    var tid;   // 计时器的句柄
    var score = 0;  // 得分
    //得分是否有效,只有新出现一个管道,得分才有效,得分后立即置为无效
    var isScore = false;
    ...

上述代码定义了游戏运行时需要的参数。

3. 首绘

在游戏开始之前,我们需要有一个初始化页面:
在这里插入图片描述
由于此时游戏还没有启动,我们只需要在canvas上绘制这里的每一个物体即可,不需要任何动态效果。游戏中总共包含5个实体:背景、上管道、下管道、地面和鸟,我们将每个物体都抽象成一个对象:

	//背景图
    function Background(x,y,width,height,img_src){
        this.bgx = x; // 背景图坐标
        this.bgy = y;
        this.bgwidth = width; // 背景图宽高
        this.bgheight = height;
        this.image = new Image();  // 背景图对应的图片
        this.image.src = img_src; 
        this.draw = drawbg;
    }
    //背景图的绘制方法,因为背景图使用了整张图,所以不需要裁剪
    function drawbg(){
        ctx.drawImage(this.image,this.bgx,this.bgy,this.bgwidth,this.bgheight);
    }
    
    //顶部管道
    function UpPipe(x,y,width,height,img_src){
        this.px = x; // 管道的横坐标
        this.py = y; // 管道的纵坐标
        this.pwidth = width; // 管道的宽度
        this.pheight = height; // 管道的高度
        this.image = new Image();
        this.image.src = img_src; // 管道对应的图片元素
        this.draw = drawUpPipe; // 管道的绘制方法
    }
    // 顶部管道的绘制方法,它可以将“管道”
    // 从“雪碧图”裁剪出来,绘制到画布上
    function drawUpPipe(){
        ctx.drawImage(this.image,150,500,150,800,this.px,this.py,this.pwidth,this.pheight);
    }
    
    //底部管道,参数同上
    function DownPipe(x,y,width,height,img_src){
        this.px = x;
        this.py = y;
        this.pwidth = width;
        this.pheight = height;
        this.image = new Image();
        this.image.src = img_src;
        this.draw = drawDownPipe;
    }
    //底部管道的绘制方法
    function drawDownPipe(){
        ctx.drawImage(this.image,0,500,150,500,this.px,this.py,this.pwidth,this.pheight);   
    }
    
    //鸟
    function Bird(x,y,width,height,img_srcs){
        this.bx = x;  // 鸟的坐标位置
        this.by = y;
        this.bwidth = width;  // 鸟的宽高
        this.bheight = height;
        this.imgs = []; // 保存三种形态的鸟
        for(var i = 0; i < img_srcs.length; i++){
            this.imgs[i] = new Image();
            this.imgs[i].src = img_srcs[i];
        }
            
        this.draw = drawbird;
    }
    //bird的绘制方法,这里是按顺序绘制三张图片,由birdIndex来控制
    function drawbird(){
        birdIndex++;
        ctx.drawImage(this.imgs[birdIndex%3],this.bx,this.by,this.bwidth,this.bheight);    
    }

上面的代码定义了背景、上管道、下管道、地面和鸟这五个实体对应的构造函数,并分别为其定义了绘制方法。它们就是构成游戏的所有元素。接下来我们用这些构造函数构造出这五个实体,并按序全部保存在一个数组中等待绘制:

    // 鸟共有三种形态(模拟翅膀挥动),这是每个形态的鸟的图片地址
    var birds = ["./images/0.gif","./images/1.gif","./images/2.gif"];
    // 背景
    var back = new Background(0,0,400,600,"./images/bg.png");
    // 上管道
    var up_pipe = new UpPipe(0,0,100,200,"./images/pipe.png");
    // 下管道
    var down_pipe = new DownPipe(0,400,100,200,"./images/pipe.png");
    // 地面
    var ground = new Background(0,550,400,200,"./images/ground.png");
    // 鸟
    var bird = new Bird(80,300,40,40,birds);
    //压栈顺序决定了绘制顺序,从第0个物体,即背景开始绘制
    objects.push(back);
    objects.push(up_pipe);
    objects.push(down_pipe);
    objects.push(ground);
    objects.push(bird);
    ...

首绘所需要的实体都已经创建完毕,直接将其绘制到画布上即可。我们定义一个初始化函数并在body的onLoad事件中调用它:

	...
	<body onLoad="init();">
	...
    // 初始化函数
    function init(){
        ctx = document.getElementById('canvas').getContext('2d');
        document.onkeyup = keyup;  // 键盘监听事件,我们稍后继续展开
        drawall();  // 绘制所有实体
        ctx.fillText("按回车键开始游戏!", 80, 200);
    }
    

首绘的关键在于这个drawall方法,我们看一下它的实现:

    // 绘制所有物体
   function drawall(){
       ctx.clearRect(0,0,cwidth,cheight); // 清空画布
       var i;
       for(i=0;i<objects.length;i++){
           objects[i].draw();  // 依次绘制每个物体
       }
       calculator();  // 计算这些物体下一帧的位置及其他处理
   }

实际上,执行完这里的for循环之后,首绘就已经完成了。objects数组中依次存储了所有的物体,依次调用它们的draw方法,就可以将其绘制到画布上。

现在我们已经得到了上面的那张开始页面。接下来就是让我们的游戏“动起来”了。

4. 启动游戏

终于到了让游戏运行起来的时刻了,是不是既兴奋又紧张?没关系,请跟着我的思路,看我是如何启动游戏的。

回顾一下前面的步骤,你认为我们最大的成果是什么?是完成了首绘吗?

其实并不是的。我们最大的成果是得到了objects数组,这个保存了所有待绘制实体的数组。

为什么这么说呢?

首先,我们在第一步定义的那些初始化参数,几乎全部都是为了这五个实体服务的。如画布宽高,描述了实体绘制的范围;重力参数,描述了鸟的下落轨迹;速度参数,描述了鸟与管道的相对移动速度。唯一似乎与实体无关的,可能就是得分了。

其次,我们定义了五个构造函数,目的也不过是为了构造这五个实体而已。

最后,我们完成了首绘。这个看似巨大的成果,其实只是简单地把五个实体按顺序绘制到画布上,为游戏的启动做准备而已。一个更能说明objects数组重要性的事实是,实际上游戏任何一帧的绘制都只是简单地把这五个实体绘制到画布上,与首绘并无二异。只不过游戏过程中的这些帧画面需要进行位置计算、碰撞检测和得分统计而已。

所以,有了objects数组,我们确实可以启动游戏了。

那么如何启动游戏呢?我们在游戏初始页面上已经给出了提示:点击回车键。因此,代码中启动游戏的第一步就是检测这个键盘事件。

还记得init方法中的那行代码吗?就是document.onkeyup = keyup;现在我将向你展示它的实现:

    //按下回车键将开启定时器,即开始游戏,回车键使bird上升80像素
    function keyup(e){
        var e = e||event;
        var currKey = e.keyCode||e.which||e.charCode; // 获取键值
        switch (currKey){
            // 按下的是回车键,启动游戏
            case 13:
                tid = setInterval(drawall, 80);
                break;
            // 按下的是空格键,让鸟向上“飞动”
            case 32:
                bird.by -= 80;
                break;
        }
    }    

本游戏中我们只监听两个按键:回车键和空格键(空格键可以让鸟向上飞起,我们稍后会介绍)。一旦监听到玩家按下了回车键,那么游戏开始。我们设置一个循环计时器,每80毫秒执行一次drawall方法。

还记得drawall方法做了什么吗?它的工作有两个,一是依次绘制objects数组中每个实体(就像我们首绘时做的那样),即绘制当前帧;二是调用calculator方法进行位置计算、碰撞检测及得分统计等,为下一帧绘制做准备。

5. 连续的帧计算

从setInterval生效开始,浏览器就会每隔80毫秒进行一次画布重绘,本质上游戏就已经启动起来了,但是它还没有“动起来”。想让物体动起来,我们还需要一些额外的计算和处理。

首先我们要实现的是,在玩家没有按下空格键的情况下,游戏的自动运行,这包括鸟的自由下落,以及鸟和管道间的相对移动(在本游戏中,背景图是不动的)。

我们之前提到,帧画面的绘制是通过调用drawall实现的,现在让我们再次把目光投向它:

    function drawall(){
        ctx.clearRect(0,0,cwidth,cheight);
        var i;
        for(i=0;i<objects.length;i++){
            objects[i].draw();
        }
        calculator();
    }

首先,清空画布,这是为当前帧的绘制做准备。

接着,用for循环遍历objects数组,依次调用它内部五个实体的draw方法,将其绘制到画布上。根据首绘的经验我们知道,这就可以成功绘制出一帧了。

接下来就是让物体“动起来”最重要的一步了:下一帧的计算。它通过调用calculator方法实现。

请设想,如果这里没有执行calculator方法会出现什么现象呢?

我们在setInterval中连续调用drawall方法,每隔80毫秒就将五个实体全部绘制一遍,但是却一直不修改各个实体的位置。因此每一帧中,五个实体都保持原位置不变,那这算什么游戏?

为了让物体“动起来”,我们必须在绘制完一帧后重新计算下一帧中该物体的位置,这样在下一次绘制该物体时,它才会在视觉上发生移动。现在让我们屏气凝神,来看本游戏的核心方法:calculator的实现:

    // 更新物体坐标和碰撞检测
    function calculator(){
        //碰撞发生的条件,包括撞到管道或地面
        if(bird.by+bird.bheight>ground.bgy ||
                ((bird.bx+bird.bwidth>up_pipe.px)&&(bird.by>up_pipe.py)&&(bird.bx+bird.bwidth<up_pipe.px+up_pipe.pwidth)&&(    bird.by<up_pipe.py+up_pipe.pheight))||
                ((bird.bx+bird.bwidth>up_pipe.px)&&(bird.by>up_pipe.py)&&(bird.bx+bird.bwidth<up_pipe.px+up_pipe.pwidth)&&(    bird.by<up_pipe.py+up_pipe.pheight))||
                ((bird.bx>down_pipe.px)&&(bird.by>down_pipe.py)&&(bird.bx<down_pipe.px+down_pipe.pwidth)&&(bird.by<down_pipe.py+down_pipe.pheight))||
                ((bird.bx>down_pipe.px)&&(bird.by+bird.bheight>down_pipe.py)&&(bird.bx<down_pipe.px+down_pipe.pwidth)&&(bird.by+bird.bheight<down_pipe.py+down_pipe.pheight))){
            // 检测到发生了碰撞,游戏立即结束,
            // 清除计时器并显示游戏结束文字
            clearInterval(tid);
            ctx.fillStyle = "rgb(255,255,255)";
            ctx.font = "30px Accent";
            ctx.fillText("You got "+score+"!",110,100)
            return;
        }
        // 计算鸟的下落。在这里鸟的纵坐标每帧增加11,含义为鸟每帧
        // 下降11像素,当然你也可以模拟重力环境来设定鸟的下落
        // 本游戏中鸟在水平方向上不发生移动
        ver2 = ver1+gravity;
        bird.by += (ver2+ver1)*0.5;
        // 如果当前管道仍然可见,则将其x坐标减少10像素,即向左移动10像素
        // 也就是管道每帧向左移动10像素
        if(up_pipe.px+up_pipe.pwidth>0){
            up_pipe.px -= velocity;
            down_pipe.px -= velocity;
        }else{
            //当前管道不可见后,从右侧重新生成管道
            up_pipe.px = 400;
            down_pipe.px = 400;
            // 上部管道的长度是100~300像素的随机数
            up_pipe.pheight = 100+Math.random()*200;
            //  游戏中管道的间距是固定的,因此你可以计算出底部管道的高度
            down_pipe.py = up_pipe.pheight+pipe_height;
            down_pipe.pheight = 600-down_pipe.py;
            //新管道出现后,计分器打开
            isScore = true;
        }
        //当bird通过了管道右侧,则加一分,并关闭计分器,防止重复得分
        if(isScore && bird.bx>up_pipe.px+up_pipe.pwidth){
            score += 1;
            isScore = false;
            //  积分每达到10分,管道的移动速度加1(管道的移动速度增加,即游戏难度增加)
            if(score>0 && score%10 === 0){
                velocity++;
            }
        }
        // 绘制当前得分
        ctx.fillStyle = "rgb(255,255,255)";
        ctx.font = "30px Accent";
        if(score>0){
            score%10!==0?ctx.fillText(score,180,100):ctx.fillText("Great!"+score,120,100);
        }
    }

下面是calculator的执行流程:

  1. 碰撞检测。根据鸟的当前坐标,计算它是否触碰到了管道或者地面。一旦发现发生了触碰,立即清除计时器,并显示游戏失败的提示,游戏结束。
  2. 计算鸟的下落。这里我们为了简单,设定鸟是匀速下落的,并且每帧下降11像素(这个参数直接影响到游戏难度,请谨慎选择)。我们直接把鸟的纵坐标加上11,这样下一帧在绘制的时候,鸟就会出现在当前位置下方的11像素处,即“鸟下落了11像素”。
  3. 计算管道的移动。为了简化游戏,我们设定上一组管道消失,下一组管道才会出现。计算管道的移动分两种情况:一是下一帧中管道仍在画布内,二是下一帧中管道移动到了画布之外。

管道仍在画布内的情况下,直接让上下管道的x坐标同时减去移动速度(移动速度的初始值是10像素/帧,并且每得10分就加一)即可。这样在下一帧中,上下管道就会出现在当前左侧10像素的位置(即管道发生了移动)。

管道在下一帧中移动到了画布之外(即管道右侧离开画布左侧)的情况下,需要从右侧重新生成管道。生成策略很简单:先产生一个100~300的随机数,作为上侧管道的长度。由于管道间距和地面高度是固定的,用画布高度减去上侧管道长度、管道间距和地面高度,就可以得到下侧管道的长度。得到两个管道的长度后,直接将对应实体的x坐标修改为400(即画布的宽度,因为管道是从右侧出现的,所以它的初始坐标等于画布宽度),然后根据管道长度计算两者在垂直方向上的位置(如上侧管道长度为200像素,管道间距200像素,地面高度为100像素,那么上侧管道左上角的y坐标为0,下侧管道左上角的y坐标为600-200-200 = 200,长度为600-200-200-100=100)。

  1. 得分计算。当鸟通过了管道的右侧位置(即鸟的x坐标大于管道右侧的坐标)后,得分加一。为了防止重复得分,需要在得分后将isScore置为false(因为我们的得分条件是鸟的x坐标大于管道右侧的x坐标(管道的移动是阶跃式的,不一定存在两者正好相等的帧),而鸟在通过管道右侧之后的每一帧都满足该得分条件,如果不关闭计分器,每一帧分数都会加一)。特别的,每当分数是10的倍数,管道的移动速度就加一,这样可以提升游戏难度。

那么游戏怎么响应玩家操作呢?

实际上这个游戏里玩家的操作相当简单,只是在敲击空格键时让鸟向上飞起一段距离而已。它的处理逻辑在键盘监听事件的第二部分中:

    switch (currKey){
        ...
        // 按下的是空格键,让鸟向上“飞动”
        case 32:
            bird.by -= 80;
            break;
    }

敲一下空格键,鸟的纵坐标减80,即“上升”80像素,仅此而已。

你敢信吗?我们的游戏写完了。

如果你想亲手体验一番,总结部分有完整的源代码,以及游戏用到的图片素材(为了方便下载,我直接把代码和图片直接粘在了文章里),欢迎下载学习。如果你想让游戏带声音,这非常非常简单,你只需要添加一个audio标签,链接到你的音频源,播放这段音频即可。如果你希望在初始界面、游戏中和游戏失败时使用不同的音频,直接在这些时刻切换音频源即可。知道吗?写游戏的一大魅力就在于,你的游戏,你说了算!

上面这些代码放在一起甚至不超过两百行,但是基于canvas的游戏制作的精髓却体现得淋漓尽致。

让我们回头总结一下我们做了哪些工作让这个游戏运行起来。

  1. 定义画布。没什么可说的。
  2. 初始化参数。也相当简单,无非是定义了移动速度,下落速度这些固定的参数。
  3. 首绘。为了完成首绘,我们可能做了超过三分之一的工作。最重要的当然是定义了它们各自的构造函数,并用这些构造函数构造出五个实体并依次保存到objects数组中。另外就是定义了一个可以将这些实体绘制出来的drawall方法。
  4. 启动游戏。它的逻辑并不复杂,就是监听回车键敲击事件,启动setInterval计时器,将drawall帧绘制函数传入进去。
  5. 连续的帧绘制。这是整个游戏的核心逻辑,负责计算下一帧中每个物体应该出现在画布的何处。在计算之前,首先需要进行碰撞检测,一旦发现有碰撞,可立即判定游戏失败。之后就是计算鸟的位置,以及管道的位置,并对某些参数进行更新。

总结

基于canvas的游戏开发,最核心的原理并不复杂,关键在于游戏的整体设计,以及细节的处理,这是一个游戏成败的关键。如果你读完本文,已经对canvas游戏开发跃跃欲试了,你可以先把本文的代码复制下来,实际运行一下并品读一番,确定理解了其运行机制再开工不迟。
源代码:

<!DOCTYPE html>
<html>
<head>
    <title>Flappy Bird</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <script type="text/javascript">
        // Edit by xingoo
        // Fork on my github:https://github.com/xinghalo/CodeJS/tree/master/HTML5
        var ctx;
        var cwidth = 400;
        var cheight = 600;
        //存储所有需要绘制的物体
        var objects = [];
        //显示第几个bird
        var birdIndex = 0;
        //以下三个参数用于计算bird的下落速度
        var ver1 = 10;
        var ver2;
        var gravity = 2;
        //通道的间距
        var pipe_height = 200;
        //管道每帧的移动速度的初始值
        var velocity = 10;
        var tid;
        var score = 0;
        //得分是否有效,只有新出现一个管道,得分有效,得分后立即置为无效
        var isScore = false;
        var birds = ["./images/0.gif","./images/1.gif","./images/2.gif"];
        var back = new Background(0,0,400,600,"./images/bg.png");
        var up_pipe = new UpPipe(0,0,100,200,"./images/pipe.png");
        var down_pipe = new DownPipe(0,400,100,200,"./images/pipe.png");
        var ground = new Background(0,550,400,200,"./images/ground.png");
        var bird = new Bird(80,300,40,40,birds);
        //压栈顺序决定了绘制顺序,从第0个物体,即背景开始绘制
        objects.push(back);
        objects.push(up_pipe);
        objects.push(down_pipe);
        objects.push(ground);
        objects.push(bird);
        //背景图
    function Background(x,y,width,height,img_src){
        this.bgx = x; // 背景图坐标
        this.bgy = y;
        this.bgwidth = width; // 背景图宽高
        this.bgheight = height;
        this.image = new Image();
        this.image.src = img_src;
        this.draw = drawbg;
    }
    //背景图的绘制方法,因为背景图使用了整张图,所以不需要裁剪
    function drawbg(){
        ctx.drawImage(this.image,this.bgx,this.bgy,this.bgwidth,this.bgheight);
    }
    
    //顶部管道
    function UpPipe(x,y,width,height,img_src){
        this.px = x; // 管道的横坐标
        this.py = y; // 管道的纵坐标
        this.pwidth = width; // 管道的宽度
        this.pheight = height; // 管道的高度
        this.image = new Image();
        this.image.src = img_src; // 管道对应的图片元素
        this.draw = drawUpPipe; // 管道的绘制方法
    }
    // 顶部管道的绘制方法,它可以将“管道”
    // 从“雪碧图”裁剪出来,绘制到画布上
    function drawUpPipe(){
        ctx.drawImage(this.image,150,500,150,800,this.px,this.py,this.pwidth,this.pheight);
    }
    
    //底部管道,参数同上
    function DownPipe(x,y,width,height,img_src){
        this.px = x;
        this.py = y;
        this.pwidth = width;
        this.pheight = height;
        this.image = new Image();
        this.image.src = img_src;
        this.draw = drawDownPipe;
    }
    //底部管道的绘制方法
    function drawDownPipe(){
        ctx.drawImage(this.image,0,500,150,500,this.px,this.py,this.pwidth,this.pheight);   
    }
    
    //鸟
    function Bird(x,y,width,height,img_srcs){
        this.bx = x;  // 鸟的坐标位置
        this.by = y;
        this.bwidth = width;  // 鸟的宽高
        this.bheight = height;
        this.imgs = []; // 保存三种形态的鸟
        for(var i = 0; i < img_srcs.length; i++){
            this.imgs[i] = new Image();
            this.imgs[i].src = img_srcs[i];
        }
            
        this.draw = drawbird;
    }
    //bird的绘制方法,这里是按顺序绘制三张图片,由birdIndex来控制
    function drawbird(){
        birdIndex++;
        ctx.drawImage(this.imgs[birdIndex%3],this.bx,this.by,this.bwidth,this.bheight);    
    }
        //计算碰撞和更新物体坐标
        function calculator(){
            //与上下管道或地面发生碰撞则游戏失败
            if(bird.by+bird.bheight>ground.bgy ||
                ((bird.bx+bird.bwidth>up_pipe.px)&&(bird.by>up_pipe.py)&&(bird.bx+bird.bwidth<up_pipe.px+up_pipe.pwidth)&&(    bird.by<up_pipe.py+up_pipe.pheight))||
                ((bird.bx+bird.bwidth>up_pipe.px)&&(bird.by>up_pipe.py)&&(bird.bx+bird.bwidth<up_pipe.px+up_pipe.pwidth)&&(    bird.by<up_pipe.py+up_pipe.pheight))||
                ((bird.bx>down_pipe.px)&&(bird.by>down_pipe.py)&&(bird.bx<down_pipe.px+down_pipe.pwidth)&&(bird.by<down_pipe.py+down_pipe.pheight))||
                ((bird.bx>down_pipe.px)&&(bird.by+bird.bheight>down_pipe.py)&&(bird.bx<down_pipe.px+down_pipe.pwidth)&&(bird.by+bird.bheight<down_pipe.py+down_pipe.pheight))){
                clearInterval(tid);
                ctx.fillStyle = "rgb(255,255,255)";
                ctx.font = "30px Accent";
                ctx.fillText("You got "+score+"!",110,100)
                return;
            }
            //更新bird的y坐标,使其下落,这里ver1为10,gravity为2,因此每帧将下落11像素
            ver2 = ver1+gravity;
            bird.by += (ver2+ver1)*0.5;
            //如果当前管道仍然可见,则将其x坐标减少velocity像素,即向左移动,velocity初始值为10,每得10分就会加1,即移动得更快
            if(up_pipe.px+up_pipe.pwidth>0){
                up_pipe.px -= velocity;
                down_pipe.px -= velocity;
            }else{
                //当前管道不可见,则从右侧重新绘制管道,上部管道的高度为100~300的随机数,通道间距固定,下部管道可以直接计算出来
                up_pipe.px = 400;
                down_pipe.px = 400;
                up_pipe.pheight = 100+Math.random()*200;
                down_pipe.py = up_pipe.pheight+pipe_height;
                down_pipe.pheight = 600-down_pipe.py;
                //新出现管道后,计分器打开
                isScore = true;
            }
            //当bird通过了管道右侧,则加一分,并关闭计分器,防止重复得分
            if(isScore && bird.bx>up_pipe.px+up_pipe.pwidth){
                score += 1;
                isScore = false;
                if(score>0 && score%10 === 0){
                    velocity++;
                }
            }
            ctx.fillStyle = "rgb(255,255,255)";
            ctx.font = "30px Accent";
            if(score>0){
                score%10!==0?ctx.fillText(score,180,100):ctx.fillText("Great!"+score,120,100);
            }
        }
        //绘制所有物体,首先清空画布,然后依次绘制objects中的所有物体,然后执行碰撞检测和位置计算
        function drawall(){
            ctx.clearRect(0,0,cwidth,cheight);
            var i;
            for(i=0;i<objects.length;i++){
                objects[i].draw();
            }
            calculator();
        }
        //按下回车键将开启定时器,即开始游戏,回车键使bird上升80像素
        function keyup(e){
            var e = e||event;
               var currKey = e.keyCode||e.which||e.charCode;
               switch (currKey){
                //回车键,设置定时器,开始游戏
                case 13:
                    tid = setInterval(drawall, 80);
                    break;
                //空格键,上升80像素
                case 32:
                    bird.by -= 80;
                    break;
            }
        }    
        //绑定keyup事件,首次绘制画布
        function init(){
            ctx = document.getElementById('canvas').getContext('2d');
            document.onkeyup = keyup;
            drawall();
            ctx.fillText("按回车键开始游戏!", 80, 200);
        }
    </script>
</head>
<body onLoad="init();" style="text-align: center;">
<canvas id="canvas" width="400" height="600">
    Your browser does not support canvas! 
</canvas>
</body>
</html>

你会用到的图片素材(将上面的代码保存到一个html文件内,然后在同级目录新建一个images文件夹,把这些图片全部粘贴进去,图片上方为保存的文件名):
0.gif
在这里插入图片描述
1.gif
在这里插入图片描述
2.gif
在这里插入图片描述
bg.png
在这里插入图片描述
ground.png
在这里插入图片描述
pipe.png
在这里插入图片描述
双击html文件,在浏览器中打开它,你就可以试玩这款游戏了(按回车开始游戏前请用鼠标先在页面内点击一下,确保当前网页获得了焦点)!

你可以凭自己的喜好随意修改游戏参数,或对游戏体验进行优化,或者在游戏中加入更多的想法。这一切都没有限制!谁让你现在是游戏的开发者呢?

发布了44 篇原创文章 · 获赞 98 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/104069911