0%

弱网战斗设计简介(艾泽塔预言)

需求分析

弱网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通讯

pve

PVP通讯

pve

底层设计

基类

实现了与项目内Lua代码类似的 Object和Singleton两个基类 ,不再赘述。但由于战斗逻辑需要基于数据执行,也就期望 绝大部分逻辑模块不存储属性,即不使用单例模式

数据管理

本Lua战斗模块中将所有数据类分为三种,并各自进行 池存储

实体(Entity, Component)

战斗中出现的”实体”,即友方、敌方等,需要有一个数据类来存储和验证其各项属性。在本项目中使用了ECS的设计思想进行了相关数据管理,以下简述其理由:

  1. 提高业务逻辑模块的内聚性:战斗中出现的各类单位/实体,常常需要实现类似的逻辑模块:如属性模块(AttrComponent)、buff模块等。
  2. 高度支持属性同步:仅需针对需要同步的业务逻辑模块,直接覆盖其属性即可完成属性同步。这也为断线重连相关的设计打下了基础。

运行中数据

运行中数据是由对象池严格管理的,其销毁时机分为两类:用后立刻销毁的数据(运行数据);等待战斗销毁时再销毁的数据(记录数据)。

元数据

元数据以读表数据为主,几乎永不销毁;以类型和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. 状态类的存在不必要,只要有一个独立的状态管理模块即可 :因为本来由状态内部自行实现的逻辑需要分离成单独模块,又不允许各状态自行决定状态切换,又不存在复用优势,专门将各个状态封装成类就只会显得臃肿了。因为状态数量有限,条件语句可以接受,所以所有状态仅由一个专门模块管理即可。
  2. 单独抽出根据逻辑类型执行具体逻辑代码的模块 :为了提高代码内聚性、更好的支持开闭原则,逻辑代码需要抽出来;由于逻辑类型有限,所有逻辑仅由一个专门模块管理即可。
  3. 单独抽出分析玩家操作的模块 :这一模块专门分析玩家操作所带来的包括状态改变在内的各项影响,由于玩家操作类型有限,所有逻辑仅由一个专门模块管理即可。此外由于玩家操作可能会产生跨流程的影响,需要封装单独操作类用于暂存玩家的操作数据。(引申:如何实现回退、预演、回放等机制?)

pve

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- BattleData中,控制战斗流程的主要方法
function M:Execute(op)
-- 执行操作
if op then
self.curOp = op;
BattleOperater:GetInstance():Operate(self);
end

-- 推进流程
BattlePlayer:GetInstance():Play(self);

-- 执行逻辑
for i = 1, #self.performList do
BattlePerformer:GetInstance():Perform(self);
end

-- 逻辑结束后,继续流程
if self:GetNextState() ~= BattleState.NULL then
self:Execute();
end
end

技能、Buff、元素反应、AI等具体业务逻辑实现

pve

与客户端通讯

由于Lua战斗模块直接在客户端Lua虚拟机中实现,故可以为实现方便而不太多关注通讯协议本身的复杂度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
客户端直接调用战斗模块方法创建战斗:
NewPVEBattle(stageId, key, seed, reconnectStr)

BC_StartBattle 通知客户端开始战斗,同步友方单位
CB_StartBattle 客户端确认战斗开始
loop{
BC_StartWave 通知客户端开始某一波,同步敌方单位
loop{
BC_StartTurn 通知客户端开始某一回合,同步单位属性和出手顺序
loop{
BC_AIAct 通知客户端直到战斗暂停为止所有的战斗内容,战斗暂停后等待出手的单位属性及其可使用的操作
CB_Operate 客户端完成战斗内容表现后,选择操作List中的一个,告知战斗模块
}
BC_FinishTurn 通知客户端结束某一回合
CB_FinishTurn 客户端完成回合演出,确认结束回合
}
BC_FinishWave 通知客户端结束某一波
CB_FinishWave 客户端完成波演出,确认结束波
}
BC_FinishBattle 通知客户端战斗结束,给出验证字符串

与服务器通讯

为降低服务器java调用Lua时的损耗,应尽可能减少开放的方法数量。现在仅用三个方法实现所有所需功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 创建战斗 传入与服务器约定好的战斗信息,返回序列化字符串
function G_CreatePVEBattle(initTable, heroList)
return BattleServerProtoManager:GetInstance():CreatePVEBattle(initTable.missionId, initTable.key, initTable.seed, heroList);
end

-- 销毁战斗
function G_DestroyPVEBattle(key)
BattleServerProtoManager:GetInstance():DestroyPVEBattle(key);
end

-- 验证战斗(任意战斗过程中) 传入战斗key和验证字符串,返回验证结果和序列化字符串
-- 该序列化字符串可用于客户端断线重连
function G_VerifyBattle(key, opStr, resultStr)
return BattleServerProtoManager:GetInstance():VerifyBattle(key, opStr, resultStr);
end

AI设计

功能实现

基于类型理论和数据建模的正确性验证系统

目的:
对策划配置进行正确性验证
背景:
面向对象的做法在表达约束时逻辑复杂,且难以支持验证;实体关系的做法亦不支持验证;使用逻辑数据(或是多维的)则过于复杂,对小型项目负重过大;这里基于类型理论,参考DDML语言和DDMM方法,设计一套更适用于小型项目,易于理解,适应快速需求变化的建模和验证环境。验证方法需要具备可靠性、完备性和可终止性。
做法:
定义类型、项及它们的语义;定义它们形成环境的方法;定义类型间的规则(结构类、关系类、等等)
由实际需求定义类型原子、数据元、数据元目录、数据元目录列表
根据上述定义设计类型检查算法

遍历式的自动测试系统

针对需求变化的适应性