BeWithYou

胡搞的技术博客

  1. 首页
  2. web前端/Javascript
  3. H5实现的flappy bird

H5实现的flappy bird


这是去年做的东西,那个时候还在tx上班,有一段时间比较闲,天天吹水摸鱼……其实也挺好的,有自己的时间才会有心思研究其他好玩的东西。现在整理一下放上来吧。(PS:图片比较多,请自行隐藏边栏)

作为一名后端程序员,如何在不用深入了解游戏开发的同时,快速山寨出一个小游戏?本文主要简单记录一下本弱菜开发的思路和经历,不喜请轻喷。

先上个图,在微信里运行的效果。自行忽略这丑陋的字体……

  

然后附上demo如下。源代码的话,全是web前端内容,直接去浏览器抓就行了。

demo地址

像素鸟刚在国内火起来的时候,就有山寨一个的想法了,于是说干就干。其实在今年2月份就已经开发完成了,中间修改和更新了几次,现在整理一下分享出来。整个工程不到1000(不包括第三方js)行代码,构思用了一周时间,实际开发只熬了两天夜。

一、技术平台选取

要兼容移动端和PC端,又不用花太大功夫熟悉新的开发平台,HTML5确实是不二之选,只要会Javascript和简单的html就可以了。最重要的是,部署到服务器之后,可以很方便的在朋友圈里分享出来装逼。

二、必备的基础知识

1、HTML5开发相关api的使用,主要是canvas绘图方面的知识,这里查查手册就行了。

2、对于游戏运行机制的分析,像素鸟游戏本身十分简单,本质上就是一个存在重力的二维物理世界。所以我们需要解决的重点问题只有三个:①如何模拟物体运动;②如何进行碰撞检测;③如何让游戏逻辑串起来。

三、如何解决上面的问题

第1个问题:如何模拟物体运动。

我查阅了一些资料,找到一篇很不错的文章,对于初学者来说详细讲解了运动学模拟的一些知识。文章作者是公司的大神,可以去膜拜一下。地址如下:http://www.cnblogs.com/miloyip/archive/2010/06/14/1758272.html

我们分析小鸟的飞行,x轴匀速,y轴存在向下的重力加速度,并且每次tap屏幕的时候,不论小鸟当时坠落速度如何,都会将其在y轴设定为一个向上的初始速度。简单的说,就是一个抛物运动

 (抛物运动)

分别是t=0时的y轴起始座标和速度,而g则是重力加速度。

游戏中,计算连续运动的物体的状态,采用帧的概念。设物体在任意时间t的状态:位置矢量为、速度矢量为、加速度矢量为。我们希望从时间的状态,计算下一个模拟时间的状态。最简单的方法,是采用欧拉方法(Euler method)作数值积分(numerical integration):

(欧拉方法)

以上都是摘自那篇文章。

于是我们用js实现一个向量类Vector。

/**
 * vector向量类
 */
Vector = function(x,y)
{
	this.x=x;
	this.y=y;
}
Vector.prototype = 
{
    add : function(v) { this.x = this.x+v.x; this.y = this.y+v.y; },
    subtract : function(v) { this.x = this.x - v.x; this.y = this.y - v.y; },
    multiply : function(f) { return new Vector(this.x * f, this.y * f); }
};
Vector.zero = new Vector(0, 0);

里面封装了向量的x,y属性,以及必须用到的操作add(向量加法)、subtract(减法)、multiply(向量乘法)。

游戏中每个元素(小鸟,背景,地面,水管)在二维坐标中都可以视作一个精灵sprite,继承了vector的属性和方法。并且必须实现自己的draw方法,即绘图方法。但是每种元素的运动模型不同,所以还必须继承sprite类,并实现各自的update方法,即每一帧更新自己状态。

首先简单给下sprite类的实现:

/**
 * sprite精灵类 继承vector
 */
Sprite = function(img,sx,sy,sw,sh,ifCut,x,y,angle)
{
	this.img = img;
	this.sx = sx;
	this.sy = sy;
	this.width = sw;
	this.height = sh;
	this.ifCut = ifCut;
	this.x = x;
	this.y = y;
	this.angle = angle;
	this.halfWidth = this.width/2;
	this.halfHeight = this.height/2;
	Vector.call(this,x,y);
}
Sprite.prototype = new Vector();
Sprite.prototype.draw = function(context)
{
	context.save();
	//不需要旋转的对象
	if(this.angle == 0)
	{
		if(this.ifCut)
		{
			//其实最后两个参数代表画出来图的大小 用于缩放 我们不需要缩放 所以直接用截取的大小
			context.drawImage(this.img,this.sx,this.sy,this.width,this.height,this.x,this.y,this.width,this.height);
		}else{
			//不需要截取 直接画
			context.drawImage(this.img,this.x,this.y);
		}		
	}else{
		//需要旋转
		context.translate(this.x + this.halfWidth, this.y + this.halfHeight);
		//context.globalAlpha = this.alpha;//不需要修改透明度
		context.rotate(this.angle);//旋转角度
		//context.scale(this.scaleX, this.scaleY);//不需要缩放
                if(this.ifCut)
		{
			//其实最后两个参数代表画出来图的大小 用于缩放 我们不需要缩放 所以直接用截取的大小
			context.drawImage(this.img,this.sx,this.sy,this.width,this.height,-this.halfWidth,-this.halfHeight,this.width,this.height);
		}else{
			//不需要截取 直接画
			context.drawImage(this.img,-this.halfWidth,-this.halfHeight);
		}
	}
	context.restore();
}

Sprite.prototype.drawWithAngle = function(context,angle,centerx,centery)
{
	context.save();
	//不需要旋转的对象
	if(angle == 0)
	{
		if(this.ifCut)
		{
			//其实最后两个参数代表画出来图的大小 用于缩放 我们不需要缩放 所以直接用截取的大小
			context.drawImage(this.img,this.sx,this.sy,this.width,this.height,this.x,this.y,this.width,this.height);
		}else{
			//不需要截取 直接画
			context.drawImage(this.img,this.x,this.y);
		}		
	}else{
		//需要旋转
		context.translate(centerx, centery);
		//context.globalAlpha = this.alpha;//不需要修改透明度
		context.rotate(angle);//旋转角度
		//context.scale(this.scaleX, this.scaleY);//不需要缩放
        if(this.ifCut)
		{
			//其实最后两个参数代表画出来图的大小 用于缩放 我们不需要缩放 所以直接用截取的大小
			context.drawImage(this.img,this.sx,this.sy,this.width,this.height,this.x-centerx,this.y-centery,this.width,this.height);
		}else{
			//不需要截取 直接画
			context.drawImage(this.img,this.x-centerx,this.y-centery);
		}
	}
	context.restore();
}


javascript实现继承的方法比较蛋疼,这里采用原型+call的方法实现面向对象和继承。Draw方法中,调用了HTML5中关于画布canvas的相关api,这里就不赘述了。DrawWithAngle方法只是从外部传了一个角度和中心坐标进去,后面用于计算碰撞检测。是否截取,取决于图片素材是否完整,如图小鸟的三种状态都在一个img里面,所以需要截取后draw在画布上。而其他素材则是完整的一张图片,不需要截取。

(小鸟图)

水管tube本来可以只用一张图片,采用旋转方式来画的,但是为了提高浏览器的计算效率,在这里分开成down和up两个图片来画。

然后,以运动模型最为复杂的小鸟为例,给出实现小鸟的代码:

/**
 * bird小鸟 继承sprite 
 */
Bird =function(img,sx,sy,sw,sh,ifCut,x,y,angle)
{
	Sprite.call(this,img,sx,sy,sw,sh,ifCut,x,y,angle);
	this.birdIndex = 0;
	this.birdChangeDirection = 1;
	this.velocity = new Vector(0,-BIRD_UP_SPEED);
}
Bird.prototype = new Sprite();
Bird.prototype.update = function(game)
{
	this.changeBirdIndex(game);
	if(game.status == PLAYING)
	{
		//向上撞到墙顶 失去速度			
		if(this.y <= 0 && this.velocity.y<0)
		{
			this.velocity.y = 0;
			return;
		}
		if(this.y >= GROUND_Y-BIRD_HEIGHT)
		{		
			Sound.playHit();
			game.status = END;
			return;
		}
		this.add(this.velocity.multiply(game.option.dt));
		this.velocity.add(game.option.acceleration.multiply(game.option.dt));
		//向上飞的时候 头朝上
		if(this.velocity.y<0)
		{
			this.angle += game.option.birdAngleUpStep;
			if(this.angle < BIRD_MAX_UP_ANGLE)
			{
				this.angle = BIRD_MAX_UP_ANGLE;
			}
		}else{
			this.angle += game.option.birdAngleDownStep;
			if(this.angle > BIRD_MAX_DOWN_ANGLE)
			{
				this.angle = BIRD_MAX_DOWN_ANGLE;
			}
		}
		
	}else if(game.status == DYING){
		if(this.velocity.y<0)
		{
			this.velocity.y=0;
		}
		if(this.y >= GROUND_Y-BIRD_HEIGHT)
		{
			game.status = END;
			return;
		}
		this.add(this.velocity.multiply(game.option.dt));
		this.velocity.add(game.option.acceleration.multiply(game.option.dt));
		if(this.velocity.y<0)
		{
			this.angle += game.option.birdAngleUpStep;
			if(this.angle < BIRD_MAX_UP_ANGLE)
			{
				this.angle = BIRD_MAX_UP_ANGLE;
			}
		}else{
			this.angle += game.option.birdAngleDownStep;
			if(this.angle > BIRD_MAX_DOWN_ANGLE)
			{
				this.angle = BIRD_MAX_DOWN_ANGLE;
			}
		}

	}else if(game.status == WELCOME){
		this.add(this.velocity.multiply(game.option.dt));
		if(this.y >BIRD_POSITION+BIRD_OFFSET_LIMIT || this.y < BIRD_POSITION-BIRD_OFFSET_LIMIT)
		{
			this.velocity.y = -this.velocity.y;
		}
	}
	
}
Bird.prototype.changeBirdIndex = function(game)
{
	this.birdIndex += game.option.birdChangeStep;//1秒钟换10次
    if(this.birdIndex>1)
    {
        this.sx += BIRD_WIDTH*this.birdChangeDirection;
        if(this.sx == 0 || this.sx == 2*BIRD_WIDTH)
        {
            this.birdChangeDirection = -this.birdChangeDirection;            
        }
        this.birdIndex = 0;
    }
}
Bird.prototype.ifHit = function(spriteArr)
{
    if(spriteArr.length<2)
    return false;
    var canvas = document.createElement('canvas');
    canvas.setAttribute('width', CANVAS_WIDTH);
    canvas.setAttribute('height', CANVAS_HEIGHT);
    var context = canvas.getContext('2d');
	
    //this.draw(context);
    //用这种不带角度的draw把鸟平铺
    this.drawWithAngle(context,0,0,0);
    //原来的方法其实不准,因为取出来是按照没有旋转来取的 现在改成把鸟平放,水管斜着放
    var data1 = context.getImageData(this.x, this.y, this.width, this.height).data;
    context.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    for(var i=0;i<spriteArr.length;i++)
    {
        //这里做优化 this.x其实是固定的 加上倾斜以后最大位置的判断 不然会漏掉
        //if(spriteArr[i].x+TUBE_WIDTH<this.x || spriteArr[i].x>this.x+BIRD_WIDTH)
        if(spriteArr[i].x+TUBE_WIDTH<BIRD_MIN_X || spriteArr[i].x>BIRD_MAX_X)
        continue;
        //把水管绕小鸟的中心反过来转。。等于小鸟转了
        spriteArr[i].drawWithAngle(context,-this.angle,BIRD_CENTER_X,this.y-BIRD_WIDTH/2);
    }   
    var data2 = context.getImageData(this.x, this.y, this.width, this.height).data;	
    for(var i = 3; i < data1.length; i += 4)
    {
        if(data1[i] > 0 && data2[i] > 0) 
		return true;
    }
    return false;
}


Bird类中,update方法用于更新每一帧的状态(这里用的是先画再更新,画的时候用上一次更新完的量)。其中,先根据时间来更换小鸟的贴图(即上面三种状态),实现小鸟在拍打翅膀的效果。然后根据游戏状态做相应的属性调整,根据欧拉方法,位置每次add一个速度*Δt,然后速度每次add一个加速度*Δt。小鸟不仅有xy轴的变化,还有旋转角度的变化,这里设定为向上飞时每帧抬头固定角度,向下飞时每帧低头固定角度,并且有上下限,很方便的模拟出原版游戏的动态效果。ifHit方法用于碰撞检测,放到下面讲。

第2个问题:如何进行碰撞检测。

碰撞检测就是两个物体是否接触了,比如子弹是否打到敌人了,主角是否吃到金币了等等,flappy bird里的碰撞检测,就是小鸟与水管之间是否接触了。碰撞检测的方法有很多种,最简单的就是两个矩形是否有接触了,直接用坐标点比较一下就ok了,实际上我当时看到网络上其他人实现的flappy bird就是用的这种方法,相当简陋。这种方法明显不适合我,因为哥的鸟是可以旋转的!

这里采用了一个投机取巧的方法——像素点判重ifHit方法中,先将小鸟画到一个不可见的canvas上,取出所有像素点,清空画布。然后循环遍历sprite数组(水管队列),将每个水管也画到那个canvas上。注意小鸟是有角度的,这里把每个水管都以小鸟坐标旋转相反的角度,达到“鸟不转水管转”的效果。之后取出所有水管的像素点,比较在某一点上是否跟小鸟的像素点有重合。如果有重合,则发生了碰撞。

PS:取像素点的时候,取出来是这样的数组:每连续的4个位为一个像素点,分别代表R,G,B和alpha值。我们直接用alpha值来判断就ok啦!

第3个问题:如何让游戏逻辑串起来。

其实很简单,说到底就是根据游戏的状态,做if else和switch case。

游戏逻辑放在一个loop里面,用setInterval来间断执行。Loop要做的就是每次画背景,画水管,更新水管,画地面,更新地面,画小鸟,更新小鸟,善后处理。至于如何控制游戏的帧数,直接放在setInterval里,用间隔来控制就好啦!

四、非主线任务

1、实现屏幕自适应

这个一直以来都是web开发最烦扰的问题。不过很幸运,游戏本身只有一个canvas,而canvas的height和width特性简直让人爽歪歪——他的长和宽只要设定成固定大小就可以了,屏幕适配的问题,交给css的style去做就行了。简单来说,canvas的大小,根据图片素材真是大小来指定,自己琢磨着舒服就行。我们调整他的style以后,画布里的内容会自行缩放。

2、实现音效

至今未能解决。因为H5自带的audio组件不能支持快速和重叠的音效。目前处理方式是这样的,支持web audio的浏览器,直接用web audio的音频池来播放音效。不支持web audio的浏览器,用audio-fx的第三方库来支持。安卓设备,对不起,只能全屏沉默了……不过肯定是有方法的,希望大神们不灵赐教。

3、实现微信分享功能

很简单,把自己得到的分数放到朋友圈的分享title里即可。代码都可以搜到。实现效果如下图:

 

 

回到顶部