基于ECS模型的卡牌战斗框架3

上一章将实现战斗逻辑的ECS模式进行了记录,这一章开始记录游戏逻辑内的几个大的业务模块。

这几个模块是经过多个项目验证,逐步走向相对完善的一套机制,也许无法涵盖所有的游戏类型,但是应当具有或多或少的参考意义,需要在具体实现时加以甄别和考量。

核心通用业务数据设计与实现

如果想构建一套成功的框架能够实现游戏内绝大多数的业务逻辑,那么就需要经过抽象,将业务逻辑进行细分,直到某一层的内容通过组合就可以实现业务逻辑所要求的功能。

基于此思路,所以我设计了几个通用的业务模块与数据结构。或许我们对大的业务模块依旧需要各种层级设计,但是对于它们的逻辑部分,则完全可以通过通用业务模块的组合实现。

需要说明,我们还需要设计一些通用枚举数据,这部分内容就在下面具体用到的模块内进行简易说明。

敌我识别的设计

游戏战斗中,最核心的功能之一便是需要区分敌我,因而就需要敌我识别模块

最简单的敌我识别,就是给每个实体添加一个id用以表示队伍,然后在模块核心存放队伍之间的关系。当需要判断两个实体的关系时,取出队伍id即可判断。需要指出,这里的队伍需要依据自身项目需求进行设计,并不指向字面意思的队伍。以我的游戏举例,那么一个玩家所能控制的所有实体归为一个队伍,所有NPC实体归为一个队伍。类比守望先锋,那么一个玩家控制的角色就是一个队伍。

现给出我的项目中关系枚举的类型说明:

关系枚举 关系说明
自己 唯一id相同的实体,即为自己
我方 两个实体所属队伍id为同一个的,即相互为我方实体
友方 两个实体所属队伍id为友方关系的,即相互为友方实体
敌方 两个实体所属队伍id为敌对关系的,即相互为敌方实体
中立 两个实体所属队伍id为中立关系的,即相互为中立实体

当我们站在游戏开发者的角度开发游戏时,所有的关系可以分为两种视角来看待:

  1. 玩家视角:即我们将自己带入玩游戏的玩家,那么很自然的就可以设定大多数NPC都是敌方;
  2. 上帝视角:即所有的实体相互之间都存在关系,而他们与我们是没有关系的,我们不可以代入特定关系去开发逻辑。

上述敌我识别的枚举虽然具有通用性,但在具体项目开发中,我依旧推荐使用上帝视角来看待我们写的每一行代码。只有我们足够抽身出具体逻辑,我们的代码才足够稳健,而不会因关系判断异常而导致各种难以发现的隐藏小bug。

通用条件

条件判断是我们业务逻辑内核心的一部分。我们经常看到游戏内技能描述的部分,会附带若干生效前提,这就是条件。如何将条件抽离的足够干净成为一个独立模块,就是下文要说明的内容。

在我的项目中,因开发阶段所涉及内容的关系,到头来也只是分为了三个条件类型:

  1. 随机:填写随机概率,表示该条件有多少的概率为通过,除此之外再无其他限制;
  2. 公式判断:直接填写条件公式,按照自定义解释器认可的公式格式,大多数数据以判断某个传入实体的指定属性是否满足条件值为主;
  3. 指定范围存在指定角色:通过填写形状、形状位置、指定角色id,若指定范围内有指定角色存在,即条件判断成功。

由此可见,通用条件的数据结构设计核心就是条件类型(依据项目需求而设计),而其他数据的设计则为条件类型服务。

公式判断是一个很重要且具有通用性的条件类型,其价值等同于随机条件,可以在任意类型游戏中进行设计。而在公式中,我们会涉及一个很重要的枚举,就是属性枚举。只有有了属性枚举,我们才可以获取实体的某个具体的属性去进行逻辑判断或生效。

公式判断也引出我自定义的解释器。

自定义解释器简介

这套自定义解释器源于我很久之前与热更抗衡的一个想法。核心战斗无法热更是一些项目无法忍受的,那么作为折中,可以开发一个足够小而不必引入插件的语言,使策划通过他配置诸如伤害公式这些重要而又足够小的代码。

正是基于这个思路,我开发了这套简易的解释器,暂称它为CH。这个解释器并不复杂,支持加、减、乘、除、余与逻辑或、且、非,以及条件、括号运算。解析公式后生成语法树即可运行出结果,返回结果可以是double或bool类型。这里比较重要的,其实是对于运算节点的解析,举一个伤害公式的例子:

(1+S.P_801)/(1+T.P_901)+(S.P_803-T.P_908)

上述公式中,S、T表示的是这个伤害公式所作用的攻击方与被击方,P_801的表现形式表示的是获取对应实体的id为801的属性。所以如何解析出这个含义就是需要额外开发的内容。

在项目中,通过继承解释器,就可以对传入的Token进行单独解析。一旦发现Token不是数字,就可以按照规定的模板去解析Token的内容,并返回可以构建语法树的对应的节点。这里就不再展开叙述。

至于执行解析完成的公式,需要我们先将一些会用到的参数传入公式对应的数据堆中,再执行语法树,即可根据公式内容返回计算获得的结果。

我这套自定义解释器除了条件会用,后续行为、过滤等模块也都会有用到。

通用过滤

过滤模块是通用模块中的基础之一,对实体的筛选都会用到通用过滤的内容。

通用过滤核心用到下面几个字段:

字段 类型 说明
敌我识别 敌我识别枚举 基于使用通用过滤的场景判断敌我
实体类型 实体类型枚举 实体类型作为业务逻辑赋予实体的数据,根据该值进行过滤
公式条件 表示条件的公式字符串 使用公式筛选目标是否满足过滤条件

通用过滤被用到的地方,大抵是要遍历实体的,然后对所有实体进行过滤条件筛选,筛选通过的实体就是过滤结果。

通用行为

通用行为是通用模块内的核心之一,所有的复杂业务逻辑模块都需要用到行为。当我们需要向选中的角色修改属性、添加buff、触发特殊技能,都可以通过通用行为的模式去执行。

通用行为数据的核心,就是其行为类型。依据业务逻辑内容的不同,行为类型也可以有很多,下面就通过表格列举常见的行为类型及其所需的参数:

行为 所需参数
属性输出 输出的属性类型、输出值的计算公式
输出buff buff的id
删除buff buff的id
添加技能 技能的id
创建子物体(子弹) 子物体的id

上面的几种常见的行为类型,基本适用所有的战斗类型。而游戏本身特殊的业务逻辑,就需要在这些的基础上再定义新的行为类型,以涵盖业务需求。

而在代码实现层面上,我们需要根据不同的行为类型去派生对应的Action以执行该行为类型所对应的逻辑(注意,此处的Action指的是通用行为类,而非显示层处理逻辑消息的Action模块)。

通用选择目标

有了行为,我们还需要选出行为所作用的目标实体,通用选择目标就是做这件事的通用模块。

不同的业务逻辑,通用选择目标数据也会有差别,但是核心流程不会变。

通用选择目标流

特定实体集合是一个抽象的概念,需要结合业务逻辑进行定义。以我们项目为例,我们设定有“场上全体角色”、“场上我方全体角色”、“自身实体”等选项。需要特别说明,这里的“我方”、“敌方”等概念,都是基于执行选择目标的实体而言的,我们开发者仍然是作为第三视角(上帝视角)来审视与实现功能的。

从实现性能角度考虑,通过选取场上全体角色再配合过滤数据,一样可以选出场上我方全体角色这一目标,但是硬编码一般可以实现更好的效率,因而这里的数据配置需要策划慎重填写,除非项目上线新增数据逻辑。另外,指定实体范围越小,则预先筛选出的实体越少,而后续的过滤等判断都是需要对实体进行循环判断的,所以实体越少对性能的提升也就越高。这里之所以着重强调性能的问题,是因为在实际项目中,选择目标是基础通用模块中运行频率最高的模块,所以这里的性能优化会直接影响游戏的性能。

根据业务需要,上面的只是一个基本流程,还可以在对应点位内添加新的选择条件以实现自身项目的业务需求。比如在通用过滤之后,可以添加依据指定形状对实体的位置进行筛选,以此可以实现场上陷阱的目标选择逻辑。

核心复杂业务逻辑架构

技能

技能是任何一个带有战斗逻辑的游戏所必不可少的模块。小则仅仅是播放一个动作扣一次血,大则可能因各种技能相互作用而产生一整套完整的战斗流程。

根据过往经验,我将技能分为主动与被动两种:

  • 主动技能具有时间属性,即随着时间的流逝,技能逻辑逐渐生效,其生效逻辑与运行时间有强关联;

  • 被动技能没有时间属性,可以理解为瞬发技能。

主动技能通过配置可以实现瞬发技能的效果,但是被动技能本身因其自身的触发机制而与主动技能表现出不同的逻辑流程。

下面进行详细说明。

主动技能

主动技能有一套通用的设计结构,可以将技能逻辑分为技能、技能因子两部分:

  • 技能部分:技能部分包含n个技能因子,同时还拥有技能的通用数据,比如技能CD、基础选择目标规则等。具体应该包含哪些数据需要根据项目需求进行设计,但很明显的是技能部分包含的是整个技能释放期间都不变的规则或概念数据。
  • 技能因子:技能因子是技能真正生效的部分,这里可以包含有具体的生效时间、生效逻辑、对应动作信息等。

其实在具体项目的技能设计时,技能与技能因子是核心构成,但是根据需求的不同,可能会在不同的地方加入新的层级,以实现特殊的逻辑。

被动技能

被动技能同样拥有技能因子,只是这里的因子更多承担的是记录逻辑的功能,而不存在触发事件的问题。而值得记录的,是这个模块设计过程中逐步完善起的一整套完整的消息触发机制,以下就做详细记录。

消息触发机制

消息触发的前提,是需要定义消息类型。也就是说,定义消息类型并在对应逻辑处埋点触发,这个核心思路是不变的。而变的是触发过程中一系列的数据传输与判定流程。

现用表格,对这套消息触发机制所用参数进行列举与说明:

参数名 触发时机 触发关系 通用条件
字段类型 int int型数组 条件字符串
参数说明 具体的消息id 抽象概念,表示触发消息的实体与被触发内容对应实体的关系集合 通用判定条件,即使用对应消息传输出来的数据进行自定义判断,具体内容交由策划配置,程序仅实现通用条件逻辑

上表中,较为抽象的就是触发关系的概念,这里作详细说明:

触发关系的本质其实就是敌我关系枚举。假设实体A所持有的技能监听消息1,当实体B触发了消息1时,就需要判断实体A与实体B的关系。若我们触发关系填的是敌,表示敌对关系才触发,且A与B是敌对关系,那么实体A所持有的这个技能就会被触发。

再举例说明:有一个技能描述为“我方任意英雄得到buff时我要做某事”,那么这个技能的触发时机就是“获得buff”、触发关系就是“我方”,这样当我方任意一个英雄触发了“获取buff”的事件时,这个技能就满足了触发条件;若是敌方英雄触发了“获取buff”事件,因实际触发关系是“敌”而不是“我”,则该技能不会被触发。

上表中,通用条件内有提到消息附带的参数,我们可以在设计阶段就对每种事件设计独立的参数。比如“获取buff”这个消息,就可以将buff的创建者、获得buff的实体、buff的id等具体信息,以约定的形式传出,交由策划通过配置表达式的形式生成条件语句。

这套流程除了用在被动技能外,针对战场上的其他触发逻辑同样适用,因为当我们将整个战场都实体化之后,都可以用相似的逻辑进行表达,而无需进行多余的逻辑判断。

buff

buff模块在逻辑部分被分为了两部分:

  • buff实体
  • buff因子

buff作为一个实体,承载了它的生命周期内所有的数据,如生命周期、层数等数据。而buff需要执行的逻辑,就放在了buff因子当中。

在这里需要重点说明buff因子,因为buff因子本身也具有不用的类型,这是由buff可以实现的功能决定的。

通常情况下,buff可以调用通用行为实现指定的效果,但是除此之外,我们又常常将buff用来作为光环效果、生命链等。所以,我们要对buff因子进行概念分类,以更好的协助我们对buff类型进行定义。

从概念上,buff因子分为如下几种类型:

  1. 普通型:在指定的时间点执行指定的逻辑,可以执行一个通用行为,也可以执行某个特殊buff所需要执行的逻辑;
  2. 周期型:在buff存续期间做一个行为,等到buff消失时需要回收之前行为造成的后果,比如可以是添加一条属性,等到buff消失时回收属性;
  3. 回调型:回调型buff专门用于属性计算后处理,即属性输出行为在计算好属性变动值后,此类buff介入对该值进行二次处理,比如护盾buff就是在计算出扣血值后将该值作用于护盾值上,再将护盾值未能抵扣的伤害值返回,得到的就是最终的伤害值。

其中,回调型buff因子虽然只针对属性输出有效,但却是我们战斗中很多逻辑实现的必须手段。

子物体

子物体是通过行为创建的可在场上游走的实体的统称。我们常见的子弹、陷阱等都可以通过子物体实现。

子物体也分为了两部分:

  • 子物体本体
  • 子物体逻辑

从结构上看,子物体与buff的结构很像,但是二者最大的区别就在于子物体有自身的位置数据。仅此一点,子物体本体就多了一堆与位置与移动相关的配置。

再看子物体逻辑,它不像buff因子一般有那么多类型,子物体逻辑只负责在适当的时机选择目标执行通用行为。所以,子物体逻辑需要配置在子物体不同的逻辑触发时机上。

子物体的触发时机分为:

  • 创建时
  • 实体碰撞进入时
  • 实体碰撞驶出时
  • 消失时

这是最基本的四种触发时机,尤其碰撞的两个时机,是基于子物体有位置与形状属性时,遍历其他实体并与之判断之后所产生的结果。

复杂业务逻辑与ECS嵌套开发的设计

围绕复杂业务逻辑的开发与ECS模式的结合,历来是很多人争论的点,因为稍有不慎,开发方式就可能背离我们选取ECS模式的初衷,而使得项目开发复杂且效率低下。

我个人认为,即便选择了ECS模式,我们仍然不可能完全抛弃面向对象的思路。技能、buff这类实体,本身就需要很多的状态数据,使用面向对象的思路可以更好的处理状态运行与切换的逻辑,完全面向过程反而会让开发变得复杂。

在实体与组件层面,单一的一个技能、buff、子物体就是一个实体,依附他们的组件会根据需求有很多,但是核心组件(技能组件、buff组件、子物体组件)内的逻辑部分,却需要我们依据面向对象的思路去设计一个实例。以我项目中的技能为例,CompSkill是一个技能组件,但是这个组件内部却有一个SkillLogic的实例,这个实例就是一个技能的完整结构,并拥有技能应当拥有的BeginEndTick方法。同时我所拥有的SkillSystem,则遍历所有CompSkill,对其内部的SkillLogic执行心跳逻辑。

也许我现在所设想的结构并不完美,还可以有更精炼的方式去实现,但我依旧坚信,“教条主义”并不利于创新,也不利于实际生产。

此外,针对复杂逻辑的设计,也可以充分利用ECS的机制

子物体一节我有说,子物体本身可以有碰撞逻辑,但是这个碰撞逻辑基于拥有位置数据与形状数据的子物体。所以在我的项目中,没有碰撞逻辑的子物体本身不会有形状数据,这样在子物体碰撞系统中便不会加载这些子物体去遍历计算,从侧面也省去了实体遍历的性能开销,而且对系统内逻辑的理解也可以更为纯粹。


以上就是我对这些年所做核心业务逻辑的一个整理,不见得适用所有项目,但应当有一些参考意义。

根据我实际开发的经验来看,逻辑的完全独立对程序理解是件好事,但是对于策划配置数据却并不全然是,所以这个过程中也需要根据实际情况有适当的调整。

下一篇,将开始记录帧同步的内容。

你可能感兴趣的:(基于ECS模型的卡牌战斗框架3)