三年的总结(技术篇)
三年来的写的代码真的很多,有必要试着理清一下思路,找到不依赖公司的框架,能够带走的东西。
三年来主要的工作是完成手机游戏的功能需求。公司的游戏客户端是使用lua语言。服务器是使用c++。以下举例的游戏主要基于ARPG的,SLG的部分另外有一篇 http://www.cnblogs.com/yao2yaoblog/p/6723621.html。
客户端
1.mvc
有借鉴意义的一部分是客户端view的管理。分三层:view,ctrl,data。是一个mvc的思想。view是加载面板layout,展示游戏画面的。data是存储服务端下发的数据。ctrl是负责调度的。ctrl通常可以写成单例,作为全局可方便调用的。
我拿下图的一个业务来举例。福利大厅里面领取奖励。
1) 服务器下发数据之后,首先是用data来存放这些数据(可领取,不可领取)。通常会调用ctrl里面的函数来把数据传到data,类似这样
LevelRewardCtrl:Instance():SavaRewardData()
2)页面上要展示的内容,首先是由layout布局决定的,view加载这个layout。再取data里的数据对页面上的内容进行update。
local data = LevelRewardCtrl:Instance():GetRewardData()
mvc这种结构,很有借鉴意义,会使得逻辑清晰很多。
2.NGUI的DrawCall优化。
NGUI是Unity一个开源的制作UI的插件。DrawCall是NGUI里面的一个概念。
Unity准备好数据通知GPU绘制的过程叫DrawCall。至于准备哪些数据,没有深入过源码就不深究了。一次DrawCall会消耗大量GPU资源,所以通常制作UI时需要减少DrawCall的数量。
所有的UI组件都有个UIWidget的脚本。每个UIWidget的显示渲染顺序都是由其面板UIPanel的depth和自己的depth共同决定的。
1)面板深度加权重。假设有UIPanel:P1,P2,UIWidget:W1,W2。W1在P1里,W2在P2里,P1的depth大于P2的depth,那么无论W1,W2的depth哪个大,W1最后算出来的depth都比W2大。
2)尽量避免图集交叉。假设有图集A,B。UIWidget:WA1,WA2使用图集A。UIWidget:WB1,WB2使用图集B。深度从小到大如果是WA1,WA2,WB1,WB2。那么会是2个DrawCall。如果是WA1,WB1,WA2,WB2,那么会变成4个DrawCall。相邻的深度如果是同一个图集,通常会合并成一个DrawCall。
DrawCall合并算法:先把UIPanel中的Widget按depth从小到大排序,如果depth相同那按照material的ID来排序。然后遍历每个元素,把material相同的Widget归类到同一个drawCall。
3)动态元素和静态元素区分。也不能一味的合并DrawCall。游戏里有一些元素是长时间不动的,比如背景的一些元素。有一些元素是经常需要变动的。如果把大量静态元素和动态元素合并成了一个DrawCall,也会浪费开销。
4)改变Position代替SetActive。SetActive需要做大量工作,如果只是改变Position,开销会小很多。
5)文字置顶。UILabel,UIRichlabel的深度置顶。会使得关于文字的drawcall全部合并。
3.lua的闭包
http://www.cnblogs.com/yao2yaoblog/p/6413190.html
4.内存池。
在游戏里的物品格子使用了内存池的策略。物品格子在很多地方需要使用,而且里面的组件不少。sprite,texture,label组合都有。在经历了多个游戏,多个版本之后最终采用的是内存池的方法。
也就是说使用不需要业务手动创建,而是从内存池取得,销毁改为放回内存池。
服务端
c++是属于我的编程母语。不过确实是一门深不见底的语言,无论看多少资料,总会新的东西展现在眼前。三年的代码量只能保证业务需求变化不大的情况下,尽量精简,不要犯低级错误。用最稳妥的写法,不要秀语法,不要秀语法,不要秀语法。能上篮别扣。
因为服务器是c++的,所以空指针,数组越界的问题很容易就让服务器崩溃了。崩溃了需要重启,对游戏运营会造成损失,玩家会流失的。肯定要尽可能少重启。
服务器的所有进程如图:
1.个人系统。
指的是玩家自己的系统,只涉及客户端和GameWorld进程,加上数据库存储的操作。
可以拿坐骑进阶系统举例,简化而言,服务器有个数据ride_level。客户端根据服务器下发的这个ride_level,在场景上,UI上展示不同的模型。
ride_level是玩家登陆的时候,从数据库取上来,放到内存里,组织在userlogic类里面。userlogic类声明在Role类里。这样通过Role的实例就可以拿到这个ride_level。于是各个人系统之间都可以通过role实例互相取得数据。
比如坐骑系统的等级是根据翅膀系统的等级而变(瞎编的需求),可以这样在翅膀系统写代码(伪代码),翅膀升级的时候导致坐骑升级。
void Wing::Levelup() { wing_level++; role->GetRide()->Levelup(); } void Ride::Levelup() { ride_level++; }
这些个人系统的数据会在玩家下线的时候存储到数据库,或者是服务器进程退出的时候存储到数据库,或者每隔一段时间存储到数据库。
至于数据库的存取使用的是json,存到mysql里的是一个json的字符串。
2.全服系统
比如帮派系统,比如结婚系统,比如全服的活动。数据区别与玩家自己的数据,在数据库中存在全服的数据表里,全服的功能在一个Global进程里。
Globa里通常会有全服的在线玩家list,每当一个玩家登录时,会从GW同步数据到Global,用一个简化的用户信息结构GlobalUser管理。
通常情况,如果需要得到其他玩家的数据,就必须经过Global得到。既是考虑多人的功能时,需要考虑Global和GW的通信问题。副本比较特殊,副本可以在GW管理多人信息。
举个例子。帮派神树的浇灌,大意是说整个帮派所有玩家共有一颗树,大家可以消耗自己的物品或者帮贡,来对树升级。
首先可能在客户端判断自己的帮贡是否足够,发消息到GW,判断自己的帮贡是否足够,发消息到Global对神树升级,在发消息回GW对自己的帮贡进行扣除。伪代码如下:
// client lua function ProtocalArmy::ReqUpTree(need_data) { Protocal.Begin(1000)//协议号 Protocal.SetData("i", need_data) //设置传输数据 Protocal.Send(NetId) //根据Ip地址发到服务端 } // GW c++ RoleArmy::ReqUpTree(need_data) { if (m_data < need_data) { // 帮贡不够 return; } // 发消息到Global 带着数据或不带 TreeUpReqStruct turs; turs.need_data = need_data; SendToGlbal(net_id, (const char*)&turs, sizeof(TreeUpReqStruct)); } // Global c++ ArmyManager::ReqUpTree(need_data) { // 神树升级 TreeUp(); // 发消息回GW 带着数据或不带 TreeUpReqSucBackStruct tursbs; tursbs.need_data = need_data; SendToGW(net_id, (const char*)&tursbs, sizeof(TreeUpReqSucBackStruct)); } // GW c++ RoleArmy::ReqUpTreeSucBack(need_data) { // 减帮贡 m_data -= need_data; // 发消息回客户端 给玩家反馈 }
整个流程大概如上,可见涉及Global的功能已经比只涉及GW难一些,因为涉及到GW和Global之间消息互发,如果思路不是很清晰,容易混乱。做功能之前需要弄清楚哪些数据在GW有,哪些数据在Globa有,数据会怎样改变。
在这个需求里面,为什么不直接从客户端发消息到Global呢,而要经过GW。是因为个人的帮贡信息是放在GW的,Global没有,先要在GW用帮贡数据做个判断。这个时候需要扣除的帮贡还不能直接扣除,需要等Global的神树数据改变后,再返回来改变帮贡。
其中有一个异常问题值得思考。假如Global数据变化之后,发消息会GW时,两个进程断开了,会怎样。岂不是会造成数据错乱。导致神树升级,但是帮贡没扣。
这样讲可能感受不深。借用数据库”事务“的概念来说。银行转账的例子,银行A转账到银行B,实际上是银行A减,银行B加,必须保证两者都完成,才算一次转账操作,否则要回滚。
刚才讲的例子是Global进程的神树数据和GW进程的帮贡数据也要同时改变,否则理论上应该回滚。但是我们的服务器好像没做这方面的考虑。因为GW和Global通常在一台服务器上,通信时间可以忽略不计。几乎不可能出现上述情况。
但是在我做过的另一个SLG服务器架构里面就有可能出这个问题。是一个悬而未决的问题。记录在这里,也许以后会得到答案。
3.跨服系统
跨服副本。
4.技能系统
技能系统是已有的基础模块了,不过我做过一个功能叫魔神系统。简而言之,是人物可以变身,变身之后人物的技能列表换掉,等变身时间到技能列表再还原回来。
这就涉及到技能模块和个人系统模块。个人系统模块可能是控制变身的条件,状态,cd等等。真正的大头在技能模块。
技能大致可以分为,被动和主动。
5.aoi模块
6.副本管理
副本管理全部在GW进程。副本其实就是涉及到场景管理。所有场景都是副本,有一个logic作为基类。如果是普通场景,没有什么特殊操作,那么用一个default子类即可。如果需要特殊操作则用子类,重载基类的方法来实现。
要创建一个场景,通常需要3个变量,scend_id,scene_key,logic_type。
bool CreateFb(int scene_id, int scene_key, int logic_type);
scene_id是由配置而来,scene_key用来唯一标志这个场景,logic_type代表这个副本的玩法。其中单人副本中,scene_key通常采用自增方法得到,而多人副本中,scene_key通常需要记录下来,以保证多人进入的是同一副本。
副本玩法比较重要的几个,需要重载的方法。心跳,人物进入,人物退出,人物死亡,人物被攻击等等。
virtual void Update(unsigned long interval, time_t now_second){}
首先心跳,重载这个函数,可以来控制副本的状态。interval是两次调用的间隔,now_second是现在的时间戳。例如如果副本有准备,开始,结束3个状态。可以在副本初始化的时候,算好这2个关键时间点m_begin_time,m_end_time。在update里分别与now作比较:
switch(m_status) { case FB_READY: { if (now_second > m_begin_time) { m_status = FB_BEGIN; } break; } case FB_BEGIN: { if (now_second > m_end_time) { m_status = FB_END; } break; } case FB_END: { // destroyfb(); break; } }
从而切换副本的状态。
人物的进出。副本通常会有一个玩家列表,来管理这个副本里玩家的信息。单人副本比较简单,多人副本需要根据需求在进出副本的时候写逻辑。比如玩家出了副本,在副本记录的信息需不需要清空。通常是会清空的。
清空和不清空的区别,在于一套对象管理的机制。场景里新创建一个人物,会有一个role对象产生,通常用一个obj_id标志这个对象。这个obj_id产生的策略,我理解为抢占式的。
如果A进入场景,那么obj_id = 1给A,B再进入,那么obj_id = 2给B,这时副本管理列表里obj_id = 1, 2分别是A,B玩家。这时A玩家推出了副本,C玩家进来了,这时obj_id = 1就给了C。同时列表里的信息覆盖掉A的信息。
如果需求是清空的,那就没任何问题。新进来的玩家覆盖旧玩家的信息。但是如果需求是清空的,那么A的第一次进入副本的信息怎么记录呢,比如他杀了个怪。这就需要另一个表了。
一个表是正在副本里的玩家信息m_on_fb_user_map,一个表是进来过副本又出去了的玩家信息m_out_fb_user_map。通常这个结构是写成一个std::map< UserId, UserInfo >的map。
通信相关
1.服务器进程间通信
2.服务端客户端通信。