html5 canvas 自制小游戏系列之 【贪吃蛇】。
【我理解的游戏】
在我的理解里,游戏就是可以交互的动画。所以游戏的原理跟动画是差不多的。
相信动画的原理大家都知道,就是通过一系列的变化来让静态的图片达到动的效果。
不过游戏与动画不同的是,游戏是可以交互的。也就是说,用户对游戏有一定的控制权。游戏也会根据用户的操作来反馈给用户不同的动画,当然也会记录用户在整个游戏中的表现。一般会用分数显示的反馈给用户,他在整个游戏中的表现。
大多数的canvas游戏,是通过不断的擦除canvas然后重绘被擦除的部分。并改变被擦除前那一部分的所有元素的位置或者颜色来达到动画的效果。当然也有部分游戏是根据用户的某个操作来激活某个动作。比如五子棋,就是通过用户在棋盘上的点击来添加一个新的棋子来构成游戏。
当然既然是canvas游戏,对canvas的一些API就一定要熟悉啦,不过不熟悉也没关系拉。 在游戏中用到的API我都会逐一介绍它的用法和用处。下面就正式开始进入游戏开发的过程吧。
【游戏结构】
既然是做游戏,大家可以先想一想。游戏都有哪些基本元素。简单分析一下应该有如下几点是每个游戏都共有 。
开始,暂停,结束。 这三点应该是大多游戏都有的,然后如果要整个游戏运转,肯定会对游戏里面的画面进行更新。 如此一来,就又多了一个更新。这四点应该就是游戏的基础配置了,如此一来我们就可以根据以上四点来搭建一个游戏的基本结构了。 为了以后的游戏也可以同样的使用,我把它写成了一个父函数,可以供游戏的具体函数来继承。 我称它为gamebase
。下面是gamebase函数的所有代码。
/** * @author cat */ function GameBase(){ this.event = { //存储游戏事件 death : function(){}, updates : [] }; this.FPS = 1000 / 60; //游戏刷新速度 this.play = false; //是否开始 } GameBase.prototype = { constructor : GameBase, //游戏更新的单步骤 step : function(){ throw new Error('此方法必须被覆盖!'); }, //是否在运行 isPlay : function(){ return this.play; }, //添加事件 bind : function(listen, callback, time){ if(listen == 'death'){ this.event['death'] = callback; }else if(listen == 'update'){ this.event['updates'] ? true : this.event['updates'] = []; time || (callback.time == time, callback.timer = 0); this.event['updates'].push(callback); } return this; }, //删除事件 unbind : function(listen, fn){ if(listen == 'death'){ this.event['death'] = function(){}; }else if(listen == 'update'){ var i = 0, len = this.event.updates.length; for(; i < len && fn == this.event.updates[i]; i++){ this.event.updates.splice(i, 1); } } return this; }, //游戏开始函数 start : function(){ var self = this; self.startTime = new Date().getTime(); if(!self.isPlay()){ //避免重复开始 self.timer = self.timer = setInterval(function(){ self.step(); },self.FPS); self.play = true; } }, //游戏停止函数 stop : function(){ this.play = false; clearInterval(this.timer); }, //游戏结束函数 death : function(){ this.stop(); this.event['death'].call(this); }, //游戏更新函数 update : function(){ var updates = this.event['updates'], i = 0, len = updates.length, now = new Date().getTime(), update, time; for(; update = updates[i], i < len; i++){ update.time ? (now - (update.timer || 0) > update.time) && (update.timer == now, update.call(this)) : update.call(this); } } } //两个工具方法 var lynx = { extend : function(parent, child){ var fn = function(){}; fn.prototype = parent.prototype; child.prototype = new fn(); child.prototype.constructor = child; return child; }, mix : function(target, source){ var hasOwn = Object.prototype.hasOwnProperty; if( !target || !source ) return; var override = typeof arguments[arguments.length - 1] === "boolean" ? arguments[arguments.length - 1] : true, prop; for (prop in source) { if (hasOwn.call(source, prop) && (override || !(prop in target))) { target[prop] = source[prop]; } } return target; } }
有了上面的这个方法,我们就能够不用在所有的游戏里写开始,暂停,结束,循环了。还有一个好处是把所有的更新操作都以事件的形式放入到一个更新的数组里,然后在一个setInterval里面批量执行,避免了使用多个setInterval。 如果要控制不同的刷新速率,可以通过第三个参数来指定FPS。 当然在update方法是需要在step里面手动调用的,之所以没有在start里面直接调用update方法,是为了更高的可控性。 有了这样一个基础框架,我们就能够开始真正的游戏制作了。(PS: 本博后续游戏系列都会基于这个函数来制作)
【贪吃蛇游戏元素分析】
想想你平时玩贪吃蛇,它有哪些元素。首先,需要有一个背景。蛇就在这个背景中活动。其次是有蛇和蛇的身体。基本就这三个元素。从复杂性上来说,贪吃蛇不是一个特别复杂的游戏。因为在整个游戏过程中,始终只有蛇的身体在运动而已。 而且游戏结束的逻辑也不复杂,蛇头碰到墙壁,或者撞到自己的身体。还有就是在碰到食物的时候将自己本身增长一节。
首先,我们先从最小的元素想起。最小的元素就是食物。 它有着自己的坐标。 有着自己的颜色。 其次它也是组成蛇身体的一部分。所以我们用一个对象来秒数它。大致是这样的。
{ x : 0, y : 0, color : '#ff0000' }
然后我们用一个数组把这些对象串连起来。就形成了一条完整的蛇了。我选择了这样的结构。
[[x, y],[x,y],[x,y]]
因为如果不做特殊处理的话(比如蛇是一个动画,这就要处理转角地方的蛇身了),蛇身的颜色应该是一样的,所以就用存颜色了。 当然如此以来,多使用一个对象,是有点浪费的。 毕竟对象比数组多了key。
然后用一个canvas来做背景。 如此一来这条蛇就有地方可以活动了。 当然最好canvas的长宽是蛇一节身体的倍数。 这样就不用处理蛇走到边缘的时候,以及检测碰壁的时候会方便一些。
首先我们先来创造一条蛇,在游戏中这条蛇每个节点占10 * 10像素的位置。 被显现出来的位置是9 * 9。这样每个节点之间就有一条空隙了。不会太难看。
[ [61,31], [51,31], [41,31], [31,31], [21,31], [11,31], [1,31]];
这样一来,我们就拥有一条蛇了,虽然现在还看不到它。 下面我们来看看这条蛇是怎么动起来的。想一下,它在动的时候有什么规律。 是不是永远都是头部像正在前进的方向移动一步。然后身体顺序都前进一步。 如果上面的这条蛇像x方向移动一步。我们看看会是什么样。
[ [71,31], [61,31], [51,31], [41,31], [31,31], [21,31], [11,31]];
按照规则移动后就变成了上面这样。 不难发现,其实只是在数组的最前面多加了一节,然后删除了最后的一节而已。
这样一来,我们便可以写出整个蛇移动的函数了。因为蛇只有4个方向可以移动,所以在代码里就很好实现了。只要找一个变量保存方向,然后根据方向来走就可以了,具体的实现代码如下。
updateSnapperContext:function(){ var _this = this; Snappers = _this.snapperContext; switch(_this.aspect){ case 1: Snappers.unshift([Snappers[0][0] + 10,Snappers[0][1]]); Snappers.pop(); break; case 2: Snappers.unshift([Snappers[0][0] - 10,Snappers[0][1]]); Snappers.pop(); break; case 3: Snappers.unshift([Snappers[0][0],Snappers[0][1] - 10]); Snappers.pop(); break; case 4: Snappers.unshift([Snappers[0][0],Snappers[0][1] + 10]); Snappers.pop(); break; } _this.eatFood(Snappers); //是否碰到食物 }
最后的代码是检测蛇是否遇到食物。如果遇到就在尾巴上增加一节。之所以增加在尾巴上。是因为整条蛇如果处于即将碰壁的情况下吃到食物。在前方在加一节的话,就导致游戏直接结束了。
蛇搞定了之后,就需要搞定食物了。食物就更简单了,创建一个对象。 用随机数来生成它的xy,随后检测这个点是否与蛇的身体重合。不重合,就返回。如果重合就再次调用自身。直到这个点不再蛇的身上为止。具体实现如下。
getBread:function(){ var _this = this; var x = 10 * Math.floor((Math.random() * (_this.canvasWidth-10) / 10)) + 1 ; var y = 10 * Math.floor((Math.random() * (_this.canvasHeight-10) / 10)) + 1; var bread, color = '#ff0000'; for(var i = 1,cnt = _this.snapperContext.length; i < cnt; i++){ if(_this.snapperContext[i][0] == x && _this.snapperContext[i][1] == y){ _this.bread = {}; bread = _this.getBread(); return bread; } } bread = {"x":x,"y":y,"color":color}; return bread; }
之所以除以10 + 1 是因为蛇的x占位空间为10,而显示空间为9. 所以要如此处理。 这样一来食物也解决了。 剩下的问题就只有,吃到食物和死亡了。 吃到食物比较简单。判断蛇头和食物的x,y是否一致就好了。死亡可以分为两步来检测,一是是否碰撞到墙壁。二是是否碰撞到身体。 也是比较简单的,就不详细讲解了。 有兴趣的可以看代码。
还有最后一个问题就是把整个游戏画出来,呈现给用户看到。 这需要用到fillRect函数,这个函数是专门用来绘制正方形的。 它有四个参数x,y,width,height 分别是x坐标,y坐标,以及长度和宽度。还有一个函数是clearRect。 这个函数用来清除cavvas的context中的像素。 跟fillRect一样。也是四个参数。分别是x坐标,y坐标,以及长度和宽度。
最后在整理一下整个游戏的流程吧。
1.创建一条蛇和食物,并给定一个初始方向。
2.在setInterval中让蛇的身体移动起来。
3.检测是否碰到食物,如果碰到食物就处理吃食物。然后生成一个新的食物。
4.检测是否死亡。如果死亡就结束整个游戏。如果没死亡就画出整条蛇。 当然在画之前要清除上一帧的画面。如此循环 3,4 两个步骤。一直到死亡为止。
下面是整个游戏的源码。
//gamebase.js /** * @author cat */ function GameBase(){ this.event = { //存储游戏事件 death : function(){}, updates : [] }; this.FPS = 1000 / 60; //游戏刷新速度 this.play = false; //是否开始 } GameBase.prototype = { constructor : GameBase, //游戏更新的单步骤 step : function(){ throw new Error('此方法必须被覆盖!'); }, //是否在运行 isPlay : function(){ return this.play; }, //添加事件 bind : function(listen, callback, time){ if(listen == 'death'){ this.event['death'] = callback; }else if(listen == 'update'){ this.event['updates'] ? true : this.event['updates'] = []; time || (callback.time == time, callback.timer = 0); this.event['updates'].push(callback); } return this; }, //删除事件 unbind : function(listen, fn){ if(listen == 'death'){ this.event['death'] = function(){}; }else if(listen == 'update'){ var i = 0, len = this.event.updates.length; for(; i < len && fn == this.event.updates[i]; i++){ this.event.updates.splice(i, 1); } } return this; }, //游戏开始函数 start : function(){ var self = this; self.startTime = new Date().getTime(); if(!self.isPlay()){ //避免重复开始 self.timer = self.timer = setInterval(function(){ self.step(); },self.FPS); self.play = true; } }, //游戏停止函数 stop : function(){ this.play = false; clearInterval(this.timer); }, //游戏结束函数 death : function(){ this.stop(); this.event['death'].call(this); }, //游戏更新函数 update : function(){ var updates = this.event['updates'], i = 0, len = updates.length, now = new Date().getTime(), update, time; for(; update = updates[i], i < len; i++){ update.time ? (now - (update.timer || 0) > update.time) && (update.timer == now, update.call(this)) : update.call(this); } } } //两个工具方法 var lynx = { extend : function(parent, child){ var fn = function(){}; fn.prototype = parent.prototype; child.prototype = new fn(); child.prototype.constructor = child; return child; }, mix : function(target, source){ var hasOwn = Object.prototype.hasOwnProperty; if( !target || !source ) return; var override = typeof arguments[arguments.length - 1] === "boolean" ? arguments[arguments.length - 1] : true, prop; for (prop in source) { if (hasOwn.call(source, prop) && (override || !(prop in target))) { target[prop] = source[prop]; } } return target; } } //snapper.js /** * 贪吃蛇游戏 * @author cat */ //继承方法 var Snapper = lynx.extend(GameBase, function(canvas, width, height){ GameBase.apply(this); this.canvas = canvas; this.canvasWidth = canvas.width; this.canvasHeight = canvas.height; this.context = canvas.getContext("2d"); this.aspect = 1; // 1 - right, 2 - left, 3 - up, 4 - down; this.bread = {}; this.snapperContext = new Array(); this.FPS = 1000 / 10; this.init(); }); //增加函数 lynx.mix(Snapper.prototype, { init:function(){ var _this = this; _this.snapperContext = _this.getSnapperContext(); _this.bread = _this.getBread(); _this.bind('death', function(){ alert('你输了'); }).bind('update', _this.updateSnapperContext).bind('update', _this.updateBread); _this.draw(); }, restart : function(){ this.unbind('update',this.updateSnapperContext).unbind('update',this.updateBread).unbind('death'); this.init(); this.start(); }, step : function(){ var _this = this; _this.update(); if(!_this.checkDeath()){ _this.death(); return _this; } _this.draw(); }, draw : function(){ var _this = this; _this.context.clearRect(0,0,this.canvasWidth,this.canvasHeight); for(var i = 0,cnt = _this.snapperContext.length; i < cnt; i++){ var tmpSnapper = _this.snapperContext[i]; _this.context.fillStyle = "#000000"; _this.context.fillRect(tmpSnapper[0],tmpSnapper[1],9,9); } _this.context.fillStyle = this.bread.color; _this.context.fillRect(this.bread.x,this.bread.y,9,9); }, getBread:function(){ var _this = this; var x = 10 * Math.floor((Math.random() * (_this.canvasWidth-10) / 10)) + 1 ; var y = 10 * Math.floor((Math.random() * (_this.canvasHeight-10) / 10)) + 1; var bread, color = '#ff0000'; for(var i = 1,cnt = _this.snapperContext.length; i < cnt; i++){ if(_this.snapperContext[i][0] == x && _this.snapperContext[i][1] == y){ _this.bread = {}; bread = _this.getBread(); return bread; } } bread = {"x":x,"y":y,"color":color}; return bread; }, updateBread : function(){ var _this = this; if(!_this.bread){ _this.bread = _this.getBread(); } }, getSnapperContext:function(){ return [ [61,31], [51,31], [41,31], [31,31], [21,31], [11,31], [1,31] ]; }, updateSnapperContext:function(){ var _this = this; Snappers = _this.snapperContext; switch(_this.aspect){ case 1: Snappers.unshift([Snappers[0][0] + 10,Snappers[0][1]]); Snappers.pop(); break; case 2: Snappers.unshift([Snappers[0][0] - 10,Snappers[0][1]]); Snappers.pop(); break; case 3: Snappers.unshift([Snappers[0][0],Snappers[0][1] - 10]); Snappers.pop(); break; case 4: Snappers.unshift([Snappers[0][0],Snappers[0][1] + 10]); Snappers.pop(); break; } _this.eatFood(Snappers); //是否碰到食物 }, eatFood:function(Snappers){ var _this = this; if(Snappers[0][0] == _this.bread.x && Snappers[0][1] == _this.bread.y){ Snappers.push(Snappers[Snappers.length - 1]); _this.bread = null; } }, checkDeath:function(){ var _this = this; return _this.snapperContext[0][0] > 0 && _this.snapperContext[0][0] < _this.canvasWidth && _this.snapperContext[0][1] > 0 && _this.snapperContext[0][1] < _this.canvasHeight && _this.collide(); }, collide:function(){ var _this = this; for(var i = 1,cnt = _this.snapperContext.length; i < cnt; i++){ var tmpSnapper = _this.snapperContext[i]; if(_this.snapperContext[0][0] == tmpSnapper[0] && _this.snapperContext[0][1] == tmpSnapper[1]){ return false; } } return true; } }); //index.html <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta http-equiv="Content-Type" content="text/html,charset=utf-8" /> <title>贪吃蛇</title> <script type="text/javascript" src="scripts/gamebase.js"></script> <script type="text/javascript" src="scripts/snapper.js"></script> <script type="text/javascript"> window.onload = function(){ var canvas = document.getElementById("canvas"); canvas.width = 300; canvas.height = 300; canvas.style.border = 'solid 1px #cccccc'; var snapper = new Snapper(canvas); if(window.addEventListener){ window.addEventListener("keydown",function(e){ var e = e || event || windwo.event; if(e.keyCode == 37){ if(snapper.aspect != 1){ snapper.aspect = 2; } }else if(e.keyCode == 38){ if(snapper.aspect != 4){ snapper.aspect = 3; } }else if(e.keyCode == 39){ if(snapper.aspect != 2){ snapper.aspect = 1; } }else if(e.keyCode == 40){ if(snapper.aspect != 3){ snapper.aspect = 4; } } },false); document.getElementById('start').addEventListener('click', function(e){ snapper.start(); }, false); document.getElementById('stop').addEventListener('click', function(e){ snapper.stop(); }, false); document.getElementById('restart').addEventListener('click', function(e){ snapper.restart(); }, false); }else if(window.attachEvent){ window.attachEvent("onkeydown",function(e){ var e = e || event || windwo.event; if(e.keyCode == 37){ if(snapper.aspect != 1){ snapper.aspect = 2; } }else if(e.keyCode == 38){ if(snapper.aspect != 4){ snapper.aspect = 3; } }else if(e.keyCode == 39){ if(snapper.aspect != 2){ snapper.aspect = 1; } }else if(e.keyCode == 40){ if(snapper.aspect != 3){ snapper.aspect = 4; } } }); document.getElementById('start').attachEvent('onclick', function(e){ snapper.start(); }); document.getElementById('stop').attachEvent('onclick', function(e){ snapper.stop(); }); document.getElementById('restart').attachEvent('onclick', function(e){ snapper.restart(); }); } } </script> </head> <body> <canvas id="canvas"> </canvas> <input type="button" id="start" value="开始" /> <input type="button" id="stop" value="暂停" /> <input type="button" id="restart" value="重新开始" /> </body> </html>
由于我一直不知道要怎么样在博客园里放一点开就是一个运行的页面的代码。所以没办法把演示页面放在这里。如果有那位大侠知道的话,麻烦指点一下。
【结束】
虽然整篇文章没有对所有代码的函数都分析到,但是整个流程以及关键点都已经说的比较清楚了。如果看完还有什么疑问的话,欢迎提出。 最后,留一个问题给大家,就是怎么样来判断用户赢了。也就是蛇布满了整个画布。(ps: 题目很简单哦,实现的办法也很多。)