这是公司的一个h5游戏项目,找茬的游戏相信大家都玩过不多说了上图。
游戏过程:开始游戏 -> 找茬 -> 游戏结束
具体需求:游戏有5个不同点,需要在30秒内找到,找错3次或时间耗尽则失败。
拿到这个项目后我首先分析,是用canvas游戏引擎做,还是用dom+js+css3来做,毕竟后者开发速度以及游戏大小来说更优。因为操作dom比较少,无非是隐藏显示和简单的动画效果,不会太耗性能,所以我选择了后者。
操作dom肯定要请出jquery这个老将,加上require来做模块化和依赖加载,再写一些css3的动画效果,好了,整个项目的架构有了:
项目名称:quickspot
css文件夹:loading.css进度条动画、main.css样式主文件
data文件夹:gamecfg.json游戏配置、res.json游戏资源
img图片文件夹
js文件夹:ajax.js与后台通信、event.js游戏事件回调、game.js游戏主文件、res.js游戏预加载
libs文件夹:库文件
根目录的index.html是游戏入口文件,main.js是依赖加载的主配置文件
本文不适合js初学者,一些基础的东西将跳过,比如css3、require的使用,不会的朋友可以看这方面的教程,接下来我逐步分析每一个文件。
main.js不多说了,是文件依赖的一些配置操作。懂require的朋友一眼就明白啦
'use strict'; (function (win) { //配置baseUrl var baseUrl = document.getElementById('main').getAttribute('data-baseurl'); /* * 文件依赖 */ var config = { baseUrl: baseUrl, //依赖相对路径 paths: { //如果某个前缀的依赖不是按照baseUrl拼接这么简单,就需要在这里指出 'jquery.mousewheel': 'libs/jquery.mousewheel', 'jquery': 'libs/jquery1.12.0.min', 'esmere': 'libs/esmere', 'game': 'js/game', 'ajax': 'js/ajax', 'event': 'js/event', 'res': 'js/res' }, shim: { //引入没有使用requirejs模块写法的类库。 'jquery': { exports: '$' }, 'jquery.mousewheel': { deps: ['jquery'] }, 'esmere': { deps: ['jquery','jquery.mousewheel'], exports: 'esmere' }, 'ajax': { deps: ['esmere'] }, 'game': { deps: ['esmere','ajax'] }, 'event': { deps: ['esmere','game'] }, 'res': { deps: ['esmere','game','event'] } } }; require.config(config); //esmere会把自己加到全局变量中 require(['esmere','res'], function(esmere,res){ var resize = function(elem,w,h){ var dw = w, dh = h, cw = $(window).width(), ch = $(window).height(); var bw = cw > dw ? cw / dw : 1 / (dw / cw), bh = ch > dh ? ch / dh : 1 / (dh / ch); var w = Math.min(dw*bh,cw), h = Math.min(dh*bw,ch); elem.css('width',w) .css('height',h) .css('top',ch*0.5 - h*0.5) .css('left',cw*0.5 - w*0.5) .css('position','absolute'); }; var onresize = function(){ resize($('.main'),1008,640); }; onresize(); $(window).on('resize',function(){ onresize(); }); }); })(this);
index.html也没啥好说的,因为不是重点代码有点乱,主要看一下html结构就行。
<!DOCTYPE html> <head> <meta http-equiv="Content-Type" content="text/html;charset=utf-8"/> <meta name="apple-mobile-web-app-capable" content="yes"/> <meta name="full-screen" content="yes"/> <meta name="screen-orientation" content="portrait"/> <meta name="x5-fullscreen" content="true"/> <meta name="360-fullscreen" content="true"/> <meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" /> <title>大家来找茬</title> <style> body, canvas, div { -moz-user-select:none; -webkit-user-select:none; -ms-user-select:none; -khtml-user-select:none; -webkit-tap-highlight-color:rgba(0, 0, 0, 0); } body{margin:0px;padding:0px;background:url('img/bg.jpg');background-size:cover;} div, p, ul, ol, dl, dt, dd, form{padding:0;margin:0;list-style-type:none;} img{width:100%;height:100%;display:block;border:0;} </style> <link href="css/loading.css" rel="stylesheet" type="text/css" /> <link href="css/main.css" rel="stylesheet" type="text/css" /> </head> <body> <div class="main"> <div class="life"><img src="img/0.png"></div> <div class="time"> <ul class="s1"><img src="img/3.png"></ul> <ul class="s2"><img src="img/0.png"></ul> <img src="img/time.png"> </div> <div class="game"> <ul></ul> </div> <div class="mask"></div> <div class="start"> <ul class="text"><img src="img/text.png"></ul> <ul class="btn play"><img src="img/start.png"></ul> <img src="img/layer.png"> </div> <div class="over"> <ul class="title"><img src="img/title1.png"></ul> <ul class="btn replay"><img src="img/replay.png"></ul> <ul class="btn share"><img src="img/share.png"></ul> <ul class="btn go"><img src="img/go.png"></ul> <img src="img/layer.png"> </div> <img src="img/main.png"> </div> <div class="sharet"> <ul><img src="img/sharet.png"></ul> </div> <div id="heng" style="width:100%;height:100%;z-index:1000;position:absolute;display:none"><img src="img/heng.png"></div> </body> <script> //判断手机横竖屏状态 //http://www.w3cways.com/1772.html var getOrient = function(){ if (window.orientation != undefined) { return window.orientation % 180 == 0 ? "portrait": "landscape" } else { return (window.innerWidth > window.innerHeight) ? "landscape": "portrait" } }; var heng = document.getElementById('heng'); if(getOrient() == "portrait"){ //console.log('竖屏状态!'); heng.style.display = 'block'; }else{ //console.log('横屏状态!'); } //判断手机横竖屏状态: window.addEventListener("onorientationchange" in window ? "orientationchange" : "resize", function() { if(getOrient() == "portrait"){ //console.log('竖屏状态!'); }else{ //console.log('横屏状态!'); heng.style.display = 'none'; } }, false); </script> <script data-baseurl="./" data-main="main.js" src="libs/require.js" id="main"></script> </html>
main.css
.main{width:auto;height:auto;position:absolute;overflow:hidden;display:none;} .life{width:3%;position:absolute;left:20%;top:5%;} .time{width:6%;position:absolute;right:10%;top:5%;} .time ul{width:50%;position:absolute;} .time .s1{left:0;top:0;} .time .s2{right:0;top:0;} .game{width:40%;height:84%;position:absolute;top:14%;left:50%;overflow:hidden;} .game li{position:absolute;} .mask{width:100%;height:100%;position:absolute;left:0;top:0;background:#000;opacity:0.5;} .start, .over{height:90%;position:absolute;left:50%;top:50%; transform: translate(-50%,-50%); -ms-transform: translate(-50%,-50%); -webkit-transform: translate(-50%,-50%); -o-transform: translate(-50%,-50%); -moz-transform: translate(-50%,-50%);} .over{display:none;} .start .btn, .over .btn{width:40%;position:absolute;left:50%; transform: translate(-50%,0); -ms-transform: translate(-50%,0); -webkit-transform: translate(-50%,0); -o-transform: translate(-50%,0); -moz-transform: translate(-50%,0);} .start .text{width:70%;position:absolute;left:50%; transform: translate(-50%,40%); -ms-transform: translate(-50%,40%); -webkit-transform: translate(-50%,40%); -o-transform: translate(-50%,40%); -moz-transform: translate(-50%,40%);} .start .play{bottom:10%;} .over .title{width:70%;position:absolute;left:50%; transform: translate(-50%,35%); -ms-transform: translate(-50%,35%); -webkit-transform: translate(-50%,35%); -o-transform: translate(-50%,35%); -moz-transform: translate(-50%,35%);} .over .replay{top:45%;} .over .share{top:60%;} .over .go{top:75%;} .sharet{width:100%;height:100%;position:absolute;top:0;left:0;background:#000;opacity:0.8;display:none;} .sharet ul{width:40%;height:30%;position:absolute;top:0;right:0;} .fail{ -webkit-animation: fail 2s linear forwards; -moz-animation: fail 2s linear forwards; animation: fail 2s linear forwards} @-webkit-keyframes fail { 0% {opacity:1;} 100% {opacity:0;} } @-moz-keyframes fail { 0% {opacity:1;} 100% {opacity:0;} } @keyframes fail { 0% {opacity:1;} 100% {opacity:0;} }
data文件夹是存放游戏的配置文件,比如游戏的生命值、时间、子弹数量等属性,以及游戏中所用的图片链接地址。可能有的人会说我直接写在js代码里面也可以啊,这么小的项目有必要搞这么复杂吗,的确这样做也是可以的而且很多人都是这样干的,但是这样做并不规范,游戏开发应尽量避免出现硬编码。如果客户改变需求,我们不需要去js代码中寻找,仅仅在配置文件中修改一个值即可。好了,说了这么多,我们来看下配置文件的内容吧。
res.json有2个属性,分别为image和cfg,image保存了图片名字和图片路径,cfg保存了游戏配置文件的名称和路径
{ "image":[ {"name":"bg","src":"img/bg.jpg"}, {"name":"main","src":"img/main.png"}, {"name":"bingo","src":"img/bingo.png"}, {"name":"bingo2","src":"img/bingo2.png"}, {"name":"fail","src":"img/fail.png"}, {"name":"time","src":"img/time.png"}, {"name":"0","src":"img/0.png"}, {"name":"1","src":"img/1.png"}, {"name":"2","src":"img/2.png"}, {"name":"3","src":"img/3.png"}, {"name":"4","src":"img/4.png"}, {"name":"5","src":"img/5.png"}, {"name":"6","src":"img/6.png"}, {"name":"7","src":"img/7.png"}, {"name":"8","src":"img/8.png"}, {"name":"9","src":"img/9.png"}, {"name":"start","src":"img/start.png"}, {"name":"text","src":"img/text.png"}, {"name":"title1","src":"img/title1.png"}, {"name":"title2","src":"img/title2.png"}, {"name":"replay","src":"img/replay.png"}, {"name":"share","src":"img/share.png"}, {"name":"go","src":"img/go.png"}, {"name":"layer","src":"img/layer.png"}, {"name":"sharet","src":"img/sharet.png"} ], "cfg":[ {"name":"gf0","src":"data/gamecfg.json"} ] }
gamecfg.json 我分析一下各属性的含义:
time:游戏时间
life:剩余机会
done:游戏中5个不同点的信息
w : 圆圈的宽度(百分比)
h : 圆圈的高度(百分比)
x : 左边的距离(百分比)
y : 上边的距离(百分比)
name : res.json中图片的名称(用于在游戏中根据图片名称获取图片路径)
fail:游戏中点错了会显示一个叉,它的信息格式与done一致
link:游戏结束后的跳转链接地址
{
"time":30,
"life":3,
"done":[
{"w":16,"h":13,"x":92,"y":23,"name":"bingo"},
{"w":15,"h":12,"x":8,"y":38,"name":"bingo"},
{"w":16,"h":13,"x":8,"y":72,"name":"bingo"},
{"w":15,"h":12,"x":26,"y":81,"name":"bingo"},
{"w":10,"h":48,"x":88,"y":76,"name":"bingo2"}
],
"fail":{"w":10,"h":8,"name":"fail"},
"link":"http://ws.4008117117.com/guangming/index.php"
}
js文件夹放了4个文件,我先说其中的2个具有代表性的。
ajax的变动是比较大的,而且还关系到与后端配合的问题,所以我把他分离出来,也是为了日后项目交接给其他同事修改方便。event.js里面保存了游戏中所有的事件回调,我们不用关心去给哪个元素绑定什么样的事件以及回调,我们只需要往evnet文件里填充我们需要执行的回调,绑定事件的杂货累活由game.js里的eventCommend函数来统一处理。
ajax.js
define(['esmere'], function (esmere) { return { postInfo:function(data){ $.ajax({ url:"http://ws.4008117117.com/guangming/project/route.php", type:"POST", //dataType:"json", data:{ 'flow':'setPlayPass', 'play':'zhaocha', 'done':'done' }, success:function(data){ //alert(data); }, error:function(data){ //alert(data); } }); } }; });
event.js
define(['esmere','game','ajax'], function (esmere,game,ajax) { var event = { move:function(e){ game.prevent = true; }, game:function(e){ if(game.prevent) return (game.prevent = false); //获取手指抬起时在文档中的位置 e = e.originalEvent.changedTouches[0]; //计算偏移值,获取手指相对于元素的坐标 var pageX = e.pageX-$(this).offset().left, pageY = e.pageY-$(this).offset().top; //px转百分比 pageX = pageX / $(this).width() * 100; pageY = pageY / $(this).height() * 100; game.bingoapi(pageX,pageY,game) .done(function(rect){ this.createapi.oo.call(game,rect.w,rect.h,rect.x,rect.y,rect.name); --this.bingo; }) .fail(function(rect){ this.createapi.xx.call(game,rect.w,rect.h,rect.x,rect.y); --this.life; }); }, //开始游戏 play:function(){ game.rePlay(); }, //重玩 replay:function(){ game.rePlay(); }, //分享 share:function(){ game.render.showShare(); }, //回主页 go:function(){ window.location.href = game.link; } }; event.move.selector = '.game'; event.move.type = 'touchmove'; event.game.selector = '.game'; event.game.type = 'touchend'; event.play.selector = '.play'; event.play.type = 'touchend'; event.replay.selector = '.replay'; event.replay.type = 'touchend'; event.share.selector = '.share'; event.share.type = 'touchend'; event.go.selector = '.go'; event.go.type = 'touchend'; return event; });
res.js
define(['esmere','game','event'], function (esmere,game,event) { //初始化title场景,添加加载进度条和提示 (function(){ //创建UI,创建加载进度条 var wrapper = $('<div class="wrapper">'); var loadBar = $('<div class="load-bar">'); var loadBarInner = $('<div class="load-bar-inner" data-loading="0"> <span id="counter">0</span> </div>'); var loading = $('<h1>loading...</h1>'); wrapper.append(loadBar); wrapper.append(loading); loadBar.append(loadBarInner); $(document.body).append(wrapper); })(); //加载资源 (function(){ esmere.resManager.loadRes("data/res.json",function(){ //删除进度条 $('.wrapper').remove(); //加载游戏图片资源以及配置文件 var data = {'image':esmere.resManager.res['image'],'cfg':esmere.resManager.getResByName('cfg','gf0')}; //安装事件 game.eventCommand.execute(event); //游戏初始化 game.init(data); },function(total,cur){ //渲染进度条 var pro = (cur/total)*100|0; $('#counter').html(pro+'%'); $('.load-bar-inner').css('width',pro + '%'); }); })(); });
game.js
define(['esmere','ajax'], function (esmere,ajax) { return { //初始化 init:function(data){ this.initElem(); this.initConfig(data); this.initRender(); this.initScene(); }, initElem:function(){ this.sMain = $('.main'); this.sStart = $('.start'); this.sGame = $('.game ul'); this.sLife = $('.life img'); this.sOver = $('.over'); this.sMask = $('.mask'); this.sOverTitle = $('.over .title img'); this.sTimeS1 = $('.time .s1 img'); this.sTimeS2 = $('.time .s2 img'); this.sSharet = $('.sharet'); }, initConfig:function(data){ this.cfg = data.cfg.data; this.image = data.image; }, initRender:function(){ this.render = this.render(); }, initScene:function(){ this.sMain.show(); }, //事件的命令模式 eventCommand:{ //添加事件 addEvent:function(elem,type,func){ elem.on(type,function(){ func.apply(this,arguments); }); }, //安装事件 execute:function(commands){ var n,func; for(n in commands){ func = commands[n]; this.addEvent($(func.selector),func.type,func); } } }, //重玩 rePlay:function(){ this.time = this.cfg.time; this.life = this.cfg.life; this.done = this.cfg.done; this.fail = this.cfg.fail; this.bingo = this.cfg.done.length; this.link = this.cfg.link; this.func = []; this.mainloop(); this.render.empty(); this.render.menuhide(); for(var i in this.done){ this.func[i] = void 0; //显示圆圈在图中的位置,游戏上线注释下面的代码 /*this.createapi.oo.call(this, this.done[i].w,this.done[i].h, this.done[i].x,this.done[i].y, this.done[i].name);*/ } }, //游戏主逻辑 bingoapi:function(pageX,pageY,context){ pageX = parseInt(pageX) || 0; pageX = parseInt(pageX) || 0; context = (context || this.bingoapi); var game = this, isBingo; //是否点击在区域内 isBingo = (function(pageX,pageY){ var done = game.done, d,w,h,x,y; for(var i in done){ d = done[i]; w = d.w; h = d.h; x = d.x; y = d.y; //点是否在Rect中 if(esmere.mathUtil.pInRect(pageX,pageY,x-w*0.5,y-h*0.5,w,h)) return {i:i,d:d}; } })(pageX,pageY); return { done:function(fn){ isBingo && (game.func[isBingo.i] || fn && (game.func[isBingo.i] = fn).call(context,isBingo.d)); return this; }, fail:function(fn){ !isBingo && fn && fn.call(context,{w:game.fail.w,h:game.fail.h,x:pageX,y:pageY}); return this; } }; }, //创建圆圈和叉叉 createapi:{ xoxo:function(w,h,x,y,name,src){ var dom = $('<li class="' + name + '"><img src="' + src + '"></li>'); dom.css({ 'width':w + '%', 'height':h + '%', 'left':(x-w*0.5) + '%', 'top':(y-h*0.5) + '%' }); $('.game ul').append(dom); return dom; }, oo:function(w,h,x,y,name){ return this.createapi.xoxo(w,h,x,y,'bingo',this.image[name].src); }, xx:function(w,h,x,y){ return this.createapi.xoxo(w,h,x,y,'fail',this.image[this.fail.name].src); } }, //渲染 render:function(){ var game = this, lcount = game.cfg.life; return { empty:function(){ game.sGame.empty(); }, gameover:function(){ game.sOver.show(); game.sMask.show(); }, menuhide:function(){ game.sStart.hide(); game.sOver.hide(); game.sMask.hide(); }, showlife:function(){ game.sLife.attr('src', game.image[Math.min(lcount,lcount-game.life)].src); }, showtitle:function(){ game.sOverTitle.attr('src', game.image['title' + (game.bingo ? '2' : '1')].src); }, showtime:function(){ var time = game.time.toString(); if(/^[\d]$/.test(time)) time = '0' + time; game.sTimeS1.attr('src',game.image[time.charAt(0)].src); game.sTimeS2.attr('src',game.image[time.charAt(1)].src); }, showShare:function(){ game.sSharet.show(); } }; }, //游戏主循环 mainloop:function(){ var game = this, timer = setInterval(function(){ if(--game.time < 1 || game.life < 1 || game.bingo < 1){ clearInterval(timer); game.render.gameover(); game.render.showtitle(); } if(game.bingo < 1) ajax.postInfo(); game.render.showlife(); game.render.showtime(); },1000); } }; });
好了,我一口气把代码全贴出来了,重点说说game.js的bingoapi方法吧。
bingoapi方法做了对点击的预处理
pageX = parseInt(pageX) || 0; pageX = parseInt(pageX) || 0; context = (context || this.bingoapi); var game = this, isBingo; //是否点击在区域内 isBingo = (function(pageX,pageY){ var done = game.done, d,w,h,x,y; for(var i in done){ d = done[i]; w = d.w; h = d.h; x = d.x; y = d.y; //点是否在Rect中 if(esmere.mathUtil.pInRect(pageX,pageY,x-w*0.5,y-h*0.5,w,h)) return {i:i,d:d}; } })(pageX,pageY);
然后返回一个对象,包含2个属性:done和fail,他们的参数是一个回调函数。并且返回对象自身,方便链式调用。
return { done:function(fn){ isBingo && (game.func[isBingo.i] || fn && (game.func[isBingo.i] = fn).call(context,isBingo.d)); return this; }, fail:function(fn){ !isBingo && fn && fn.call(context,{w:game.fail.w,h:game.fail.h,x:pageX,y:pageY}); return this; } };
这是bingoapi方法在event.js中的调用:
game.bingoapi(pageX,pageY,game) .done(function(rect){ this.createapi.oo.call(game,rect.w,rect.h,rect.x,rect.y,rect.name); --this.bingo; }) .fail(function(rect){ this.createapi.xx.call(game,rect.w,rect.h,rect.x,rect.y); --this.life; });
这样的话bingoapi只负责点击预处理,并返回一个点击正确和点击错误的方法,至于点击后的具体实现它并不关心。这里有一个细节需要注意,当点击正确后,会在页面中显示出一个圆圈,用来提示用户。当用户再次点击这个圆圈的位置,是不需要做任何处理的。这样的话我们可能需要在done的回调中做判断,防止多次创建圆圈。但是我们看上面的代码,done的回调并没有做判断,而是与fail回调一样。那么是怎么做到的呢?原因就在bingoapi的done方法中,我用了装饰者模式给他再包了一层做了处理。
现在看来代码的质量还是不错的,没有if语句的嵌套,结构清晰,遵循单一职责的设计原则。不过游戏数据与视图仍然耦合在一起,在一个方法内同时存在修改数据与更新视图。这就不如在canvas中那么舒服了,canvas的游戏处在轮询中,修改数据交给update函数去做,更新视图交给render函数去做,由于处在轮询中,更新了数据之后,渲染是自动完成的,所以在canvas的游戏架构中,数据与视图天生就是分离开来的,而到了以dom为架构的游戏中就变得很不好处理了。我认为要解决这个问题可以借鉴前端mvc框架的原理,把游戏数据全部存在在一个model中,更新数据交给model的update方法,update在更新数据的同时去触发自定义的事件回调,回调函数的具体实现便是操作dom。于是更新视图也成为了一个自动化的过程。