这是公司的一个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,);
--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,);
--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。于是更新视图也成为了一个自动化的过程。
















