HTML5 2D游戏开发入门——结合实例,一步一步开发2D小游戏
第一节 静态图片的绘制
在开始开发游戏之前, 首先要了解一下如何利用HTML5来绘制静态的图片, 这是开发2D游戏的基础。在HTML5 Canvas中实现绘图非常简单,大致流程如下: 创建一个canvas,并设置大小
2. 取得canvas的context
3. 加载图片
4. 通过context绘制图片
实现的代码:
- // 创建canvas,并初始化(我们也可以直接以标签形式写在页面中,然后通过id等方式取得canvas)var canvas=document.createElement(”canvas”);canvas.width=600;canvas.height=400;document.body.appendChild(canvas);
复制代码
- // 取得2d绘图上下文var context= canvas.getContext(”2d”);
- // 加载图片,加载后在context上进行绘制。(图片是异步加载,所以在onload事件里进行绘制)var image = new Image();image.src = ”./res/bg。png”;image.onload=function(event){var loadedImg=event.target;// 将加载后的图片,绘制在画布坐标[dx,dy]处,也就是图片的左上角坐标为[dx,dy]var dx=0,dy=0 ;context.drawImage(loadedImg,dx,dy);};
复制代码
上面的例子只是加载并绘制了一张图片。 而开发游戏时,我们往往会需要多张的图片。
为了更好的加载并管理这些图片,通常需要编写一个简单的资源加载的工具函数。
引入该函数后的代码如下:
- //一个简单的图片加载函数function loadImage(srcList,callback){/* 代码略,详见 /step1/step1-2.html 内的loadImage函数 */}
- ImgCache=loadImage( [{ id : “player”,url : “../res/player.png”},{ id : “bg”,url : “../res/bg.png”}],startDemo );
- function startDemo(){
- // 绘制背景var dx=0, dy=0 ;context.drawImage(ImgCache[“bg”],dx,dy);
- //绘制站在地上的player,坐标为200,284var sx=0, sy=60, sw=50, sh=60;var dx=400, dy=284, dw=50, dh=60;context.drawImage(ImgCache[“player”], sx, sy, sw, sh, dx, dy, dw, dh );
- }
复制代码
实现上述需求的代码如下
- // 一些简单的初始化,var FPS=30;var sleep=Math.floor(1000/FPS);var img=ImgCache[“player”];
- //初始坐标var x=0, y=284;//移动速度speedY<0,向上移动。var speedX = 65/1000 , speedY=-45/1000 ;//x/y坐标的最大值和最小值,可用来限定移动范围。var minX=0, maxX=500, minY=0, maxY=284;
- //主循环var mainLoop=setInterval(function(){//距上一次执行相隔的时间。(时间变化量),目前可近似看作sleep。var deltaTime=sleep;
- //每次循环,改变一下绘制的坐标.x=x+speedX*deltaTime; //向右移动y=y+speedY*deltaTime; //向上移动,,坐标y减小,这点和数学中的坐标系不同.
- //限定移动范围x=Math.max(minX,Math.min(x,maxX));y=Math.max(minY,Math.min(y,maxY));
- //使用清空画布的方式,清空之前绘制的图片//context.clearRect(0,0,canvas.width,canvas.height);
- //使用背景覆盖的方式,清空之前绘制的图片context.drawImage(ImgCache[“bg”],0,0);
- //在新位置上绘制图片var sx=0, sy=60, sw=50, sh=60;context.drawImage(img, sx, sy, sw, sh, Math.floor(x), Math.floor(y), sw, sh );
- },sleep);
- 要实现一个Animation,首先要定义Frame。 看一个简单的示例:
- var frame={img : ImgCache[“player”] ,x : 0,y : 60,w : 50,h : 60,duration : 100}
复制代码
其中img属性 ,就是这帧要显示的图片—-之前已经加入到 ImgCache中的那张马里奥的“帧集合”图片。一个Frame只对应这张图片中的一个区域,接下来的4个属性定义了该区域:X,y 区域左上角坐 标。 W,h区域的宽度和高度。duration属性定义了该帧在播放时显示的时间,单位毫秒。上面的定义的帧 最后对应的图像就是下图中蓝框中的部分。
把若干个这样的frame放在一起,然后依次进行更新和绘制 就可以产生动画。一个Animation对象的结构大致如下:
- var animation = {frames : null ,frameCount : -1 ,currentFrame : null ,currentFrameIndex : -1 ,currentFramePlayed : -1 ,img : ImgCache[“player”]}
复制代码
不难发现,这和第二节中,移动图片时的流程非常相像。为了更好的实现这个流程, 也为后面更复杂的场景打下一个坚实的技术,我们可以引入面向对象的思想,对animation相关代码进行重构,封装出如下的Animation类。
- // Animation类。// cfg为Object类型的参数集, 其属性会覆盖Animation原型中定义的同名属性。function Animation(cfg){for (var attr in cfg ){this[attr]=cfg[attr];}}
- Animation。prototype={constructor :Animation ,
- // Animation 包含的Frame,类型:数组frames : null,// 包含的Frame数目frameCount : -1 ,// 所使用的图片id(在ImgCache中存放的Key), 字符串类型。img : null,currentFrame : null ,currentFrameIndex : -1 ,currentFramePlayed : -1 ,
- // 初始化Animationinit : function(){// 根据id取得Image对象this。img = ImgCache[this。img]||this。img;
- this。frames=this。frames||[];this。frameCount = this。frames。length;
- // 缺省从第0帧播放This.setFrame(0);},
- //设置当前帧setFrame : function(index){this.currentFrameIndex=index;this.currentFrame=this。frames[index];this.currentFramePlayed=0;},
- // 更新Animation状态。 deltaTime表示时间的变化量。update : function(deltaTime){//判断当前Frame是否已经播放完成,if (this。currentFramePlayed>=this。currentFrame。duration){//播放下一帧
- if (this.currentFrameIndex >= this。frameCount-1){//当前是最后一帧,则播放第0帧This.currentFrameIndex=0;}else{//播放下一帧this。currentFrameIndex++;}//设置当前帧信息This.setFrame(this。currentFrameIndex);
- }else{//增加当前帧的已播放时间。This.currentFramePlayed += deltaTime;}},
- //绘制Animationdraw : function(gc,x,y){var f=this.currentFrame;gc.drawImage (this.img, f.x , f.y, f.w, f.h , x, y, f.w, f.h );}};
复制代码
其中稍微复杂点的是类的update方法。update方法中的 deltaTime 参数 表示距上一次执行update方法到这次执行所流逝的时间。在本例中,它约等于sleep ,所以在这里我们就使用 sleep。实现动画效果的代码如下:
- // 一些简单的初始化,var FPS=30;var sleep=Math.floor(1000/FPS);
- //初始坐标var x=0, y=284;
- // 创建一个Animation对象var animation = new Animation({img : ”player” ,//该动画由3帧构成,对应图片中的第一行。frames : [{x : 0, y : 0, w : 50, h : 60, duration : 100},{x : 50, y : 0, w : 50, h : 60, duration : 100},} );// 初始化Animationanimation。init();
- //主循环var mainLoop=setInterval(function(){
- //距上一次执行相隔的时间。(时间变化量), 目前可近似看作sleep。var deltaTime=sleep;
- // 更新Animation状态Animation.update(deltaTime);
- //使用背景覆盖的方式 清空之前绘制的图片Context.drawImage(ImgCache[“bg”],0,0);
- //绘制AnimationAnimation.draw(context, x,y);
- },sleep);
复制代码
运行这个示例 可以看到一个原地踏步的马里奥。下面我们结合移动图片的示 例, 来让这个原地踏步的马里奥真正的走动起来。实现Anmation 和 移动的主循环可以合并成一个。 事实上,在游戏开发中通常也都是合并的。无论 有多少个动画 游戏的主线程都只有一个。代码如下:
- // 一些简单的初始化,var FPS=30;var sleep=Math.floor(1000/FPS);
- //初始坐标var x=0, y=284;//移动速度。speedY<0,向上移动。var speedX = 65/1000 , speedY=-45/1000 ;//x/y坐标的最大值和最小值, 可用来限定移动范围。var minX=0, maxX=500, minY=0, maxY=284;
- // 创建一个Animation对象var animation = new Animation({img : ”player” ,//该动画由3帧构成frames : [{x : 0, y : 0, w : 50, h : 60, duration : 100},{x : 50, y : 0, w : 50, h : 60, duration : 100},} );// 初始化AnimationAnimation.init();
- //主循环var mainLoop=setInterval(function(){
- //距上一次执行相隔的时间。(时间变化量), 目前可近似看作sleep。var deltaTime=sleep;
- //每次循环,改变一下绘制的坐标。x=x+speedX*deltaTime; //向右移动y=y+speedY*deltaTime; //向上移动, 坐标y减小,这点和数学中的坐标系不同。
- //限定移动范围x=Math.max(minX,Math.min(x,maxX));y=Math.max(minY,Math.min(y,maxY));// 更新Animation状态Animation.update(deltaTime);
- //使用背景覆盖的方式,清空之前绘制的图片Context.drawImage(ImgCache[“bg”],0,0);
- //绘制Animationanimation。draw(context, x,y);
- },sleep);
复制代码
显然,把一个人物的各种动作和属性,放到一个对象里,进行统一的管理,是一个不错的主意。而这个对象,就是我们常说的精灵(Sprite)。 下面我们引入一些面向对象的思想来编写一个Sprite类:
- function Sprite(cfg){for (var attr in cfg){this[attr]=cfg[attr];}}
- Sprite.prototype={constructor :Sprite ,
- //精灵的坐标x : 0,y : 0,//精灵的速度speedX : 0,speedY : 0,
- //精灵的坐标区间minX : 0,maxX : 9999,minY : 0,maxY : 9999,
- //精灵包含的所有 Animation 集合. Object类型, 数据存放方式为” id : animation ”.anims : null,//默认的Animation的Id , string类型defaultAnimId : null,
- //当前的Animation.currentAnim : null,
- //初始化方法init : function(){//初始化所有Animtionfor (var animId in this.anims){var anim=this.anims[animId];anim.id=animId;anim.init();}//设置当前Animationthis.setAnim(this.defaultAnimId);},
- //设置当前Animation, 参数为Animation的id, String类型setAnim : function(animId){this.currentAnim=this.anims[animId];//重置Animation状态(设置为第0帧)this.currentAnim.setFrame(0);},
- // 更新精灵当前状态。update : function(deltaTime){//每次循环,改变一下绘制的坐标。this.x=this.x+this.speedX*deltaTime; //向右移动this.y=this.y+this.speedY*deltaTime; //向上移动,坐标y减小,这点和数学中的坐标系不同。
- //限定移动范围this.x=Math.max(this.minX,Math.min(this.x,this.maxX));this.y=Math.max(this.minY,Math.min(this.y,this.maxY));
- if (this.currentAnim){this.currentAnim.update(deltaTime);}
- },
- //绘制精灵draw : function(gc){if (this.currentAnim){this.currentAnim.draw(gc, this.x, this.y);}}
- };
复制代码
一个Sprite对象可以是游戏里的一个人物,也可以是一个道具(门、食 物),也可以是一颗子弹、一个景物。具体把把游戏中的哪些物体定义成Sprite,要视游戏而定。在本文示例中, 主角马里奥,以及敌人都定义为 Sprite。前面定义好了Sprite类, 下面看一下如何创建我们的主角马里奥。
- var sprite = new Sprite({
- //初始坐标x : 0,y : 284,
- //移动速度. speedY=0,垂直方向不移动.speedX : 90/1000,speedY : 0,
- //x/y坐标的最大值和最小值, 可用来限定移动范围.minX : 0,maxX : 500,minY : 0,maxY : 284,
- defaultAnimId : ”walk-right”,
- //定义两个Animation,向左走 和 向右走.anims : {“walk-left” : new Animation({img : ”player” ,frames : [{x : 0, y : 60, w : 50, h : 60, duration : 100},{x : 50, y : 60, w : 50, h : 60, duration : 100},{x : 100, y : 60, w : 50, h : 60, duration : 100}} ),
- “walk-right” : new Animation({img : ”player” ,frames : [{x : 0, y : 0, w : 50, h : 60, duration : 100},{x : 50, y : 0, w : 50, h : 60, duration : 100},{x : 100, y : 0, w : 50, h : 60, duration : 100}} )}
- });
复制代码
前面曾用来表示Animation位置和运动方式的几个变量:x、y、 speedX、speedY等,因为都是马里奥的基本属性,所以都设置到Sprite对象上。这个Sprite由两个Animation构成,id分别 为 walk-left 和 walk-right。 想要显示哪个动画,就执行 sprite.setAnim(”动画id”);下面我们改变一下动画的逻辑:马里奥在画面上左右来回走动(垂直方向不动),那么动画的主循环变为:
- // 定义sprite走路速度的绝对值,和默认的speedXSprite.walkSpeed=90/1000;Sprite.speedX=sprite.walkSpeed;
- // 初始化spriteSprite.init();
- //主循环var mainLoop=setInterval(function(){
- //距上一次执行相隔的时间。(时间变化量),目前可近似看作sleep。var deltaTime=sleep;
- // 更新sprite状态Sprite.update(deltaTime);
- //如果做到最右侧,则折向左走,如果走到最左侧,则向右走。//通过改变speedX的正负,来改变移动的方向。if (sprite.x>=sprite.maxX){sprite.speedX=-sprite.walkSpeed;sprite.setAnim(”walk-left”);}else if (sprite.x<=sprite.minX){Sprite.speedX=sprite.walkSpeed;Sprite.setAnim(”walk-right”);}
- //使用背景覆盖的方式 清空之前绘制的图片Context.drawImage(ImgCache[“bg”],0.0);
- //绘制spriteSprite.draw(context);
- },sleep);
- 现在代码变得越来越多, 为了便于维护和阅读, 从本节开始将js代码分文件存放。输入与控制一个可以走来走去的马里奥,显然不能称作是一款游戏, 这里就来说一说,如何实现玩家对游戏角色的控制。对于通过键盘操控的游戏,我们可以通过监听浏览器的keydown/keyup事件来进行检测。代码如下:
- var Key={A : 65,W : 87,D : 68}//用来记录按键状态var KeyState={};
- function initEvent(){//监听整个document的keydown,keyup事件,为了保证能够监听到,监听方式使用Capture
- document.addEventListener(”keydown”,function(evt){//按下某按键,该键状态为trueKeyState[evt.keyCode]=true;},true);document.addEventListener(”keyup”,function(evt){//放开下某按键,该键状态为trueKeyState[evt.keyCode]=false;},true);
复制代码
}通过上面的代码记录按键状态后,就可以在游戏的主循环里根据不同按键状态来执行不同的操作。前面,我们通过判断马里奥的位置来改变运动方向和Animation,下 面来看一看如何通过键盘来控制马里奥左右移动以及跳跃。左右方向是匀速直线运动,所以的原理和前述的类似, 通过改变speedX的正负来改变方向。但是 跳跃相对复杂些,跳跃在垂直方向上属于一种上抛运动, 要引入起跳初速度和重力加速度。由于处理玩家输入以及改变马里奥运动状态的代码比较多,且有相关 性,所以给马里奥的精灵增加一个handleInput方法:
- handleInput : function(){// 读取按键状态,如果A键为按下状态,则向左移动,如果D键为按下状态,则向右移动。var left= KeyState[Key.A];var right= KeyState[Key.D];var up= KeyState[Key.W];
- //取得人物当前面对的方向var dirX=this.currentAnim.id.split(”-”)[1];
- // 判断是否落地if (this.y==this.maxY){this.jumping=false;this.speedY=0;}
- //如果按了上,且当前不是跳跃中,那么开始跳跃。跳跃和走路使用同一个Animation。if (up && !this.jumping){this.jumping=true;this.speedY=this.jumpSpeed;this.setAnim(”walk-”+dirX);}
- if (left && right || !left && !right){// 如果左右都没有按或者都按了,那么水平方向速度为0,不移动this.speedX=0;
- //如果不是在跳跃中,那么进入站立状态,站立时面对的方向根据之前的速度来决定if (!this.jumping){this.setAnim(”stand-”+dirX);}
- }else if(left && this.speedX!=-this.walkSpeed){//如果按下了左,且当前不是向左走,则设置为向左走this.setAnim(”walk-left”);this.speedX=-sprite.walkSpeed;}else if(right && this.speedX!=this.walkSpeed){//如果按下了右,且当前不是向右走,则设置为向右走this.setAnim(”walk-right”);this.speedX=sprite.walkSpeed;}
- }
复制代码
这个handleInput方法还是比较好理解的,但是何时调用它是一个 值得注意的问题。我们通过按键,改变的是移动的速度,而不是直接改变坐标。 如果要让改变的速度生效,这个速度必须要持续一段时间。而这一段时间通常就是 主循环两次迭代之间的间隔。也就是说,我们第n次迭代时改变的速度,要让它在第n+1次生效才有意义。
所以我们在迭代的最后调用 handleInput 方法。// 定义走路速度的绝对值, 默认speedX
Sprite.walkSpeed=200/1000;
Sprite.speedX=0;//定义跳跃初速度,垂直加速度, 默认speedY
Sprite.jumpSpeed=-700/1000;
Sprite.acceY=1。0/1000;
Sprite.speedY=0;
//默认情况下向右站立。
Sprite.defaultAnimId=”stand-right”;// 初始化sprite
Sprite.init();//主循环
var mainLoop=setInterval(function(){//距上一次执行相隔的时间。(时间变化量), 目前可近似看作sleep。
var deltaTime=sleep;
// 更新sprite状态
sprite。update(deltaTime);//使用背景覆盖的方式 清空之前绘制的图片
Context.drawImage(ImgCache[“bg”],0,0);//绘制sprite
Sprite.draw(context);//处理输入,当前输入,影响下一次迭代。
Sprite.handleInput();},sleep);
游戏主控类文示例中,除了主角马里奥之外,还有5个来回移动的敌人。他们也是精 灵。随着精灵的增加和代码的进一步复杂话,我们需要再次重构,封装出游戏总的控制类:Game 。Game类中包含 多个Sprite,在每次迭代时,由 Game负责所有精灵的状态更新和绘制。同时游戏的canvas context、全局的基本属性(如宽高、FPS)、启动函数、主循环等也都包含在 Game里。代码如下:
有了这个主控类之后,创建游戏的流程如下:
1. 创建Animation对象;
2. 创建Sprite对象,并加入相应的Animation;
3. 创建Game对象,并把Sprite加入Game;
4. 初始化Game对象,同时初始化内部的Sprite,以及Sprite内的Animation;
5. 开始游戏。
当游戏中 产生新的精灵(如出现新敌人), 旧的精灵消失(被消灭的敌人)时, 只要对 Game中的sprites集合进行操作即可。
下面看一下重构后的启动代码,简单了许多:
- //声明全局game对象var game;
- // Demo的启动函数function startDemo(){
- //创建game对象game=new Game({FPS : 30,width : 600,height : 400,sprites : [ ]
- });
- //将必要的精灵加入game的精灵列表里//加入马里奥game.sprites.push(createPlayer());
- //初始化gamegame.init();
- //开始gamegame.start();
- }
- }
复制代码
敌人与碰撞检测
有了主控类,向游戏中添加敌人就变得容易了许多。下面试着向游戏中加入5个敌人,由于敌人的外观和属性基本相同,不同的就是初始的坐标,和移动的速度,所以可以将创建敌人的过程封装成一个函数。
- function createEnemy(){
- var r=genRandom(0,1);
- var cfg = {img : ”enemy”,
- x : r?500:0,y : 294,
- //x/y坐标的最大值和最小值,可用来限定移动范围。minX : 0,maxX : 500,minY : 0,maxY : 294,
- handleInput : function(){var s=genRandom(-4,4);var moveSpeed=(150+s*10)/1000;this.speedX=this.speedX||moveSpeed;if (this.x<=this.minX){this.x=this.minX;this.speedX= moveSpeed;}else if (this.x>=this.maxX){this.x=this.maxX;this.speedX=-moveSpeed;}},
- defaultAnimId : ”move”,anims : {
- “move” : new Animation({img : ”enemy” ,frames : [{x : 0, y : 0, w : 50, h : 50, duration : 100 },{x : 50, y : 0, w : 50, h : 50, duration : 100 },{x : 100, y : 0, w : 50, h : 50, duration : 100 },{x : 150, y : 0, w : 50, h : 50, duration : 100 }})}
- };return new Sprite(cfg) ;}
复制代码
在游戏初始化之前,将精灵加入到Game主控类中:
- //将必要的精灵加入game的精灵列表里//加入马里奥game.sprites.push(createPlayer());//加入五个敌人for(var i=0;i<5;i++){game.sprites.push(createEnemy());}
- //初始化gamegame.init();
- //开始game
复制代码
game.start();现在运行游戏后,就可以看到一个马里奥和一群来回移动的敌人了。有了主角 和敌人,下一步要做的就是对两者进行碰撞检测, 判断主角和敌人是否有接触,如果接触到了便Game Over。对于简单的2D游戏,通常只需取 Sprite当前Frame的外框,作为碰撞区域,进行简单的矩形碰撞检测即可。当上图中红色和蓝色矩形有交集时,则认为马里奥和敌人它们发生了,检测函数如下://返回true为两矩形发生碰撞。
- checkIntersect : function(rect1, rect2){return !(rect1.x1>rect2.x2 || rect1.y1>rect2.y2 || rect1.x2<rect2.x1 || rect1.y2<rect2.y1);}
复制代码
其中x1,y1为矩形左上角坐标, x2,y2为矩形右下角坐标。
下面为Sprite类添加两个方法:
- //取得精灵的碰撞区域,getCollRect : function(){if (this.currentAnim){var f=this.currentAnim.currentFrame;return {x1 : this.x,y1 : this.y,x2 : this.x+f.w,y2 : this.y+f.h}}
- },
复制代码
- //判断是否和另外一个精灵碰撞collideWidthOther : function(sprite2){var rect1=this.getCollideRect();var rect2=sprite2.getCollideRect();
- return rect1 && rect2 && !(rect1.x1>rect2.x2 || rect1.y1>rect2.y2 || rect1.x2<rect2.x1 || rect1.y2<rect2.y1);}
复制代码
现在我们就可以使用emeny.collideWidthOther(player)来判断敌人是否和主角发生了碰撞, 如果碰撞了就提示“Game Over”。
为Game类加入碰撞检方法:
- //碰撞检测,返回true 则发生了主角和敌人的碰撞。checkCollide : function(){//本示例中,主角为第一个精灵。var player=this.sprites[0];for (var i=1,len=this.sprites.length;i<len;i++){var sprite=this.sprites;var coll=sprite.collideWidthOther(player);if (coll){return coll;}}return false;},
复制代码
在主循环那恰当的位置进行碰撞检测:
- //主循环中要执行的操作run : function(deltaTime){
- //碰撞检测var coll=this.checkCollide();if (coll){//如果发生敌人和玩家的碰撞,则结束游戏。clearInterval(this.mainLoop);alert(”Game Over”);return;}
- this.update(deltaTime);this.clear(deltaTime);this.draw(deltaTime);
复制代码
//处理输入,当前输入,影响下一次迭代。
this.handleInput();,
现在我们就可以通过键盘上的A/D/W键来控制人物进行移动和躲避敌人了。(要先关闭输入法)。
记录分数
现在,已经完成了游戏核心部分的开发,下面加入对分数的记录和显示,这里的分数就是在游戏中坚持的时间。首先在页面中加入一个简单的浮动div, 用来显示分数。
- <div id=”statebar” style=”font-size:24px; position:absolute; top:10px; left:280px;” >Time : <span id=”timeCount”></span></div>
- 然后在游戏主循环中,更新这个div的显示。修改Game类的start 和run方法如下:
- start : function(){var Me=this;
- // 记录游戏开始时间Me.startTime=Date.now();
- //主循环this.mainLoop=setInterval(function(){//距上一次执行相隔的时间(时间变化量),目前可近似看作sleep。var deltaTime=Me.sleep;Me.run(deltaTime);},Me.sleep);
- },
- //主循环中要执行的操作run : function(deltaTime){
- //显示游戏时间var palyedTime = Date.now()-this.startTime;$(”timeCount”).innerHTML=palyedTime;
- //碰撞检测var coll=this.checkCollide();if (coll){//如果发生敌人和玩家的碰撞,则结束游戏.clearInterval(this.mainLoop);alert(”Game OVer.n Your score : ”+palyedTime);return;}
- this.update(deltaTime);this.clear(deltaTime);this.draw(deltaTime);
- //处理输入,当前输入,影响下一次迭代。this.handleInput();
- },
复制代码
原文链接:http://www.html5so.com/forum.php?mod=viewthread&tid=2615&extra=page%3D3