接上文,前面说到一个游戏的构建思路
- 确定资源加载方案、编写游戏的场景或背景
- 明确游戏场景,设置场景基本布局
- 构造基本组件类
- 调用逻辑编写
- 屏幕适配
- 性能优化
本文主要阐述游戏组件的设计及代码优化。展开来说可以说很多很多,但是又确实不知道该从哪里写起来,总结了几点实际实现过程中有所思考的设计,还有最近看书过程中的一些思考。对于编写非游戏的前端项目来说也有些借鉴意义。
方法拆分
随着逻辑的复杂,一个方法越来越长,功能越来越多,特别是很多需要实例化的时候就要执行的代码,刚开始很多都直接写在构造函数里,看起来比较难看。所以把它拆分出成:
- 事件处理函数
- UI初始化设置
- 子组件添加
这样拆分除了代码的整洁,也为后面数据容错和组件拆分提供了可能。
组件的解耦、拆分与合并
同样随着逻辑的复杂,一个类越来越大,越来越臃肿,之前我们的做法是一个页面/对象两个类,一个页面/对象中所有的控制逻辑都在一个类中。其实拆分有两种方式,第一是拆分UI,对应的UI逻辑放到对应的UI控制类中,第二是UI不变仅仅拆分逻辑。拆分UI就是把各个页面单独的UI组件都新建一个小的“页面”,然后在需要的时候加到主场景中,出于性能、屏幕适配还有其它方面的综合考虑我放弃了这种做法,反而将一些小的UI类合并到一起,原因如下:
- 每新建一个页面意味着多加一个对象,多加至少两个JS文件,子组件的嵌套更深,继承关系也更加复杂
- UI布局方面,由于加到主场景是通过实例化,然后addChild方法加入,每个实例都需要手动代码调整其在主场景中的位置,这样逻辑类中混杂着大量UI布局的代码,看起来很不清爽
- 不利于屏幕统一适配
- 过于零散的组件不方便管理,修改起来麻烦
而且其实很多UI组件虽然可能不是同时出现的(比如游戏场景的一些2D UI组件),但是在一场游戏中一定会出现,游戏场景本身内存消耗就大,如果再频繁地创建销毁这些组件虽然可能节省一些内存,但实际开销也是会增大的,不如一次性创建缓存起来,况且这些组件由于是2D的,再做一些节省内存的处理,本身占用并不是特别大。
针对以上考虑,拆分UI组件方式不是一个很可取的方式,反而我们要做的是合并UI,将一些零碎的UI组件合并到一起。这就使本来就复杂的类更加复杂了。试想一个最复杂的场景,里面有各种组件的控制逻辑混杂,它们错杂在一起,一不小心写个bug,想找到出错的地方都复杂。(可以用jscheckstyle 进行计算圈复杂度)。
项目中有一个类,是游戏场景的主类,由于整个游戏场景的的控制代码,除了3D实例对象(比如地板块、人物)的控制脚本,其它的不管3D还是2D的所有元素的控制脚本都在里面,由于整个前端开发团队(虽然只有三个人…)所写的逻辑基本上都和它耦合,都会有往里面加代码或修改代码,所以变得很复杂。当时正好在解决屏幕适配问题,想到的一个解决方案所以想把所有界面相关的UI组件都合并起来,但是混杂到这个类中实在难以下手,就是不知道代码要往哪加的感觉…所以进行了以下重构:
- 将每个战斗场景都一定会出现,或高频会出现的组件都移到一个页面的UI类中,不同组件都设置隐藏属性的布尔值,不需要立刻显示的设置false。
新建一个类Controller,用于编写这些组件的内置方法,以及要抛出的方法。每个组件抛出的方法基本包括:需要给外部调用的UI设置、状态量重置和组件销毁。UI变动的设置主要是指,比如游戏从第一局变到第二局,标志局数的这个UI就需要重新设置。当然不同组件的控制逻辑也可以分好几个类,比如3D场景的创建、3D实例对象的生成等等都可以单独出来,我的基本代码如下:
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
30var FightControlScript = (function(){
function FightControlScript(){
// 设置变量缓存
}
Laya.class(FightControlScript, 'FightControlScript');
var _proto = FightControlScript.prototype;
_proto.comp1Controller = function(){
// ...
return {
setContent:setContent,
// ...
resetFlag:resetFlag,
destroyComp1:destroyComp1
}
}
_proto.fun2Controller = function(){
// ...
return {
setContent:setContent,
// ...
resetFlag:resetFlag,
destroyComp2:destroyComp2
}
}
})()之前的主类继承创建的页面的UI类,仅负责编写收到各个消息时的处理函数。
消息处理
由于整个游戏基于RPC进行消息的发送接收,所以一些逻辑存在不确定性,因为你不知道下一个数据包什么时候发来,但游戏循环始终在继续。也就是说我们不能在收到消息时立马进行相应的响应操作,而是需要判断是否处于可以处理该消息的状态,同样,当我们处于可以处理某个消息的状态时也需要判断当前是否接收到这个消息。
基于以上原因,同时考虑到代码的整洁性,我们将消息相关的代码和具体的类分开。定义一个消息类,专门用于RPC包的接收和发送。定义一个store,用于存储各个消息接收到的数据。在收到消息时将数据保存到store中,当到达某个可以处理该消息的状态时,检查这个数据是否为空,不为空则进行下一步操作,否则继续进行轮询判断,直到收到消息。当然也不可能永无止境地轮询下去,万一网络中断收不到这个RPC包了总需要给用户一些反馈,所以需要有个判断机制,设定一个时间没收到这个数据对用户进行提示或进行重连操作。
另外一个场景是我们收到消息确实要进行一些相关操作,比如收到某个数据包的时候进入某个场景中。同样是上面的问题,数据包发过来了,但是场景UI还没有准备好,这个时候需要在接收数据包的时候判断UI是否已加载完毕,有则调用相应场景方法,如果没有就把数据存下来,而在UI加载完成的回调里再检查这些数据是否被存过,如果有则执行相应方法。但是这样其实消息类就不纯粹了,我们设计消息类的初衷是把消息和具体业务逻辑分开,但是这样收到某个消息的时候势必要执行某些业务代码,这一步该如何解耦我还没有特别好的思路,后续有会加以更新。
这些是跟网络相关的消息,还有就是各个组件之间的消息传递。比如游戏中有个场景是地板翻转时人物要跟随地板相对静止,当翻转到一定角度时如果人物还在地板上则掉落,也就是说地板在运动的时候人物其实也是在运动的。很显然地板运动是写在地板的控制脚本中的,人物运动是写在人物控制脚本中的,合理的方法是地板翻转时给人物的控制脚本发消息,通知任务更新。所以在地板和人物类共同的父组件上定义一个消息队列,地板开始翻转时给这个队列发消息,包含一些角度等参数,并在该队列中进行一系列处理,人物的脚本主循环中判断这个队列是否为空,不为空时则判定当前在翻转地板上,进行坐标变换(当然实际情况更加复杂,因为人物不一定是静止的,也许还在进行一些其它跟坐标变化有关的操作,关于这块当时也想了很久要如何设计逻辑,由于在谈消息处理,所以具体逻辑容后再谈);当地板停止翻转时将消息队列置空,同时传另一个参数给人物,通知人物掉落,地板掉落下来的时候将这个参数置为false,此时人物可以在平面上正常行走。
以上仅仅是一个小例子,其实不仅是游戏,任何组件化的设计都需要考虑这一点,这个很容易增加代码耦合度的一个地方。概括一下上面所说的,主要这几步:
- 确定消息的作用域
- 把消息发到这个作用域的最顶层
- 广播到作用域下的各个组件
其实不管是父子组件还是非父子组件之间的通信,最好都有一个统一的机制,当然我们可以直接通过方法调用进行组件间传参,但是这样一是数据管理比较混乱,二是出了问题不好定位。以上方法也有值得优化和欠考虑的地方,比如任何地方都可以对消息进行更改,更好的方式是做一些控制,发送消息的地方只能发送,接收消息的地方只能接收,所有对消息的修改都统一在消息转发中心(这里指消息的作用域)进行。