开发一个h5小游戏

这次,我们的目标是开发一个网页版猜拳小游戏。首先看看最终的效果图:

瞄一眼效果图,游戏玩法大致可以了解个七七八八了。很简单,用户从石头剪刀布中随便选一个并出拳后,电脑也会随机(实际上是根据服务端的返回值)做出选择,之后判断用户和点小融之间的胜负,弹出相应的动画即可。

游戏的玩法虽然简单,但实际的开发过程中,坑还是不少。下面我们从头梳理一下开发的过程。

游戏的本质就是一个状态机

无论多么复杂的游戏,本质上都是一个根据用户的输入切换到不同的状态,并播放相应动画的状态机,其中处理的逻辑可能有难易之分,但终究逃脱不了状态机的本质。拿最近最火的王者荣耀来举例,从游戏的最上层俯瞰,我们可以将游戏分为以下几个状态:

1
2

INIT、LOADING、LOBBY、PLAYING、END

当我们打开游戏时,游戏处于INIT状态,播放完开场动画后,就进入了LOADING状态,LOADING结束之后游戏自动切换到LOBBY状态,在这里,用户可以选择不同的游戏模式开始游戏。一旦游戏开始,游戏就切换到最重要的PLAYING状态了,一局游戏结束,游戏END。

实际上,上到整个游戏,小到游戏中的一个中立生物,他们的行为都可以用状态机来控制。就拿PLAYING状态时,我们操纵的英雄来说,他的行为也可以用状态机来理解:

1
2

IDOL, WALKING, TURNING, USE SKILL, ATTACK

当我们不执行任何操作时,英雄处于IDOL状态,滑动方向轮盘,英雄进入行走状态,点击不同的指令(攻击、技能),英雄进入相应的状态(Attack、USE SKILL),并播放对应的动画。

既然王者荣耀这样复杂的游戏都可以通过状态机进行拆分,用它来处理我们的小游戏自然是不在话下。

这里我定义了四种状态

1
2

INIT, PLAY, SUCCESS, FAILED

在非PALY状态下,用户可以进行操作(选择、出拳),一旦进入PLAY状态,我们就开始播放游戏动画并等待游戏结果,此时,用户将无法执行其他任何操作。当我们获得游戏结果且动画播放完毕之后,我们就会进入结束状态(SUCCESS、FAILED)并播放相应的结束动画,此时用户又可以进行操作了。

自然的手臂摇动动画

游戏进入PLAYING状态时,会播放摇手动画。对于这个动画,除了最基本的自然、流畅,我们还希望无论什么时候获得游戏的结果(这个结果是通过AJAX在服务端获取),我们的手臂一定会摆回正中,再切换到对应的石头/剪刀/布的图片。

先谈谈自然流畅。我们希望的是,手臂摇动时是以底部为轴进行旋转,当手臂摆动时,拳头摆动的幅度应该比手臂摆动的幅度更大(否则像跳机械舞)。因此手臂和拳头应该是分开的两张图片,且我们要对他们分别执行不同的动画:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130

@keyframes fist-animation {

0% {

-webkit-transform: translate3d(0, 0, 0) rotate3d(0, 0, 1, 0deg) ;

transform: translate3d(0, 0, 0) rotate3d(0, 0, 1, 0deg);

}



25% {

-webkit-transform: translate3d(33%, 10%, 0) rotate3d(0, 0, 1, 35deg) ;

transform: translate3d(33%, 10%, 0) rotate3d(0, 0, 1, 35deg) ;

}



50% {

-webkit-transform: translate3d(0, 0, 0) rotate3d(0, 0, 1, 0deg) ;

transform: translate3d(0, 0, 0) rotate3d(0, 0, 1, 0deg) ;

}



75% {

-webkit-transform: translate3d(-33%, 10%, 0) rotate3d(0, 0, 1, -35deg) ;

transform: translate3d(-33%, 10%, 0) rotate3d(0, 0, 1, -35deg) ;

}



100% {

-webkit-transform: translate3d(0, 0, 0) rotate3d(0, 0, 1, 0deg) ;

transform: translate3d(0, 0, 0) rotate3d(0, 0, 1, 0deg);

}

}



@keyframes arm-animation {

0% {

-webkit-transform: translate3d(0, 0, 0) rotate(0deg) ;

transform: translate3d(0, 0, 0) rotate(0deg);

}



25% {

-webkit-transform: translate3d(0, 0, 0) rotate(10deg) ;

transform: translate3d(0, 0, 0) rotate3d(10deg) ;

transform-origin: center bottom;

}



50% {

-webkit-transform: translate3d(0, 0, 0) rotate(0deg) ;

transform: translate3d(0, 0, 0) rotate( 0deg) ;

}



75% {

-webkit-transform: translate3d(0, 0, 0) rotate(-10deg) ;

transform: translate3d(0, 0, 0) rotate(-10deg) ;

transform-origin: center bottom;

}



100% {

height: px2rem(171);

-webkit-transform: translate3d(0, 0, 0) rotate(0deg) ;

transform: translate3d(0, 0, 0) rotate(0deg);

}

}

// 对arm和fist分别应用上面的动画

.arm {

// ...

animtion: arm-animation 0.5s infinite ease-in-out;

}

.fist {

// ...

animtion: fist-animation 0.5s infinite ease-in-out;

}

手臂摇动的重点是设置它的transform-origin属性,让它相对底部中心进行摆动,拳头摆动的重点则是在摆动同时,给它增加一个小小的位移。

接着看第二点,保证PLAY状态结束时,手臂始终会摆到屏幕正再出拳。

对这个问题,我们可以换个角度思考:只要保证每次接口返回的时间一定是动画播放时间的整数倍,那么动画一定会回到正中。

于是,我们可以用一个Promise来包装我们的异步函数,让它始终会在0.5的整数倍时间之后再执行回调:

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

const returnAfter = function (ms) {

return (func) => async function () {

let result;



const beat = new Promise((resolve) => {

let timer = setInterval(() => {

if(!!result) {

resolve(result);



clearInterval(timer);

}

}, ms);

});



result = await func.apply(null, arguments);



return beat;

};

}

页面滑动、回弹效果的实现

最后一个“小小的”需求是,当用户drag页面的幅度较小时,页面会回弹。如果用户drag的幅度较大(进入了下一页的范围),那么就进入下一页。当用户快速滑动超过某个阈值时,也会触发翻页的效果。

这个效果实现的原理说起来并不复杂:

首先,使用preventDefault来避免触发页面的滚动事件,接着分别监听容器的touchstart,touchmove和touchend事件。如果touchend和touchstart事件发生的间隔较小(eg: <200ms),且移动距离足够长(eg: > 300px),则认定为swipe事件,并触发翻页效果。如果end和start之间间隔较大,则让页面随着手指的滑动进行滚动,当touchend事件回调时,判断容器的scrollTop属性,来决定是停留在当前页面还是进入下一页。

说起来简单,但做起来的坑还不少:

  1. 如何保证mouse事件和touch事件的同步?

  2. preventdefault后,页面上原有按钮的点击事件无法触发,应该如何处理?(如果不对按钮的事件进行preventdefault,那么当用户在按钮上拖动时,页面原本的scroll事件就会触发)

  3. 如何实现一个自然的页面回弹效果?

下面,我们来一一解答这几个问题:

1. 保证mouse事件和touch事件的同步

事实上现在绝大部分h5都不需要兼容pc端,因此保持mouse和touch事件的同步可以看做是对页面的优化,实现起来也很简单:只需要保证mousedown和touchstart,mousemove和touchmove,mouseup和touchend事件的回调函数一致即可。

在项目中,为了简化事件处理的流程,用到了rxjs,看看rxjs对这个问题的解决方案也是蛮有意思的一点:

1
2
3
4
5
6

let mouseDowns = Rx.Observable.fromEvent(domItem, "mousedown").map(mouseEventToCoordinate).do(() => console.log('mouse down'));

let touchStart = Rx.Observable.fromEvent(domItem, 'touchstart').map(mouseEventToCoordinate).do(() => console.log('mouse down'));

let start = mouseDowns.merge(touchStart);

受限于篇幅,这里就不展开聊rxjs对翻页、滚动、点击事件的处理了。

2. 当页面的所有事件都被container“吞掉”后,如何触发按钮的click事件?

通过elementFromPoint我们可以找到点击事件对应的低层节点,再手动触发这个dom节点的click事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

observer.clicks.forEach(({ x, y }) => {

var elem = document.elementFromPoint(x, y);

const click = new Event('click');

if(elem.click) {

elem.click();

} else if(elem.onclick) {

elem.onclick();

}

});
3. 实现自然的回弹效果

回弹效果就是一个简单的动画,通过requestAnimation函数,修改页面的scrollTop属性就可以实现回弹。

需要强调的是,在css中,updater也叫做timing function,可以将他理解为一个随时间变化的函数。我们对对象属性的修改都可以在里面实现。

简单贴一下代码:

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


const playAnim = (startAt, duration) => (updater) => (delta) => {

if(delta - startAt > (duration + 1)) {

updater && updater(delta - startAt, duration);



rAF && cancelAnimationFrame(rAF);

return;

}



updater && updater(delta - startAt, duration);



rAF = requestAnimationFrame(playAnim(startAt, duration)(updater));

}

为两个相似的活动”换肤”

除了猜拳,这次还同步开发了另一个翻牌子的小游戏。两个小游戏除了核心的动画外,按钮、边框、弹框样式等都只有颜色/贴图上的不同。一句话,要给猜拳换个皮肤。

之前的做法是把活动的样式复制一份,再修改不同的地方,先明显,这种过时做法肯定不符合我们工程化的理念。如今有了sass,我们可以通过它,来复用我们的样式了。

首先我们定义我们的游戏的主题:

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

$theme-a: (

//...

color-bg: #fff,

color-border: #fff,

//...

)

$theme-b: (

//....

)

$themes: (

a: $theme-a,

b: $theme-b

)

有了这几个对象,我们就可以用scss内置的map-get函数取得不同主题对应的css属性的值了,举个例子:

1
2
3
4
5
6

a {

color: map-get(map-get($themes, a), color-bg);

}

当我们有新的主题时,我们只需要定义新的$theme-style对象即可。

坑爹的audio

众所周知,HTML5标准提供了对音频的支持, 因此在实现游戏音效的时候, 我天真的以为一个audio标签就可以搞定一切, 像这样:

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

// audio for bgm

<audio

ref="audio"

autoPlay={ true }

controls={ false }

loop={ true }

src={ src }

/>

测试时,它在pc浏览器上的表现堪称完美: 打开网页,背景音乐响起, 播放完成之后, 自动回到开始,循环往复, 一切都如此顺利. 殊不知, 一个大坑正在前方等着我们.

当我们关掉pc, 自信满满的在iphone的微信浏览器上访问我们的网页时, 突然感觉不对了:

熟悉的音乐没有播放, 点击猜拳动画, 除了点击的一霎那, 会有挥舞拳头的音效, 动画结束之后的胜利/失败音效也没有如期响起.开始我以为是资源加载的问题, 于是采用import的方式, 将我们的音频资源通过webpack重新构建了一次, 然而一切依旧. 同事告诉我说他的android浏览器中音效是正常的, 于是我开始怀疑是ios safari浏览器的问题…

一通百度谷歌之后, 终于锁定了问题的所在.caniuse可以查看所有标签在不同浏览器的兼容性,对audio兼容性, known issues中,是这样描述的:

1
2
3
4

Chrome on Android does not support changing playbackrate as advised by the specification.
Volume is read-only on iOS.
Chrome on Android does not support autoplay as advised by the specification.

如果只是如描述这般, 我们的IOS浏览器怎么会出问题呢?

事实上, 除了chrome on android, IOS的safari也不支持autoplay, 同时他对loop的支持也存在一定的问题, 另外最新的android浏览器已经提供了对autoplay的支持. 所以如今大部分问题都集中在IOS和老的Android系统中.

光提出问题还不行, 针对这几个问题我们有什么好的解决方案吗?

  • 不能改变音量大小

safari mobile限制了这种操作, 因此寄希望于代码, 我们只能老老实实的修改音频源文件了. 不过好消息是手机上音量的大部分人还是习惯通过手机自带的音量键来进行调节, 因此这不算一个大的问题

  • 不能自动播放

如果仅仅只是不支持autoplay, 那么解决起来也许并不复杂: 大不了当音频加载完毕之后, 我们调用audio.play()方法手动播放它就好了. 但试过之后你会发现, 这么做一点卵用都没有, 事实上, 这背后不仅仅是禁止autoplay这么简单.

首先, 我们看看苹果官方文档:

1
2

In Safari on iOS (for all devices, including iPad), where the user may be on a cellular network and be charged per data unit, preload and autoplay are disabled. No data is loaded until the user initiates it. This means the JavaScript play() and load() methods are also inactive until the user initiates playback, unless the play() or load() method is triggered by user action.

翻译过来就是说:

1
2

为了防止网页偷跑流量,一切需要下载的资源都需要经过用户的同意才能进行. 也就是说, 音频的播放必须要在用户操作触发event之后, 才能触发.我们不能通过代码去控制它.

苹果和谷歌在这个问题上达成了一致,因此Android浏览器中, 也不能自动播放音频. 但是, 微信似乎不是这么想的.

如果我们的活动的主要是在微信浏览器中浏览, 我们可以通过微信提供的js-sdk实现自动播放.

但这个方法在safari中就不管用了.

如果需要在safari中播放, 有两个选择:

  1. 像很多H5页面一样, 提供一个播放/暂停的按钮, 进入页面的时候不播放音频, 让用户自己决定是否播放背景音乐.

  2. 监听window的touchstart事件, 当用户触发touchstart事件时, 开始播放音频, 一旦音频开始播放, 就删掉对该事件的监听. 具体实现可以参考这里

  • 不能循环播放

在低版本的ios和android手机上, loop属性并不能如愿起作用, 因此, 如果我们希望实现一个循环播放的背景音乐, 一个更靠谱的做法是: 监听audio的onEnded事件, 当onEnded触发时, 我们再重新调用audio.play()播放音频. 代码如下:

1
2

<audio src="noise.mp3" onended="this.play();" controls="controls" autobuffer></audio>