设计思想和插件介绍完了,那么就需要看看实际项目怎么去使用它。
根据策划和服务器大佬的评估,正常情况下每秒发生的战斗约2000场,我们的服务器预估为8核,如果每个核起一个战斗线程,就可以同时并发8场战斗。如果每场战斗花费50ms,那么一台服务器一秒只能计算160场,那么就需要13台服务器,呃~有点贵。。。如果每场战斗花费20ms,那么一台服务器一秒能计算400场,就只需要5台服务器即可,似乎能接受了。
所以我们的目标就是让服务器(逻辑)每场战斗的耗时保证在20ms左右(非常困难),想看看大家有没有跟我们类似的项目,服务器的异常战斗结算是多少。。。
客户端编程与服务器编程
虽然都是码代码,但服务器的编程思想和客户端还是有一些差异的,服务器更多是无“我”概念(和客户端帧同步的处理有些相似),而客户端则以“我”为核心。比如一个联盟的功能系统,有人晋升了,它给所有的联盟成员推送的协议都是一样的,但是在客户端你需要跟自己的id进行比对,如果是别人晋升了那我只要改变一下别人的显示头衔,如果是自己晋升了,那不仅要改变显示头衔,还需要处理自己的各种权限按钮,甚至是一些特殊权限才能查阅的功能界面。
服务器的大部分更新和逻辑都是由驱动完成的,而客户端在线时期,往往都是即时计算和展示的。这些驱动可能来自某个玩家的操作请求,某些定时器(这个功能不太常用)的触发。比如一个联盟如果100天都没有人上线,那么它就会被自动解散。你可能会认为,这些100天没有上线的联盟一到100天就会被服务器清除,但其实并不是。大部分时候,它们会静静的躺在数据库里,直到这个僵尸联盟里的一个成员诈尸上线,服务器一检测,我擦你小子联盟200天没人上线了,立马解散,然后给所有成员发送邮件,然后从数据库清除。当然那些挂机或者放置类的游戏也是一样的,用玩家上线的事件驱动来完成逻辑。客户端在这部分上要求就比较高一些,比如一个界面打开的时候,如果数据发生变化了,都要求即时体现在逻辑和表现上。
二者在添加变量以及类型上面也会有所区别。比如客户端可能为了展示方便,在某个功能系统里随便加几个long或者double类型的变量。但是服务器不行,它必须得考虑必要性。假如一个系统我加了一个long类型,我同时在线有多少人,那么我的内存就要多消耗多少。
客户端总是以服务器的数据作为准确输入,而服务器原则上除了操作和交互的请求类型之外,不相信一切客户端数据,总是以自己为准。如果有些服务器的开发人员为了省事不去自己的库里捞数据,让客户端带给他,然后用这个值去判定,嘿嘿~~~
前面的铺垫其实是为了引出我们多线程理解的差异。正如开头所说,服务器的多线程其实都用在了开辟每条线程做并发服务器了,而客户端的多线程大多是认为自己独占了所有核,避免出现一核有难 多核围观的情况。鉴于我们的战斗要跑在这样的服务器类型下面,多线程的编程反而是个累赘,负优化。。。
逻辑部分拆离
ECS中的逻辑都在System里去处理,但是Entitas里的System是需要注册才能用的,所以就出现了一个用来管理System的System,为了区分我们叫它Feature。Feature用宏来决定是从Entitas.DebugSystem中继承还是从Entitas.System中继承。如果是前者就会看到第二篇里的各种调试信息。
Feature可以拥有多个,每个之间是独立逻辑,这里用来处理战斗的不同阶段。如下图所示:
对于服务器的需求,优化要精确到ms级别。所以每一个能够挖掘的部分都必须深入挖掘。像Entitas的entity初始化就需要10ms的地方,我们自然要将它从Battle部分拆离出来。所以结合实际的战斗流程,我们拆离了3个Feature(System),一个是用来做初始化的,一个是用来做阵型操作的,最后一个才用来做战斗计算。
可以简单看一下EntityCacheSystem:
很简单,初始化500个entity,然后销毁,这样池里就有500个缓存,避免了战斗进行过程中创建Entity的时间开销。
DataSystem其实是管理战斗所需要的各种表格数据,比如兵种属性,技能、英雄、阵型等等。这部分因为要放在服务器计算,所以必须要完备。如果依赖外部的话,数据结构和处理就会变得异常复杂,所以我们会将跟战斗相关的数据分开加载,确保战斗模块的数据独立。
ReadySystem(Feature)就是战前布阵阶段,这个阶段还没参与正式的战斗,但是已经有单位和阵型表现了,并且单位会延伸到战斗过程中(布阵好了之后点开始,会战斗会直接接管布阵的兵力和阵型),AI的行为会在这个时候进行筛选。
要知道,几百个独立单位的AI是非常非常耗时的,我们不可能把这部分放到战斗内去(不然20ms的高压线肯定完不成),所以,我们其实知道对面是什么阵型,知道自己现在什么阵型,每次变阵或者调整兵力的时候,扫描一遍AI,把每个单位的初始AI放在这个部分去完成(如果AI的目标单位死亡,寻找下一个的时候就没办法了,只能从算法和规则去优化了)。
这部分的最后,其实可以给大家展示一下几个简单但是作用非常关键的System。
逻辑帧记录器,记录当前执行的逻辑帧数,用于同步技能释放,估算当前总耗时等等,逻辑非常简单,每帧 1。
战斗结束监听,每帧根据指定条件判定是否结束,有大概几种方式:1、时间到了 2、某一方全部死完了 3、GM强制输赢,比较简单,注意Group是在构造函数里做的,所以不会有额外消耗。
战斗日志,可以看到我们使用了一个GameServices统一管理了需要输入和输出的部分,下面会讲一下这里实现。现在大体就是监控到有新日志就输送出去,用的是ReactiveSystem类型,所以没有日志的时候没有消耗。
DestroySystem监控需要销毁的Entity,比如死掉的士兵,客户端表现的弹道,特效,已经输出过的日志等等。这里就涉及到了Collector了,注意它和之前BattleOver的Group的使用区别。
记得我们之前将Entitas的时候用的是接口对外,或者事件对外。这两种我们评估下来都有些复杂,最主要的是服务器战斗要接入服务器框架还需要按照格式去写,那么我们就用了一种最简单原始的方式delegate。服务器也不用关注interface类型,自己写一个delegate参数对了,设置进来就好。
最后一个就是GameServices怎么初始化代理,一般在初始化战斗模块的时候,设置好代理。逻辑写在自己代理器里就可以了。
这里服务器运行的时候,给一个非常大的逻辑帧补偿就可以秒算结果了。(代码是客户端的战斗设定,服务器的按照步骤设置参数完成初始化,然后每次需要战斗的时候调用start就会接收到战斗结果的回调)
录像和回放
我们知道,War3的录像文件,必须版本一致才能播放,那是因为不一致的版本逻辑和数据都不对,计算结果也一定不对。而且网游基本都很少会提供录像播放器,那么我的需求就是需要一个内部的录像播放来检测战斗的逻辑BUG。
我们的SLG和王者荣耀不一样,不一样的地方在于王者10个人同时跑一个战斗,并且跨度几十分钟。服务器每隔几秒就可以收集关键数据比对不同客户端之间的计算是否一致,如果有人不一致,里面可以检测到,然后T他下线重连恢复正常。但是我们因为是自动战斗,并且是秒算结果,不可能依靠多人采集关键数据的方式验证逻辑。不过我们的优势就是验证时间非常短,按照20ms一场战斗,1秒就能50场,所以我们可以在时间片段上验证。输入同样的数据跑1万次,如果有5场以上结果不一致(对标王者万五的不同步率),那么就必须解决之后才能上线。
另外服务器要保存一定时间内玩家的战斗录像,用于被举报时随时校验玩家数据。
未来的优化方向
Entitas是基于Unity的框架,用的是C#,那自然就有IL那一套东西,在Linux上虽然也可以使用.netCore来支撑,但是在效率和内存上仍然有比较大的性能问题。所以第一个优化方向是将C#转为C 代码,提高性能和内存管理。
因为是基于Unity的,所以开发的时候为了快,用了一些Unity的数据结构和数学库,那么一方面服务器得引用Unity引擎代码,一方面性能也没有定点库来的高效。第二个优化方向就是脱离Unity引擎,完全独立工程,独立编译。
多线程的优化虽然在服务器上没有效果,但是客户端还是有必要的,所以第三个方向会区分客户端和服务器,在客户端上独立开启多线程计算,当然这会比较难,不一致的计算很可能带来不一致的结果。
战斗本身逻辑上其实还是有很多可以优化的点,比如技能,弹道、AI等等,第四个优化方向是保障计算正确的情况下,优化逻辑。
还有暂时没想到,如果大大们有想法欢迎补充。。。