为了总结方便,本文基于layaAir引擎进行叙述,但其概念适用于大多数H5游戏。
一月份离职期间看了本书《游戏编程模式》(作者:Robert Nystrom),觉得还不错哦,如果之前没有接触过游戏编程可以一看,读书笔记见《游戏编程模式笔记》
由于文章有点长,所以分了几个部分,本文主要阐述基本构建思路、游戏布局及其过程中所做的相关优化。
构建项目
翻邮件的时候找到某一天的日报,应该是实习第二天,第一天是熟悉了一下部门的组件库和编码规范以及了解了下laya。
这里提到了项目的构建编写思路,其实当时概括得挺好的,不过再加一点,性能优化。再整理一下,如下:
- 确定资源加载方案、编写游戏的场景或背景
- 明确游戏场景,设置场景基本布局
- 构造基本组件类
- 调用逻辑编写
- 屏幕适配
- 性能优化
再简单一点,单纯从游戏逻辑来看(比如在一个现有的游戏上添加组件)
- 打包、加载所需资源
- 场景布局(如果需要)
- 编写控制脚本
- 组件及方法调用
接下来再回到目录,一步步来看:
确定资源加载方案
由于游戏资源一般都比较大,而如果不等资源加载完进入相关页面很容易发生意料之外的效果,但是如果等所有资源加载完毕才给用户展现,第一是等待时间很长,第二是内存容易爆炸,所以要及时卸载资源,也要确定资源的加载方案。当然,这也属于性能优化的一方面,一开始其实不用考虑的那么细致,不过要有个大致的数,哪方面应该留出余地以方便后续优化。
当然,整个项目最简单的加载策略就是,先加载所有资源,再显示页面。但是这个时间通常很长,所以我们通常会显示一个漂亮的loading页面。
那么问题来了,虽然通常loading页被设计的简单力求加载资源最小,但不管多小都是有资源的呀,所以我们改成:
- 加载loading页面所需资源
- 显示loading页面,同时加载整个游戏的资源,或者至少确保要进入的页面的资源加载完毕
这里有两个点值得思考:
第一,加载loading页面所需资源,尽管你和设计大大说了,loading页要简单简单简单简单,不然第一次进游戏会白屏很久,BUT…它依然被设计得很高端大气上档次…emmmm……这就要求绞尽脑汁用最少的资源实现很漂酿的效果了,关于我是如何压榨资源以及实现一个很漂亮(假装很)酷炫的loading条效果的,之后组件模块会提到,这里不赘述。
第二,分步加载策略。开始有提到,如果资源很多等所有资源加载完毕再进入游戏那会等待很长时间,并且那么多资源同时加载到内存中会爆炸(真的会炸的!!!)。还有一个硬伤是,laya不允许2D资源和3D资源同时加载,所以更好的加载策略是,先加载一批资源,等这批资源不再使用的时候,卸载它,再加载另一批资源。
很容易想到的一个策略是,进入一个页面前加载一个页面的资源,同时卸载上一个页面的资源。但是这样有两个隐患:
第一,“加载一个页面的资源,同时卸载上一个页面的资源”这个相当于进入每一个页面前有需要时间,所以你看见过哪个游戏在跳转每个页面时都有loading?如果可以从页面1->页面2->页面3也还好,问题是用户的操作是不确定的,可能他进了页面2,又想返回页面1,然后又想重新进入页面2,而装载和卸载资源的消耗都是很大的,频繁地装载卸载资源反而会带来更大的隐患。
第二,鉴于我们开发的是一款MOBA(Multiplayer Online Battle Arena)手游,而手机的性能和网络因素导致每个人进入一个游戏页面的时间是不确定的。如果你在一个网速差的地方,很有可能还没进入游戏场景就被敌人杀死了。这也是为什么《荒野求生》开始前有那么长时间的等待时间了,它要确保所有人都加载并进入了场景。可是我们游戏的服务端并没有提供这个RPC告诉我们谁谁谁已经加载完了进入了场景,所以只能想办法避免,或者减少这个问题带来的影响。
我们以《QQ飞车》手游为例(不好意思我这是我最近也是玩得最久的一个MOBA游戏……emmm……我还是爱网易的),由于这个游戏我玩着玩着也失去兴趣卸载了,所以凭记忆回忆流程,如果不对…就不对吧,不影响叙述~
开始一场对战,用户需要经过登录-进入对战房间选择页-选择对战方式-匹配-进入对战场景等步骤,进入对战房间可以退出回到大厅页,选择对战方式也可以取消回到选择页面,而且在这些页面中还有其它很多条选择路径,你可以进入任何想进入的页面。但是,一旦进入对战场景,就没有那么多退出选项了,要么进行游戏,要么退出游戏。而整个对战场景也是消耗资源最多的一个页面,不断有sprite被渲染被销毁。所以我们可以这样设计,第一次加载将所有进入对战场景之前的资源加载完毕,切换这些页面的时候,仅销毁页面(甚至其实页面也可以不用销毁,存进对象池或仅仅在stage上remove),不清除资源。当进入对战场景的时候,loading页面中加载对战场景所需的资源,同时销毁其它场景,清除其它所有资源。退出战斗场景的时候销毁战斗场景资源,加载其它场景的资源。这个时间其实是可以接受的,毕竟开始一场需要高注意力的游戏之前,用户一般是可以接受一定的等待时间的。这样设计就解决了刚刚说的第一个隐患。
至于第二个隐患,其实也很简单,推测用户行为。在进入对战场景之前,是不是先进入对战房间选择页?换个角度,当用户进入对战房间选择页的时候,是不是可以代表他有很大的几率想开启一场对战游戏?当然用户可以进入再退出,但是这个几率毕竟相对而言会比较小。所以进入对战房间页的时候,我们就开始加载对战场景的资源,由于整个页面没有很高频率的渲染重绘,再加一些内存的合理控制,其实内存爆表的可能性会很小。点击开始按钮进入战斗场景后,网速比较好性能比较好的手机甚至已经加载完了,也有些手机还没有加载完,由于没有办法判断其它客户端是否加载完进入游戏,我们在这里设置一个缓冲时间,同样加一个loading页,但这个loading是假的loading,前90%以一定时间匀速显示进度(其实为了仿真设置一个贝塞尔曲线也是可以的…),后10%判断自身场景是否加载完毕,加载完毕后进度变成100%,直接进入场景,否则以更慢的速度推进进度,直到加载完成。这是为了平衡硬件及网络条件差异的一个方法。还有一些平衡策略可以根据游戏本身的设置来设置比如游戏开始前有倒计时,为了同步开始游戏,这段时间也是一个可以调整的点,还有比如说控制人物在进入场景前死亡保护等等手段。当然当然,最好的方式还是服务端可以返回一个网络包,告诉客户端其它玩家是否已经进入游戏场景了,那就不用想得那么复杂了。
总结一下上面的优化策略:
- 分块加载卸载
- 推测用户行为提前加载
- 逐步缩小加载速度差异
由于上述所做的优化都是基于对用户行为的一个推测,所以我们还需要给每个推测做推测失败的处理。比如用户提前结束游戏、或者进入房间选择页已经加载了一部分资源了又退出这部分资源的处理,等等等等等等。很多微小的地方都可能造成一个你好几天都解决不掉还不是必现的S级bug,所以在决定使用这些策略的时候就要思考后遗症了,不然出现莫名其妙的bug的时候想哭都没地方哭。
编写游戏的场景或背景
场景,这个概念应该不用过多解释,就是拍电影的一个个分镜头嘛,有背景人物还有其它装饰。
这里要说的是如何组织一个游戏,刚刚已经解释了加载,所以这里提到会一笔带过,当我们构建一个JS文件作为入口文件,里面的逻辑要怎么写呢?
首先不用考虑那么多,我们先把整个舞台搭出来,官方的示例demo就已经实现了,laya.init()足矣。3D场景的init也无非Laya3D.init()。接下来来考虑舞台的一些属性,宽高呀、背景色呀、适配模式呀、FPS呀等等,还有舞台初始化之后的操作。舞台初始化之后,我们当然要预加载loading页面,写入需要的资源,实例化loading页面,loading页面的逻辑中加载第一批要加载的资源和加载完成后执行的逻辑函数,一步步来就好了。
整理一下,我们可能需要这些类:
- Init
- LoadingView
- LoadingController
- LoginView
- LoginController
Init定义舞台的一些初始参数,接下来每个场景,我们都预计给它们两个类,一个类用于视图的生成,一个类用于逻辑的控制。当然有些视图过于简单,或者逻辑过于简单的可以合并成一个类。
可以让LoadingController继承LoadingView,以操作View中的元素,也可以在全局提供一个所有页面的挂载点,以便这些页面的访问、操作等。然后在需要的地方进行实例化包含视图的类,就可以进行场景的加载了。当然3D场景的话要复杂一点,需要创建摄像机等等。但是只要把这些都看成视图的一部分,其实理解起来也没有很复杂。
设置场景基本布局
laya提供IDE使我们可以直接将元素拖动到画布上,当我们在IDE中新建一个页面后,laya目录下会生成一个pages文件夹,里面包含.ui文件,包含刚刚生成的页面信息,如下:1
2
3
4
5
6{
"type":"View",
"props":{"width":211,"sceneColor":"#000000","height":44},
"child":[
]
}
所有在IDE中设置的节点属性都会加到相应的props中,当往页面上添加元素节点的时候,child中的项会越来越多,这个json文件也会越来越复杂。当我们执行导出命令时,src目录下会生成一个ui目录,里面有一个layaUI.max.all.js文件,简单看一下这个文件:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21var CLASS$=Laya.class;
var STATICATTR$=Laya.static;
var View=laya.ui.View;
var Dialog=laya.ui.Dialog;
var TestUI=(function(_super){
function TestUI(){
TestUI.__super.call(this);
}
CLASS$(TestUI,'ui.TestUI',_super);
var __proto__=TestUI.prototype;
__proto__.createChildren=function(){
laya.ui.Component.prototype.createChildren.call(this);
this.createView(TestUI.uiView);
}
TestUI.uiView={"type":"View","props":{"width":211,"height":44}};
return TestUI;
})(View);
由于我创建的页面名称叫Test,所以它生成了一个TestUI类,当我们往页面添加一个元素的时候,引擎会遍历json文件中的child属性,并依次会调用View.regComponent()方法进行组件的注册,而createChildren方法用于构建各个组件,比如按钮、图片、遮罩等。createView方法用于根据视图数据(下面的Test.uiView)生成整个视图。
当然,这是IDE创建布局的方式,我们也可以在JS代码中通过实例化相应的组件类,然后可以直接设置组件的属性,并加到场景中(addChild)。对于IDE中创建的组件节点,可以设置它的变量名进行在代码中的操作调用,这样不管是创建还是操作都可以灵活进行了。
我所使用过的游戏引擎中很多都是这样一个概念,不管是页面还是页面中的一个组件都是一个类,使用的时候通过实例化生成,并通过addChild加到场景中,不需要的时候可以通过remove方法进行移除,也可以彻底destroy,这一点和ActionScript有点像,或许这种API确实是从ActionScript沿袭来的。
页面布局方面可优化的,第一是cacheAs静态缓存。第二是图片的处理,比如说一个背景圆角矩形可变长度的文本框,首先拿路径绘制一个圆角矩形就不是一件轻松的事情,其次这个圆角矩形还有比较复杂的样式(阴影边框条纹等等),切图又太大,大量相似图片重复,所以采取的方式是把首尾切下来,中间重复部分设置一个切片,把这三个切片(或者还有更多其它装饰的切片)合并成组,设置首尾相对组的布局(类似于给组设置relative定位然后给切片设置absolute),设置中间的切片平铺重复,再获取文字的宽度设置中间切片的宽度就可以了;径向渐变图片的处理,切一块包含所有渐变色的细条拉伸就好;有些图片过长或者过宽无法合并到spritesheet里(因为有限制图片大最大长宽),但本身其实不大,就是一个长条,可以切片工具切成一个个小切片,使用的时候再拼起来;
还有一些组件的制作思路也比较巧妙,后续有时间可以详细写。
总之整个做游戏的过程都无比怀念CSS的语法,不管用laya还是Phaser。