需求分析
弱网PVE
弱网PVE战斗,即客户端仅在战斗开始/结束时与服务器进行强通讯(服务器返回前等待),在战斗中靠自己进行数值计算、随机触发等等功能。在此基础上,仍需保证与传统强通讯战斗相同程度的防作弊验证、数字运算正确性;也需要支持断线重连等功能的实现。
PVP
PVP战斗必然不是弱网的;但其战斗逻辑本身与PVE是类似的,所以我们并不希望为PVP再另外实现一套战斗通讯甚至战斗逻辑。因此 弱网PVE战斗的设计需要能够支持强通讯的PVP战斗 。
基础设计思路
Lua战斗模块
为了客户端自行实现战斗逻辑、服务器支持同逻辑验证这个基本需求,我们需要 服务器和客户端共用同一套战斗代码 。由于本项目客户端逻辑模块由Lua编写,且已有LuaJit解释器可以实现服务器Java代码与Lua代码的通讯,故该战斗代码模块使用Lua实现。
为实现各项需求,对Lua战斗模块会有以下设计要求:
在内部完成几乎所有的数值运算
在不同语言、不同运行环境下,编译器对浮点数的精度处理是有差异的。为了让不同环境下的浮点数运算结果一致,必须将所有数值转换为定点数。使用定点数会有几种做法,这里不做赘述;但在业务逻辑中强制要求使用定点数势必会导致代码更难维护、更不易读,甚至更容易出错,以及些许的性能问题。为了拒绝大量使用定点数,我们就必须保证大部分运算的所处环境是一致的,而最适宜的环境就是在Lua战斗模块内。所以在基础设计中, Lua战斗模块承担了包括数学运算、随机算法、读表在内的所有数值运算内容;从外界获取的数值仅有基础英雄属性 。
支持结果验证和过程同步
为实现防作弊需求,需要 Lua对同一场战斗同操作的运行过程和最终结果必须保持一致 ,从而实现结果验证。为此,我们需要实现一个稳定的随机数算法和种子演变方法,它们仅受战斗最初种子和玩家操作的影响,且影响结果是一定的;为同步玩家操作,又需要为玩家操作生成一个唯一可验证的标识符,将其作为凭据实现过程同步。具体实现方法见下文。
可同时运行多场战斗
由于Lua负责了战斗数据的暂存和过程验证,所以Lua必须支持穿插着运行多场战斗。但Lua是不支持多线程的语言,故如果由Lua管理战斗的创建,在服务器多线程同时调用时可能会创建出key值完全相同的战斗。这也就意味着 Lua战斗模块需要根据服务器提供的唯一key和seed创建战斗数据,存储战斗数据,且战斗逻辑是基于数据的 。另外需要特别注意的是,Lua是单线程的,因此需要服务器将Lua模块作为单例使用。
异常处理
客户端至少需要支持断线重连;在复杂情况下可能需要支持预测、回滚、校正等机制。具体参照帧同步思路。
性能要求
假设一个服务器上3000人同时在线,全都在跑战斗;客户端3分钟会跑完一场10~20回合的战斗。这就需要Lua运行一场10~20回合的战斗逻辑耗时低于60毫秒,在逻辑不出严重问题的前提下这是很容易实现的;但考虑到服务器java调用Lua过程中的损耗,就必须精简接口,并提前确定服务器侧的一些策略,以降低调用Lua耗时高所导致的风险。
PVE通讯
PVP通讯
底层设计
基类
实现了与项目内Lua代码类似的 Object和Singleton两个基类 ,不再赘述。但由于战斗逻辑需要基于数据执行,也就期望 绝大部分逻辑模块不存储属性,即不使用单例模式 。
数据管理
本Lua战斗模块中将所有数据类分为三种,并各自进行 池存储 。
实体(Entity, Component)
战斗中出现的”实体”,即友方、敌方等,需要有一个数据类来存储和验证其各项属性。在本项目中使用了ECS的设计思想进行了相关数据管理,以下简述其理由:
- 提高业务逻辑模块的内聚性:战斗中出现的各类单位/实体,常常需要实现类似的逻辑模块:如属性模块(AttrComponent)、buff模块等。
- 高度支持属性同步:仅需针对需要同步的业务逻辑模块,直接覆盖其属性即可完成属性同步。这也为断线重连相关的设计打下了基础。
运行中数据
运行中数据是由对象池严格管理的,其销毁时机分为两类:用后立刻销毁的数据(运行数据);等待战斗销毁时再销毁的数据(记录数据)。
元数据
元数据以读表数据为主,几乎永不销毁;以类型和id作为索引,由一个专门的对象池管理。
为各项数据生成唯一可确定的Key
在实现过程同步和结果验证时,服务器Lua和客户端Lua间需要互相告知”谁(数据)做了什么(数据)”,所以需要给双方所持的数据生成一致的索引Key。以下定义Key的生成方法:
Key生成基本规则:该数据创建者的Key + 该数据特征码 + 递增Index。如:B1E1, B1E2, B1E1C1, B1E2C1, B1E2C2…
该数据创建者的Key:仅有战斗数据不需要创建者,其他大部分数据的创建者就是该战斗数据。
数据特征码:自定义的一个枚举。如实体数据为”E”,技能记录为”rs”等等。
递增Index:递增Index是针对特定创建者和特征码的,不是全局的。
Key在使用完毕后,需要随着数据销毁而销毁。
种子生成和随机算法
种子的生成和演变
种子可以由服务器生成,也可以由Lua生成;现在是由服务器生成的。每当用户操作时,种子需要依据操作内容产生变化。由于战斗数据的key总是一定的,所以只要根据客户端所选择的Operation数据的key去修改种子,种子变化就也是一定的,从而满足服务器进行结果验证的要求。
随机算法
随机算法需要是伪随机算法;在输入同一个seed时,其随机结果必然一致。随机结果同时要求一定程度的均匀性和随机性。本随机算法首先采用了常用的伪随机算法:线性同余法。线性同余法具有高均匀性,低随机性和周期性的特点。为了降低其均匀性,并提升其随机性,本随机算法在线性同余法的结果之上又增添了二进制随机判断:先将需要的概率转换为二进制,再对每一位二进制数使用线性同余法进行一次概率为50%的二元随机判断,直到随机判断结果不一致,或位数达到最小精度。
该随机算法同时满足必然的随机结果、一定的均匀性、一定的随机性、低时间复杂度等要求,最终验证满足项目需求。
过程同步和结果验证
过程同步
过程同步指的是客户端在任意时点向服务器同步Lua战斗的运行过程,从而满足断线重连、PVP强验证等需求。受益于战斗运行结果的唯一可确定性和战斗所创建数据的Key的唯一可确定性,客户端Lua只要将所有可选操作都存为数据,并将玩家所选择的操作数据的key告知服务器Lua,即可满足战斗过程同步的需求。
断线重连
断线重连时,客户端需要的仅仅是当前战斗状态,不希望从头重新演算一遍战斗。也就是说客户端Lua不在意此前发生过什么(Record),仅需将现在的战斗状态(State)和所有实体(Entity)同步至最新。因此服务器Lua只要将战斗State以及所有Entity中需要同步的属性序列化为字符串,交给客户端Lua进行解析即可。
结果验证
结果验证指的是验证客户端Lua与服务器Lua的运行结果是否一致。这个结果包括最终结果,也包括过程中的结果。受益于Record数据的设计,客户端Lua仅需将战斗的所有Record序列化成字符串后同步给服务器Lua,服务器Lua对照Record的序列化字符串是否完全一致即可。
加密
为加大客户端作弊成本、降低序列化产生的字符串长度,Record序列化出的字符串需要进行加密操作。为避免解密所造成的的时间成本,对于需要解密的序列化内容不进行加密,如断线重连使用的属性相关字符串。
战斗逻辑实现
战斗状态控制:将操作、状态和逻辑分离
要实现战斗从开始到AI行动到玩家操作到战斗结束等等一系列战斗流程,必然会采用 基于状态的设计模式 。一般的状态模式会创建一个接口类(BaseState),并为其实现多个具有实际意义的状态类(XXXState);这些状态类除了有自身的执行方法(Execute)之外,还可能有属于自己的生命周期(OnEnter,OnExit,Tick);最后外部逻辑会创建一个负责切换状态的环境(StateMachine)用于管理状态,此外各个状态有可能会根据自身逻辑运行结果主动通知状态的改变。这种设计是具有普适性的,且在多个回合制游戏中都得到验证的。在本项目弱网战斗设计中,因包含前期误判在内的多种理由拒绝了这种设计;下面先简述其特点,以及它与本次弱网战斗设计需求间的适应性:
状态机的特点 | 设计需求 | 是否适应 | 优化方向 |
---|---|---|---|
状态数量必须有限,状态间切换逻辑不能过于复杂(网状) | 战斗流程状态是有限的,其状态切换不至于到网状程度 | √ | \ |
各状态在自己内部实现属于该状态的运行逻辑 | 误判: 在不同战斗状态中可能需要执行相同逻辑 实际: 不同战斗状态中执行相同逻辑的情况偏少 |
√ | 将逻辑与状态分离,参照EC设计(为了提升逻辑代码内聚性) |
方便添加新的状态 | 战斗流程状态偶尔可能会增加 | √ | \ |
各状态的运行逻辑具备可扩展性,如扩展生命周期 | 战斗逻辑不需要生命周期,仅需要一个Execute方法 | × | 各状态内部方法可以很简单 |
减少条件语句的大量使用 | 战斗流程中会出现的逻辑类型数量有限 | × | 使用判断状态的条件语句是可以接受的 |
不能很好的支持开闭原则(修改状态的行为逻辑时必须修改状态类的代码) | 在某个状态中具体要执行的逻辑可能常常随着需求改变 | × | 将逻辑与状态分离,参照EC设计(为了更好的支持开闭原则) |
不能很好的支持开闭原则(修改状态切换逻辑时必须检查所有负责了状态切换的源代码) | 可能常常增加新的玩家操作类型导致状态切换逻辑改变,甚至玩家操作所带来的的数据也会影响状态切换方式 | × | 1.不能允许各状态类自行书写状态切换逻辑,状态切换必须由独立模块负责 2.操作可能自带数据,数据经过某一状态逻辑后可能才会生效,故需要封装”操作类” |
可以在不同环境(即不同战斗)中共享同一个状态对象,降低创建对象的数量 | 状态对象共享可能会影响上文所述”索引一致性”,提升状态同步和结果验证实现的难度 | × | 状态对象需要属于唯一环境 |
整个状态机是附属于环境(即战斗)本身的 | 本次弱网设计需要强调”基于数据”,各逻辑模块希望仅仅是工具类,且数据本身希望尽可能简化 | × | 实现状态模式的整个模块都仅仅是工具类,不存储属性 |
综上,可以总结优化方向为:
- 状态类的存在不必要,只要有一个独立的状态管理模块即可 :因为本来由状态内部自行实现的逻辑需要分离成单独模块,又不允许各状态自行决定状态切换,又不存在复用优势,专门将各个状态封装成类就只会显得臃肿了。因为状态数量有限,条件语句可以接受,所以所有状态仅由一个专门模块管理即可。
- 单独抽出根据逻辑类型执行具体逻辑代码的模块 :为了提高代码内聚性、更好的支持开闭原则,逻辑代码需要抽出来;由于逻辑类型有限,所有逻辑仅由一个专门模块管理即可。
- 单独抽出分析玩家操作的模块 :这一模块专门分析玩家操作所带来的包括状态改变在内的各项影响,由于玩家操作类型有限,所有逻辑仅由一个专门模块管理即可。此外由于玩家操作可能会产生跨流程的影响,需要封装单独操作类用于暂存玩家的操作数据。(引申:如何实现回退、预演、回放等机制?)
1 | -- BattleData中,控制战斗流程的主要方法 |
技能、Buff、元素反应、AI等具体业务逻辑实现
与客户端通讯
由于Lua战斗模块直接在客户端Lua虚拟机中实现,故可以为实现方便而不太多关注通讯协议本身的复杂度。
1 | 客户端直接调用战斗模块方法创建战斗: |
与服务器通讯
为降低服务器java调用Lua时的损耗,应尽可能减少开放的方法数量。现在仅用三个方法实现所有所需功能:
1 | -- 创建战斗 传入与服务器约定好的战斗信息,返回序列化字符串 |
AI设计
功能实现
基于类型理论和数据建模的正确性验证系统
目的:
对策划配置进行正确性验证
背景:
面向对象的做法在表达约束时逻辑复杂,且难以支持验证;实体关系的做法亦不支持验证;使用逻辑数据(或是多维的)则过于复杂,对小型项目负重过大;这里基于类型理论,参考DDML语言和DDMM方法,设计一套更适用于小型项目,易于理解,适应快速需求变化的建模和验证环境。验证方法需要具备可靠性、完备性和可终止性。
做法:
定义类型、项及它们的语义;定义它们形成环境的方法;定义类型间的规则(结构类、关系类、等等)
由实际需求定义类型原子、数据元、数据元目录、数据元目录列表
根据上述定义设计类型检查算法