“疯狂香蕉塔”是在最近《神偷奶爸3》上映,京东联合环球影业和品牌资源打造小黄人IP特色活动的背景下,诞生的一个H5小游戏。人员配置:交互、视觉、前端各一枚,前端4天。点击查看


玩法

1、点击屏幕释放箱子,每成功堆一个箱子获得相应的分数增加:香蕉箱子10分,品牌箱子20分。
2、箱子投放位置不对或因失去平衡倒下则游戏结束。
3、分数超过50分则有机会获得优惠券。
4、发券方式:用户每天6张券都可以领一次,每次游戏用户领到的券是随机的。如果第1、2张券发完了,将发后面没发完的券;如果所有的券都发完,返回发完;如果用户6张券都领过,返回今日已领完;如果用户领过部分,其他发完,也返回发完(返回的信息可以灵活)。
5、区分默认分享和有战绩的分享,实现京东站内分享和微信内分享。

相信大家对这种盖楼的小游戏并不陌生,玩法上大同小异,实现的技术就五花八门了。下面我们对这个H5的技术实现和遇到的一些问题进行一次复盘。

游戏形式定下来以后,前端首先要想的事情是如何在短时间内定出游戏的主体框架和模拟出真实的物理场景。考虑到时间成本,借助现成的游戏框架、物理引擎是最保险的方式。脑海中闪过:

游戏框架

1、CreateJs: 虽然API齐全、多code examples,但缺乏demo、不带物理引擎,上手有一定难度;
2、ThreeJs: 主要做3D场景,2D有些大材小用,不带物理引擎;
3、Cocos2d: 非纯前端的方式,确实不太适应;
4、Phaser: API相对简单,官方例子众多并附代码,容易上手,内嵌Arcade、Ninja、P2三种物理引擎。

也是由于对Phaser有一定的认知,而且看中它提供了非常方便的状态管理和事件钩子,因此,游戏框架决定用Phaser。

物理引擎

1、Phaser内嵌的Arcade、Ninja、P2可设置的属性依次增多,看情况使用,因为我们的箱子碰撞需要更接近真实场景、更加细腻(如弹力、摩擦力、碰撞范围等),P2可配置性高,更适合;
2、基于phaser的Box2d,一开始我用的是box2d,看中的是Phaser对它封装后的写法比封装后的P2更简单一些,但结合phaser使用后发现性能不太理想,而且是额外加载的phaser插件,文件体积变大,放弃。

实际测试对比之后,决定用Phaser中内嵌的P2引擎。

以上提到的这些框架和引擎,我可以说是只知皮毛,如果要用,得靠变做边查。然而要想到这些方面的东西,平常也要多关注多留意,不求一下子掌握,先大致摸索了解一下思想也不错,关键时刻可以派上用场。

工程化

接着,工程化是前端开发的素养,它决定工作的效率和条理。

一、前端自动化构建

使用前端自动化构建工具ELF,工具足以满足游戏的需求,具体查看ELF到底自带了哪些依赖,请点击传送门。下面额外安装的依赖是拿来做图片替换成CDN资源用的。colors: 终端打印信息的美化;crypto: 用crypto模块的哈希算法校验图片资源是否有发生变化;lowdb: 用作图片资源信息本地数据库;request: 用作发送图片上传CDN请求。这个将在最后上线部署做一个说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
...
"scripts": {
"start": "elf start",
"build": "elf build && gulp copy",
"build:pack": "elf build && gulp pack && gulp copy"
},
"devDependencies": {
"colors": "^1.1.2",
"crypto": "0.0.3",
"gulp": "^3.9.1",
"gulp-util": "^3.0.8",
"lowdb": "^0.16.2",
"request": "^2.81.0"
},
"dependencies": {}
}

第三方js库全部走公司CDN,游戏js最后打包成一个bundle.js引进。具体请看下面的引用。

1
2
3
4
5
6
7
8
9
10
<!-- phaser库 -->
<script src=https://static.360buyimg.com/jdcopr/lib/phaser.min.js></script>
<!-- 京东分享组件 -->
<script src=https://storage.360buyimg.com/babel/00018849/39476/production/dev/jdShare.js>
</script>
<!-- 微信分享组件 -->
<script src=https://storage.360buyimg.com/babel/00018849/39476/production/dev/share.js>
</script>
<!-- 埋点统计 -->
<script src=https://wl.jd.com/unify.min.js></script>

ELF主要处理的事情是:
1、搭建本地服务器、热替换;
2、设置以750宽设计稿为准,进行设备适配,px自动转化rem;
3、sass编译;
4、图片压缩、js编译压缩合并。

二、Git托管,方便迭代和回滚。项目目录如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
collectbox
├── src //开发目录
├ ├── css
├ ├── img
├ ├── js
├ └── index.html
├── tools //工具
├ └── helper.js //主要是nodejs文件操作
├── .elf.config.js //构建配置
├── db.json //图片cdn数据库
├── gulpfile.js //gulp任务
├── .gitignore //gulp任务
├── package.json
└── README.md

编写代码

开始编码之时,需要从整体上考虑的事情是游戏的整个生命周期,围绕这个周期,我们可以很清晰的划分功能和编写代码。先预览一下文件划分,最后上线将打包合成一个bundle.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
src
├── css
├── img
└── js
├── boot.js //游戏启动
├── game.js //游戏入口文件
├── global.js //全局变量、数据源
├── load.js //资源预加载
├── main.js //游戏html、css、js拼接
├── play.js //物件添加和核心逻辑代码
├── shareConfig.js //分享设置
├── statistic.js //埋点统计
└── util.js //工具类

1、游戏设备适配

这里有人就感到奇怪了,上面不是说ELF做了设备适配的工作了吗,为啥还需要适配?是的,按照常规需求,ELF已经做了。而我们的游戏在html结构上存在两部分:其一是Phaser Canvas游戏画布,其二是独立于游戏画布之外的层(各种弹出层、遮罩层,个别按钮)。只是前者采用的是Phaser自身生成、定位物件特殊的方法,而非css,自然不存在单位的转化,该部分也就无法使用ELF的适配。因此,需要单独拿出来讲,不然有人可能会走一些弯路乱搞。一个html5游戏框架如果没有设备适配的功能,那就非常不成熟了,而这时候,我非常有信心地上Phaser官网翻文档去了。果然,一条语句搞定:

1
2
// 开启缩放
game.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;

2、Phaser的几个重要的事件钩子

用Phaser做游戏,最基本的就是知道它的几个最重要的事件钩子,掌握了它们就可以写出一个完整的Phaser游戏了。一个Phaser游戏整个面貌大致如下:

1
2
3
4
5
6
7
8
9
10
11
var game = new Phaser.Game(800, 600, Phaser.CANVAS, 'phaser-example', { 
preload: preload,
create: create,
render: render,
update: update
});

function preload(){}
function create(){}
function render(){}
function update(){}

3、开启debug模式

为了方便调试,你可能时不时开启Phaser的debug模式,我们可以看到物件的轮廓或者打印一些重要信息,只要在render钩子里面设置:

1
2
3
4
5
function render(){
// 举例子
game.debug.spriteBounds(Global.ground);
game.debug.cameraInfo(game.camera, 0, 402, '', '25px Arial');
}

4、充分利用Phaser本身的状态管理

为了代码清晰和方便,我们实际不会像2中那样简单地使用几个事件钩子去写完我们的游戏。Phaser提供了非常强大的状态管理,有什么状态也是由自己来决定的,我们可以充分利用它的这一便利。下面是game.js的全部代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
require('./global.js'); // 定义了一些全局变量,所以下面的game是全局的

var bootState = require('./boot.js');
var loadState = require('./load.js');
var playState = require('./play.js');
require('./shareConfig.js');
require('./statistic.js');

module.exports = function(data) {
game.state.add('boot', bootState);
game.state.add('load', loadState);
game.state.add('play', playState);
game.state.start('boot');
}

这里自然可以想到,先执行boot,boot完去执行load,load完去执行play,在play中包含了游戏结束重玩一次的状态(也可把这个状态分离出来),会由用户去触发重新走一次play,注意没必要再执行boot或者load,浪费。

5、利用Phaser的预加载,下面是load.js的全部代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var load = module.exports = {
create: function() {
//load event
//game.load.onLoadStart.add(this.loadStart, this);
game.load.onFileComplete.add(this.fileComplete, this);
game.load.onLoadComplete.addOnce(this.loadComplete, this);
this.loadResources();
},
// 加载资源
loadResources: function() {
game.load.image('bg', '../img/bg.jpg');
...
game.load.audio('bgmusic', 'https://static.360buyimg.com/jdcopr/activity/xhr/music.mp3');

// 执行加载
game.load.start();
},
// 单个资源加载完成
fileComplete: function(progress, cacheKey, success, totalLoaded, totalFiles) {
$("#progress").html(progress + '%')
},
// 全部资源加载完成
loadComplete: function() {
$('#loader').addClass('scaleOut');
setTimeout(function() {
$('#loader').remove();
}, 800);

// 进入play
game.state.start('play');
}
}

6、模拟真实的物理场景

这是整个游戏体验的核心,要模拟这个堆香蕉塔的真实场景,需要设置重力、碰撞面积、弹力、摩擦力等因素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//  应用p2物理引擎
game.physics.startSystem(Phaser.Physics.P2JS);
// 设置重力
game.physics.p2.gravity.y = 1800;

// 碰撞材质
worldMaterial = game.physics.p2.createMaterial('worldMaterial');
boxMaterial = game.physics.p2.createMaterial('boxMaterial');

// 设置发生碰撞的相关系数
game.physics.p2.createContactMaterial(boxMaterial, boxMaterial, {
stiffness: 1e100, // 弹力
restitution: 0, //损耗
friction: 10, //摩擦
surfaceVelocity: 0 //平面速度
});

// 开启箱子的物理特性,并设置碰撞范围和碰撞材质
game.physics.p2.enable(Global.box);
Global.box.body.setRectangle(Global.box.width, Global.box.height - 20, 0, 5);
Global.box.body.setMaterial(boxMaterial);

碰撞反馈,不管箱子发生怎样的碰撞,都会执行一次blockHit函数。

1
Global.box.body.onBeginContact.add(game.blockHit, this);

发生碰撞的时候,有两个要点。

其一,是如何判定箱子摆放成功与否。经过一番思考,最便捷的方法是:通过判断碰撞的时刻,摆放箱子的实际位置与其摆放成功下的位置的距离游戏世界顶部的高度差,如果该高度差大于箱子的半个身位,即可认为箱子摆放失败。而箱子的定位原点是设置在了中心点。

1
2
3
4
5
6
7
8
9
// 摆放箱子距离游戏世界顶部的距离
Global.postTopY = Global.boxesArray[Global.boxesArray.length - 2].y;
// 假设该箱子成功状态离顶部的高度 = 游戏世界高度 - 地面高度 - 下方箱子总高度 - 箱子半个身位
Global.tmpY = game.world.height - Global.groundToBottom - (Global.boxesArray.length - 2) * 156 - 78;

Global.posDiff = Global.postTopY - Global.tmpY;
if (Global.posDiff > 78) {
// 堆箱子失败
}

其二,是如何判断相机镜头需要往上移。镜头往上移必然发生在箱子摆放成功的情况下,当摆放的箱子距离相机镜头上方的距离小于某个阀值的情况,可以将镜头往上移动一个箱子的高度。我以屏幕高度为568的作为参考(这个可以很随意,能拿到一个大概的临界阀值就行,350为568高度下大概的临界阀值),不同屏幕高度,这个值肯定是需要做调整,因此,需要乘以一个调整系数ajustment。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

Global.ajustment = game.height / 568;

...

Global.distance = Global.postTopY - game.camera.y;
if (Global.distance < 350 * Global.ajustment) {
Global.cameraMoveVal = 156;

// 相机镜头缓动动画
Global.cameraTween = game.add.tween(game.camera).to({
y: game.camera.y - Global.cameraMoveVal
}, 1000, Phaser.Easing.Cubic.Out, true);
}

7、再玩一次。

这个地方非常重要,必须把所有需要重置的变量重置,漏了就出各种bug了,我试过在这里粗心忘记把couponIndex重置,导致再玩一次的时候查询优惠券信息可能出现报错,游戏卡住。如何在不刷新的情况,简便快速地初始化游戏,非常重要。Phaser的状态管理又派上了用场,这里面重置的变量也可以改为在play的create中一开始进行一次重置,效果是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var play = module.exports = {
create: function(){
...
},
...
restart: function() {
// 隐藏弹窗
$('.modal-success,.modal-fail').removeClass('show');
$('.overlayer').hide();

// 重置变量
Global.isShowFail = true; // 允许游戏状态弹窗
Global.grade = 0; // 得分归0
Global.boxIndex = 0; // 当前最新生成的箱子索引
Global.boxesArray = []; // 生成的箱子数组
Global.canAward = true; // 允许领奖
Global.couponIndex = -1; // 优惠券索引
bgmusic.mute = true; // 背景音乐静音
bgmusic.destroy(); // 背景音乐销毁

// 重新走一遍play
game.state.start('play');
},

onTouchEvent: function() {
...
// 再玩一次
var _this = this;
$('.btn-again').click(_this.restart);
}
...
}

8、设置背景音乐和碰撞音效。

这里的关键点是如何设置背景音乐循环:监听音乐播放结束,再重新播放,也是依赖于Phaser便捷的音频API。在某些机子或者APP中,音频是不会自动播放的,需要统一手动执行audio.play();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
var play = module.exports = {
...

onAudioEvent: function() {
// 添加箱子放正的声音,播放play()在碰撞反馈中执行
hitAudio = game.add.audio('hitAudio');
game.sound.setDecodedCallback([hitAudio], this.hitAudioPlay, this);

// 添加背景音乐
bgmusic = game.add.audio('bgmusic');
bgmusic.onDecoded.add(this.bgMusicStart, this);
bgmusic.play();
// 监听背景音乐结束
bgmusic.onStop.add(this.bgMusicComplete, this);

// 开启和关闭背景音乐
$('.btn-music').off('touchstart').on('touchstart', function() {
if (!$(this).hasClass('off')) {
$(this).addClass('off');
bgmusic.pause();
} else {
$(this).removeClass('off');
bgmusic.resume();
}
})
},

hitAudioPlay: function() {
hitAudio.onStop.add(this.soundStopped, this);
},

// 背景音乐渐入
bgMusicStart: function(){
bgmusic.fadeIn(4000);
// 音乐按钮禁止状态下背景音乐应该暂停
if($('.btn-music').hasClass('off')){
bgmusic.pause();
}
},

// 实现背景音乐循环
bgMusicComplete: function(){
bgmusic.restart();
},

soundStopped: function() {},
...
}

上线部署

1、Crypto的哈希算法

nodejs读取本地图片生成Buffer对象后,使用crypto的哈希算法生成校验,与本地db.json中存储的图片信息进行对比,如果发生变化,则进行上传CDN操作,更新db.json

1
2
3
4
5
6
7
8
// 创建hash对象
crypto.createHash(algorithm)

// 创建摘要,data为Buffer对象
hash.update(data)

// 输出摘要内容
hash.digest([encoding])

db.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"img": [
{
"md5": "8cd1da24ec26c238cafa056a2112288c",
"name": "bi_gear.png",
"url": "https://img10.360buyimg.com/opr/jfs/t5752/102/8291568425/2649/bb2d71cb/59799dc7Nb7311ed6.png"
},
{
"md5": "3985a826519a06a11354c9e288aaf21d",
"name": "btn_music.png",
"url": "https://img12.360buyimg.com/opr/jfs/t6859/293/784441135/3425/3341879b/59799dc6Nd9472b33.png"
},
...
]
}

2、lowdb: A small local database powered by lodash API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var low = require('lowdb');
var db = low('./db.json');

// Set some defaults if your JSON file is empty 
db.defaults({ 
img: []
}).write()

// 增
db.get('img').push({
md5: md5,
name: filename,
url: result[filename]
}).value();

// 删
db.get('img').remove({
name: filename
}).value();

// 查
db.get('img').find({
md5: md5,
name: name
}).value()

// 改
db.get('img').find({
name: name
}).set('url', 'https://......').value()

3、File System(文件系统)

cdn替换涉及到深度遍历文件、字符窜匹配、强制写入文件,属于nodejs的fs模块和stream模块的API使用,代码有点长,github上类似这样的代码也有很多。

4、输出生产目录

1
2
3
4
5
6
7
8
9
10
11
// 输出生产目录dist
npm run build

// 输出生产目录dist并生成zip压缩包
npm run build:pack

// 上传CDN
gulp uploadimg

// 图片资源CDN替换,生成多一个CDN化的_dist
gulp cdnimg

整个游戏比较关键的点也就上面提到的一些,另外像领奖和分享,只要按照玩法的中规则,耐心地写一下,也不难。纵览整个游戏,其实就是Phaser API的运用,加上ELF做开发环境。如果要说里面最大的挑战是什么,不是技术实现,而是心态。如何在只对Phaser只有基本的认知水平下,不浮躁心急,保持思路的清晰,耐心一个个问题查找解决。