本文主要是想对目前所学所用的战斗模块进行拆分和梳理,方便加深理解和整理思路。旨在给大家在设计战斗系统的时候提供一个思路和方向,同时详细分析一下目前我对于战斗框架的理解。如果你觉得战斗框架设计没有方向或者想更多的了解战斗整体内容是如何设计的话,可以参考我这篇文章的一些方法和建议。
首先我想先深入探讨下战斗所需要运用的思想。
大家应该都知道帧同步跟状态同步,简单来讲,帧同步就是对玩家输入进行上传并分发,核心的战斗计算逻辑都在客户端进行,而状态同步则是后端计算完成,将结果或者状态分发给玩家,核心逻辑都放置在后端进行。可能有很多同学认为帧同步跟状态同步的战斗逻辑截然不同,因为一个在前端一个在后端,但是其实就战斗逻辑而且基本都是相似的。这里就要引入一个思想——逻辑与表现分离
具体这个思想就如它的名称一样,逻辑与表现解耦,表现依赖于逻辑,但逻辑完全不管表现,逻辑是纯数据驱动的,哪怕没有任何表现部分,逻辑也能正常运转。可以简单的理解为一种设计模式,很多人应该都听说过MVC架构,将战斗的核心逻辑部分理解成Model,将战斗的表现部分理解成View。M和V之间的交互通过事件或者注册接口等方法进行交互。Model并不关心View的任何表现。
OOP,面向对象编程思想,这个想必大家都非常非常熟悉了,小弟我就不在赘述了。
ECS,全称Entity-Component-System,即实体-组件-系统。是一种软件架构模式。我个人的理解是:
E: Entity 一个不代表任何意义的实体(可以理解为Unity里的一个空的GameObject)
C: Component 一个只包含数据的组件(可以理解为Unity的一个自定义组件,里面只有数据,没有任何方法)
S: System 一个用来处理数据的系统(可以理解为Unity的一个自定义组件,里面只有方法,没有任何数据)
其主要特点在于是面向数据编程,利用组合优于继承的思想,关注数据类型,关注系统的运行和状态,不关注具体某个对象的细节。数据与逻辑处理完全解耦。
下面放上一个对比图,方便对比优劣势:
OOP | ECS | |
---|---|---|
性能 | 内存命中率较低,分布散乱,并且在继承上不可避免会有一些冗余的占用 | 内存命中率极高,内存分布紧密,并且支持多线程,为单个System提供单独线程处理 |
耦合度 | 数据与逻辑的耦合不可避免,一个对象中数据处理和逻辑往往集成在一起 | 由于Componet组件只能存放数据,不能实现任何功能相关的函数,而System系统不存放任何数据。数据和逻辑完全解耦。 |
扩展性 | 基于对象类型进行扩展,通过继承跟多态实现不同的扩展需求 | 基于组件进行扩展,只需通过组合的方式,即可实现扩展,不同组合 |
易用性 | 大部分开发都更加熟悉面向对象的思想,并且将逻辑跟数据集中在对象上确实会更便于开发。 | 从面向对象的思维向ECS转变需要一定的学习成本,因为ECS的思考方式和面向对象的思考方式存在着较大的不同。开发速度相对较慢。ECS的低耦合特性来源于规范,规范导致了开发者需要花更多的时间在代码结构的设计上 |
应用环境 | 更适合关注对象的系统去使用,并且如果团队人数不多,或者想敏捷开发的话,建议还是使用传统的OOP即可。 | 更适合关注数据类型,关注系统的运行和状态,不关注具体某个对象的细节。并且可能存在大量同类对象,列入SLG这种类型的游戏,比较适合。 |
至于选用那种思想来设计战斗模块,这个可以根据具体需求来酌情考虑。
前面提到了设计思想跟优点,下面就讲讲具体要怎么去设计和架构。在开始分析之前,我先提一个问题。那就是有没有一种方案可以比较好的这种上述两种思想呢?既可以支持扩展性,降低耦合,又可以一定程度上不那么被ECS的开发规范影响效率。
我这边给出的方案就是接下来我想讨论的,大家应该多少都了解或使用过Unity。Unity编辑器本身其实是一个比较特殊的架构方式,在我们日常业务开发中,GameObject就像是一个实体,而它身上的组件就类比Component。只不过其和ECS的区别在于并没有将数据与逻辑分离,也就是说Unity的Component 其实是带有数据处理的逻辑的,具备各种行为、并且也持有数据。比如每个物体都具有的Transform组件,其自身本身具有位置、缩放、旋转等信息,同时也具备处理逻辑。不同的物体通过持有不同的组件来进行描述。
我认为在战斗架构上也可以采用这种思想,AOP的思想,将组合优于继承的方式加入进来。当然上述只是讨论了具体对于战斗业务的开发怎么架构。
我们还需要将整体架构进行分层:
负责实体的管理,实体的交互逻辑,实体的分类等。一般来说一种功能全局唯一一个系统。
负责管理持有的组件,并且与表现层交互需要依赖实体进行,并且不同实体的组件交互通过事件交互。
数据部分,每个不同的功能行为独立为一个组件,包含数据处理,也是非常重要的部分,并且包含调试逻辑。被实体引用,组件可以热插拔。
网络模块必须独立出来,并且最好是表现与逻辑用两套网络同步层。因为逻辑对于网络底层的需求更复杂,不是单单断线重连那么简单,如果是传统的TCP无法满足帧同步的需求。这里稍微展开聊一下,TCP虽然是可靠传输,但是受限于较长的RTO,以及超时重传机制,并且Nagle算法限制了必须收到确认,才能继续发送下一个分组包。加上Delay Ack机制,会使延迟进一步增加,如果在网络较差的情况下,体验会很差,卡顿感明显。
所以我们选用UDP来作为网络协议,当然也不是不能使用TCP,使用Tcp就得解决上述的几个问题。使用UDP进行传输的话就必须实现可靠传输,delayAck,超时重传,消息序列。这一块后续可以再展开讲讲,主要是是否使用冗余传输。我们项目选用的是KCP,是对TCP进行的一种上层实现,更短的RTO,选择性重传,快速重传(不等RTO),是否开启delayAck,以及是否动态控制窗口。KCP的主要原理也是基于UDP的基础上增加包头,进行控制。
主要是全局的数据,例如帧率,deltaTime,以及一些系统之间共享的数据。并且系统间的交互依赖于此。保证某个系统只维护一份。
因为是纯逻辑,所以也必然需要支持多平台,提供一个接口,不同平台自己实现功能,一般是时间获取,不同平台需要保证时间戳同步,配置或者文件读取接口,不同平台需要读取配置,一般是技能配置关卡配置,还有就是物理相关逻辑
主要是处理用户的输入,记录,整合合并,处理冲突操作。如果是帧同步则需要以帧为单位收集,并且定好网路消息同步频率。
主要是一些配置的读取,处理。技能、关卡、模块功能配置等。
如果说分层是一种思维方式,那么功能就是具体的实现方式了,这一块主要是想分析下,战斗基础模块具体依靠哪些功能来组成。这个部分可能不同类型的游戏会有较大的区别,我只会提到部分游戏共有的那些功能。我认为主体的战斗模块主要分为以下几个部分:
可以把它抽象成一种交互的流程,即实体与实体间交互流程是怎样的,这个可以非常简单,例如玩家控制角色普通攻击敌人,这在数据上其实就表现的非常简单,即攻击造成伤害,这其实就是一种实体间的互动。当然互动也可以非常复杂,例如玩家蓄力一秒后,发出一个气功波飞向敌人,敌人在光波的飞行过程中进行了闪避,避免了正面撞到子弹,但是子弹还是在敌人的旁边炸开并且爆炸伤害到了敌人。并且对不同敌人造成不同的影响等。
这部分拆分出来主要是因为Buff其实是复杂的,基本上所有的对实体产生的影响都需要依赖Buff,你可以把他抽象的理解成一种对实体的影响。这种影响可以是非常简单的,也可以是非常复杂的。例如单纯的减少攻击力,这种Buff非常的简单,又或者是持续5秒的防御力下降并且附带3秒的减速,并且给释放者增加移速。这种就比较复杂了,而且需要考虑同类Buff的叠加和互斥等关系。Buff模块可以看做是一个实体上接受外部影响的入口模块,并且需要进行二次处理,例如合并、替换、叠加、互斥等等,之后通知其他模块进行相应的处理,属性变化需要通知属性模块,状态变化需要通知状态模块。
状态顾名思义就是实体的当前一种现状,常见的状态有空闲,移动,技能释放,死亡,眩晕,击飞等等。那这么多状态怎么去统一管理呢?这个放到后面的具体实现会进行详细的阐述,这里就不展开了。总而言之,这个模块主要是管理状态,并且管理状态的切换等处理。
这个模块一般是对怪物的AI实现,或者托管等自动战斗的情况。具体方式可能根据项目的具体需求来选择,常用的有:有限状态机,行为树。具体解析放在后文进行讲解。
这个模块不是非必要,其实你可以把他附加到状态模块之下,当做玩家的一属性状态也可以。不过我觉得如果是复杂的RPG游戏的话,属性管理还是非常重要的,因为影响属性的地方会非常多,如果不能统一处理好,并且归纳来源的话,追溯变化会非常困难,增加维护成本。
非必要,这块独立出来主要是因为位移是非常非常基础的功能,不管是什么实体都应该会具备位置。这部分主要是一个是进行位移的计算,然后进行寻路的计算。
非必要,目前大多数引擎都有自己的物理实现,但是如果要做帧同步的话,物理模块是需要自行实现的。因为必须保证服务端也能进行计算,并且输出一致。可以根据需求自行实现。
总结:这里列举的模块拆分都是比较基础比较重要的模块,当然并不是每一个模块都是一定需要的,因为战斗本身是一个重业务的逻辑,不同类型的游戏可能需求也大相径庭,所以具体怎么划分还得根据需求来定制,我这边只是提供一个基础的参考供大家一起交流学习。
上面阐述完了具体的设计思想、层级划分、和模块拆分,下面就进入到具体某些功能的实现方式了,这块会谈及到部分比较核心的功能设计与开发,主要是提供一个思路,不一定是最优解。以下是挑选了一些比较特别的模块进行讲解。
我们可以先划分一下目录结构 并且定义三个命名空间,或者程序集。
BattleFrame:可以理解为逻辑层——战斗核心部分,纯粹的逻辑,全部由数据驱动,不能依赖任何表现,非常适合移植服务器,
BattlePerf:可以理解为表现层——战斗表现部分,强依赖BattleFrame,一切表现基于BattleFrame传出,同时也可以依赖于引擎或者业务框架,例如战斗主界面,动画表现依赖引擎,也就是基础的渲染依赖引擎。
BattleBase:可以理解为公共基础层——业务基础部分,BattleFrame 跟 BattlePerf 都可以同时依赖此部分,两种公用的模块可以放在这部分,例如配置加载与读取管理,亦或者逻辑与表现都需要用到的枚举,或者寻路逻辑等,其实也可以理解就是业务模块依赖的基础工具或者功能。
这一块也是比较重要的内容,首先我们基于上面的前提,表现层 BattlePerf单向依赖 BattleFrame,那么表现层可以直接从逻辑层拿数据刷新,但是逻辑层不能直接调用表现层,需要通过事件来解耦,但是需要注意的是,战斗中的事件非常繁多、并且参数复杂,并且之前提到过逻辑与表现交互的话,其实是依赖于实体间的事件。
我们定义一个接口用于处理事件传递,而实体间如何进行事件传递的,此图为逻辑实体与表现实体是如何进行绑定的流程图。而事件的交互则是基于这个绑定关系之上。
通过两个工厂间的交互,可以非常便携的控制逻辑实体和可视化实体的创建和销毁,并且如果需要扩展新的实体对象,只需要在两个工厂中进行注册即可,而且这样设计的好处就是耦合度很低,有一些逻辑实体可能不需要表现,此时只需要切断注册即可完全脱离表现。
那么基于上述的流程,事件交互则会变得十分简单,只需要在可视化实体的下面实现事件接口 IEntityVisual 即可。不过如果细心的同学肯定会问,你这里接口的参数只有事件ID啊,难道不需要传递参数吗?
接下来就讲讲如何传递事件参数:上面提到过战斗的事件参数非常多且复杂,如果不进行管理的话,任由其他模块不停的发送事件,例如实体的位移事件,如果调用逻辑频率不一致,很有可能导致移动变得卡顿,又或者同时又好几个模块都发送位移事件,则有可能后来的事件直接影响了前面的事件,导致表现混乱,或者事件冗余,同一个事件发送多次。那么如何避免这些问题呢?我们这边选择的做法是统一发送频率,并且对事件进行合并处理,并且事件参数也通过对象池管理,避免频繁的创建参数造成GC。
即通过逻辑中的主循环,控制发送频率,逻辑帧一般15帧够用了,每个实体上都有事件缓存列表,每当新的一帧开始后会将前面缓存的事件全部发送出去,如果一帧之内有重复事件,会进行合并操作。
.
技能其实本身就是一个抽象的一个过程,从技能开始到技能结束,中间的流程会很多,很复杂,并且很多是与表现相关联的,一个技能大致上就是从玩家点击释放技能开始,角色进入释放技能状态,开始释放技能,技能有很多类型,瞬发,直接伤害,子弹类型,激光类型等等。并且每个技能的释放时机和结束时机,触发事件时机,伤害点时机可能都各不相同。那么如何做到这么多情况都可以轻松配置和维护呢?这里就得实现一个技能编辑器了,
技能编辑器:你可以把他理解成一个复杂的配置编辑器,供策划大佬们自行去配置技能,并且可以实时看到配置后的技能表现,从而非常方便的扩展技能。而从程序的角度来看,技能编辑器就一是一个庞大的技能配置界面,然后将配置数据存储下来,之后进行读取播放等。
这里就不详细阐述实现方式了,因为功能比较复杂,后续可以单独开一个文章进行详解。大致的实现思路就是,定制各种技能的数据结构:
然后实现编辑器界面,用GUI或者别的插件实现都可以,主要就是需要一个TimeLine,可以供玩家控制动画时长和时间点事件,也可以配置其他各种表现,特效、音效、子弹、Buff添加、伤害点伤害范围等等。这些如果都需要根据时间的话就都需要在TimeLine上进行配置。
策划配置完成后可以保存和修改任意ID的技能配置,并且提交同步给程序即可。当然这些数据肯定是支持序列化的,用二进制或者XML等其他数据类型都可以。需要注意的是如果要放置在服务端跑战斗,帧同步的话,需要一定保证技能数据是服务端可以读取到,并且前后端的技能配置数据一定得保证一致性。这部分其实主要是要根据需求来实现,不同的游戏类型可能会有完全不同的技能编辑器实现,我这里就只介绍一个思路了。
实现了技能编辑器之后,就是正常技能流程了,跟其他模块协作,主要需要注意的就是技能元素结构设计好,能够及时停止并且释放,并且技能数据缓存重用。特别是子弹这种技能元素,肯定是需要对象池的。还需要注意的就是子弹碰撞这块如果是帧同步,需要做好物理碰撞模拟,不能依赖引擎的了,容易出现误差。总的来说技能主要是流程性的设计,本身的实现并不难,但是需要做好扩展,这个很重要。
如果说技能是一个实体对另一个实体造成影响的过程,那么Buff就是这个影响本身了。这个影响可以影响任何一个实体,你可以把它看做成一个影响的抽象。
具体实现:下图为Buff处理的大致流程图
主要流程就是技能模块将BuffID传给对应的目标实体,实体读取Buff配置,创建BuffItem,每次创建Item后都需要统一计算整合一遍当前属性影响,并且刷新一边状态影响,处理状态逻辑,通知状态管理切换状态。
然后根据是否需要多次触发的配置,如果需要,创建BuffDot,这里的Dot可以理解一次Buff数据处理,重新计算一次影响数值,(例如百分比伤害,或者真实伤害,破防伤害,属性伤害计算等等)这个Dot可能是会多次重复触发,也可能只触发一次,这个是根据配置控制的。然后算出一个总的伤害值,通知给受击组件入口,受击再进行后续的属性变化处理。大致的流程就是这样子,需要注意的点有一个就是 注册回调方法时最好是将方法缓存在变量里面,不然会有额外的GC产生。
状态模块主要就是不同状态的切换,我们实现了一套公共的有限状态机(FSM)管理,不与其他任何模块耦合,只对状态管理处理,并且支持不同的状态切换到另一种状态是可配置的,例如眩晕状态下就无法切换至技能状态,只能切换至闲置或者死亡状态。下图为基础类图。
主要的实现思路就是,StateMachine 可以作为一个组件添加给实体,然后存储并管理所有的状态对象,状态对象都继承自 SMBaseState基类。SMBaseState 维护一个转换事件映射表,只有存在此种转换才能进行状态切换,允许转换后,再依次执行BaseState的各种流程方法,子类自行实现相关逻辑。大致的流程就是,先定义好所有状态转换事件和单个状态,每个状态如下图需要添加各自的状态转换事件。
状态转换事件例如进入移动状态、移动状态结束、开始释放技能状态、结束技能释放状态等。状态对象例如移动状态,技能,眩晕等等状态。全都继承自SMBaseState,然后各自实现对应的方法。外部开始通知 StateMachine 切换的状态切换事件,StateMachine 调用当前状态的 OnReason方法开始尝试切换状态,如果当前状态的切换列表中含有此状态切换事件,代表允许切换 ,则开始走切换流程,StateMachine 执行 PerformTransition 方法,先执行旧状态的 OnLeave方法,再执行新状态对象的 OnEnter方法,至此状态切换完成。
这样设计的好处就是尽可能的保证模块内聚,只关注状态切换,并且每个状态易于扩展,只需要派生状态对象和添加对应的状态转换事件即可,并且开放了各种流程接口给状态对象自行处理实现,每个状态都有进入判断——当进入时——更新时——当退出时————清空时 这些时机,尽可能的满足各种状态切换时的独立逻辑。
这个模块也是主要看具体需求,如果项目需求 敌人或者NPC其他等需要AI的实体只是简单的寻敌,移动,攻击,释放技能的话,这种其实简单的状态机,枚举所有可能的AI状态,然后一个大的SwitchCase即可。这个应该在网上都有很多实现了,这里就不展开了。
如果要实现复杂的AI逻辑,那就必不可少的要用到行为树的思想了,我谈谈我对于行为树的理解,其实如果把简单状态机比作,一颗只有一层子树的树,根据条件不停的往这一层上面添加子树,这样势必到后面,这一层子树会非常庞大,并且可能两个子树有一部分判断逻辑其实都是冗余的,但是没法区分它们。而行为树就是在这个基础上,添加更多的子树,也就是分支。这样可以使逻辑结构更加清晰,更模块化。不会被复杂的状态条件绕晕。其实行为树更多的是一种思想,本身并不复杂,大致需要实现的就是:
四种节点类型:Composite(组合节点)、Decorator(修饰节点)、Condition(条件节点)、Action(行为节点)。
三种执行结果:SUCCEED(执行成功)、FAILED(执行失败)、RUNNING(正在执行)。
以及其他:Blackboard(黑板)、Root(行为树根节点)。
具体实现可以去搜其他大佬的文章,很多都解析的很细致了。
再说一说寻路,目前用的最多应该还是A* 寻路了,其他的 BFS、Dijkstra、贪心最佳优先算法。大家应该都比较了解,其实A* 就是结合了Dijkstra算法和 贪心最佳优先算法两者的特点。核心就是A* 的启发函数,也就是如何计算每个点的优先级或者说是权重。这里就不展开了,大家可以去搜一搜A* 的原理解析文章,里面有很详细的原理讲解。
我这里想着重提一下A*的算法优化,我个人总结了以下几个方法,根据性能需求选用即可,没必要为了优化而优化。
在查找OpenList中最优节点时,可以考虑用二叉树排序或快排优化。(这个还可以考虑堆排序,即用小根堆即可快速找到当前最优的节点值。其实还可以 考虑构造最小堆,然后进行插入。或者用二分查找法插入。)
优化启发函数:f( p)= g(p) + w( p) * h( p)
h( p) 表示从指定的方格移动到终点 的预计耗费 (h( p) 有很多计算方法, 比如欧几里得距离)
w( p) = h( p)的系数。
实际是动态加权方法的应用,即实现w( p) 函数———其原则为在搜索开始时,快速到达目的地所在区域更重要;在搜索结束时,得到到达目标的最佳路径更重要。分段函数去动态设置加权值。
寻路规则优化JPS:即将周围的点加入OpenList,该为将拐点加入。整体会比A星更快。
长距离优化:如果是很大的场景,从起点到终点特别远,A星的开销就太大了,考虑空间换时间,即离线寻路+实时计算混合。
拆分寻路区域:即将中间没有障碍的一部分区域独立拆分出来,区域内可直线寻路。区域之间则用A星。
双向A-Star:其实就是同时从起点和终点开始运行 A星寻路,直到找到同一个节点,然后追溯路径相加即可。
采用布兰森汉姆算法预先判断两点是否可以直接通行,可通行就直接返回两点的直线路径,不可直接通行再采用A星算法寻路。
采用 B星 算法,即先朝目标点移动,遇到障碍物再沿着障碍物分出两条分支计算路径。最终选取更快的路径。一定程度上提高效率,如果在没有复杂障碍物的情况下。
A星算法的细节优化:当我们判断一个节点是否在 CloseList中,需要全部遍历一遍。随着CloseList 的增长,消耗越高。可以改为在节点实例中,加上 isClose 布尔变量来标识。但是这样还有一个消耗就是 每次寻路需要初始化 所有节点的 isClose 变量,产生开销。可以改成 用一个 静态全局变量 CurFindIndex ,然后节点类里 也有一个 FindIndex。l两个都是整型,每次开始一次寻路 CurFindIndex + 1。当需要 判断节点是否 isClose 时,只需判断 CurFindIndex 是否等于 FindIndex。当节点加入 CloseList 时,将FindIndex 等于 CurFindIndex。
前面也提到过,属性修改在战斗中是非常频繁的,并且来源可能会特别多。那么关键就是做到保证属性修改可以支持多个模块互不影响,并且支持多种数值计算方式、合并数值等处理。保证安全无误,并且能够快速追溯修改源,方便调试检查。下图是具体架构:
大致的流程如下:ActorData中实例化单个 ActorAttrData,此数据为玩家的所有属性数据,私有只读。然后实例化 ActorAttrImpactMgr列表。这里有一个小优化,一般来说申请一个List即可。但是属性修改一般来说有一些模块基本上影响次数比较少,例如关卡带来的额外加成这种,整局可能就影响一次,而像Buff模块的话,修改的就非常频繁了,可能没一秒或者几秒就会调用修改。一个列表存储的话每次修改频繁的也会需要重算整个列表,所以将一个列表划分成两个,一个是修改较少的列表,另一个则是动态频繁修改的。
说回之前的流程,接下来就是上层其他模块如果需要修改属性的话,实例化一个 ActorAttrImpactMgr,并且将这个对象通过 ActorData的 RegAttrImpact方法注册进去。然后出现修改时通过调用 AddAttrImpact方法,传入数值,之后会实例化一个 ActorAttrImpactData对象,将输入填充进去,然后进行合并操作,合并完成后将新的 ActorAttrImpactData对象添加到 ActorAttrImpactMgr总影响数据列表中。然后外部调用 ActorAttrImpactMgr的设置脏标记方法,然后 ActorData 开始走属性刷新流程,通知所有 ActorAttrImpactMgr 统筹计算所有属性,然后将属性传给 ActorAttrData,根据数值类型走统一的修改方法,这样ActorAttrData的属性就修改了。
还有一个要注意的就是 ActorAttrData需要有多份,一份原始数据,一份不常修改数据,一份动态修改RunTime数据。原始数据主要是一些最大血量最大移速等属性的保留。方便后续去根据此属性计算。
这块其实跟物理模块关联的比较深,涉及碰撞等处理。限于篇幅的原因这块就不详细阐述了,以后有时间的话再继续补充进去。其实主要就是 位移计算、寻路、碰撞检测等等。
个人认为战斗系统其实可大可小,但是一个良好的架构是不可或缺的,初期的工作做的越多,后续扩展的工作则会越少。当然不可能在一开始就做到最完美的设计方案,肯定需要不断迭代和重构的。当然每个项目的业务需求也天差地别,不能保证一个基础架构能够适用于任何项目,毕竟战斗是一个与业务紧密关联的模块,所以按需设计和重构修改也很重要。
以上就是整个战斗系统的主体架构和核心功能的阐述了,可能有一些部分拆解的不是很好,讲的有点乱,如果有不理解的可以评论区留言或者私信我都可以,我尽量解答各位的问题。有可能小生我提及的方案不一定是最好的,如果大佬们有更好的方案也欢迎评论区交流。如果觉得文章对你有帮助的话,可以给小生点个赞支持一下,感谢!