unity战斗系统的实现

战斗模块是网络同步中最棘手的模块,因为它是逻辑分支和数据流转最为复杂的模块,而越复杂的模块,剥离同步数据的难度就越大,这就要求我们要尽量把战斗系统模块化,说来我也是感觉自己非常的幸运,因为从一开始选择的的纯行为树方案,到现在的行为树 + Timeline配合的方案,都在潜移默化的做着模块化这件事情,这让我省了很多重构的功夫

在开始阅读这块内容之前,我想请读者将这两句话作为下文的前提条件:

  • 战斗系统由各个组件组成,每个组件各自负责自己的网络同步,而使用这些组件的系统是不需要关心其内部的同步细节的,直接使用即可

  • 行为树负责负责逻辑切换,Timeline负责线性逻辑推进和表现,二者结合完成技能的制作

组件化的重要性

我们战斗是由许多模块组成的,比如Buff组件,状态组件,数值组件,伤害组件,治疗组件。。。而且很多情况下这些组件会被其他系统所使用

比如我们的普通攻击其实就会和伤害组件和数值组件互动(因为涉及到伤害计算和英雄属性修改),就完全可以让伤害组件与数值组件去负责伤害的网络同步,而不是依赖于普通攻击这个行为去做

再比如状态组件,Buff组件会被行为树使用,也会被碰撞系统所使用,如果把这两个组件的同步工作交给行为树和碰撞系统去做,可不是一件美逝

综上,其实我们战斗系统的同步工作其实分摊给各个组件各自统一进行网络同步工作,这样不管这个组件被多少系统使用,这些使用它的系统都不需要关系其内部的网络同步细节,只管使用即可

基于事件驱动的行为树

我们使用的行为树是事件驱动的行为树,这种类型的行为树有以下几个很卓越的优势:

  • 行为树依据黑板中的键值数据运行,当黑板数据没有任何数据更新时,整颗行为树是可以没有任何性能消耗的
  • 黑板中键值更新后,下一帧行为树才会响应,这保证了行为树状态每帧的独立性和完整性(试想下黑板键值A改变了,如果当前帧B节点立即响应,又将A节点改变回去,那么在帧末尾的时候我们岂不是无法得知这一帧里发生的任何事情?)
  • 纯数据驱动,完美分离了数据和逻辑,我们只需要同步黑板数据即可完成整颗行为树的同步

对于行为树来说,想要将其适用在帧同步里,有三个核心点:

  1. 对于任意一帧而言,一致的黑板内容,都将会得到一致的行为树状态
  2. 涉及 时间 这一概念的内容全部改成帧的概念
  3. 节点本身是否需要回滚

相同输入下的状态一致性

对于第一点,因为我们使用的NPBehave是事件驱动的行为树,所以我们只需要将所有的运行时数据都放在黑板中,就可以自然而然地达成目的

延时节点的帧概念

对于第二点,会重写相当大部分的的行为树底层代码,但这是必要的,考虑这样一个情形:玩家在第100帧按下技能键,客户端立即开始响应,释放技能,播放动画,播放特效。。。但是,由于是客户端先行的,所以不知道当前帧其他玩家的状态,是默认其他玩家什么操作都不做就站在那挨打的,得要等到服务器回包之后(此时假设服务端才跑在95帧)才知道其他玩家做了什么,那么如果其他玩家在第100帧对本地玩家添加了一个异常状态的Buff(眩晕,禁锢,沉默),那么本地玩家这个技能在服务端判定后其实是会释放失败的,但是本地玩家客户端已经跑了一部分行为树逻辑了,所以要涉及到行为树整体状态的回滚,如果这个行为树没有延时节点,那么根据事件驱动行为树特性,我们只需要回滚行为树的黑板数据即可,行为树会自动恢复到正确的状态,但如果这个行为树中恰巧还有一些延时节点,比如释放此技能的第一段后等待1s才能释放第二段,那么这个延时行为,该怎么回滚呢,不管怎么做,这种计时器性质的节点都不好处理,或者说处理起来会搞得很脏,究其原因是它把这个计时状态放在了这个节点的内部,导致不好回滚,这还是节点完全回滚的情况,如果不完全回滚呢?比如一个节点是等待5s,过了3s之后在一个Buff的作用下,CD清零,已经不需要继续等待了,处理起来更麻烦。最终答案也很简单,延时节点的真实时间改成帧的概念,比如这个延时节点是1s,我们Tick FPS为33.33ms,也就是30帧,并且在这个延时节点开始执行的时候,就把它的目标触发帧数存在黑板中,随后每帧都去取这个黑板值与外部传进来的当前帧帧数做对比即可,这样一来,延时节点的问题就迎刃而解了

节点本身是否需要回滚

答案是不需要,而且要做的话也很会很难做

首先明确一点,我们行为树的多帧节点只有一个,那就是延时节点

然后解释一下为什么说行为树做回滚不好做:因为行为树本身并没有一个时间轴的概念,本质上是条件达成就去做某事,这个所谓的条件达成包含时间条件,逻辑条件两种。如果只有时间条件,那就是Timeline,但是行为树出色的逻辑组织能力就是得力于这个逻辑条件,而这个逻辑条件是没有办法做回滚的。考虑这样一个情况,一个单帧节点A在条件B达成的时候时候会执行,然后给玩家添加一个Buff C,如果此时条件B被回滚了,那么这个Buff C也当被回滚(从玩家身上移除),到目前为止还在可以接受的范畴内,那么关键问题来了,我们怎么知道条件B回滚导致的节点A回滚是回滚,而不是正常的逻辑执行呢?正常逻辑也完全有可能重设条件B,也就是说,我们没办法知道条件B回滚会导致哪些节点进行回滚,唯一能做的就是把回滚的责任给到对应的模块去做,比如新添加/移除的Buff需要回滚,那就交给BuffComponent进行回滚,如果新生成/移除的碰撞体需要进行回滚,就交给ColliderManagerComponent进行回滚。。。这样行为树本身只需要根据黑板值的改变做出响应,而不必分心于细碎的回滚需求

总结一下行为树回滚相关解决方案如下:

  • 利用Timeline分担一部分回滚工作,如果是纯行为树方案,对于一个播放动画节点来说,就必须处理这个节点的回滚,但是我们的技能系统是将线性的表现逻辑(播放动画,动画混合,播放特效,播放音效)放在了Timeline中,也就是让Timeline去托管这些行为的回滚,这些对于行为树来说很棘手的回滚问题对于Timeline来说却是再容易不过了(想象一下我们平时用Timeline的时候,来回随意拖动时间轴,每一帧的表现都是正常的,并不会有动画,特效,音效的穿帮问题)。
  • 利用战斗各个模块各自分担回滚工作

实例演示

由于每帧服务端都会发送最新的黑板脏数据到客户端,而我们的行为树是事件驱动的行为树,也就是黑板值的改变会通知行为树的黑板条件节点然后执行相应的逻辑,所以自然而然地可以想到把客户端/服务端会运行的节点通过一个个黑板条件节点做划分,这样同时也就实现了表现和逻辑分离,因为我们的策略是客户端只做表现不做逻辑,可能听起来有点抽象,看下面这个例子:

unity战斗系统的实现_第1张图片

image-20211225122553106

假设客户端当前在100帧,服务器在95帧,此时玩家按下Q键,右侧的子行为树会直接运行,并且根据玩家当前状态做一些检测,检测是否能够释放技能,如果可以就直接播放动画,播放特效,播放音效,这个玩家输入指令将会在100帧被服务端处理

如果服务端判定玩家此次技能释放有效,就进行逻辑层处理,也就是会把左边的Server_PlayerInput同步给客户端,然后客户端的左侧行为树也进行处理(之所以这样做也是因为一些表现行为也是在一些逻辑判定之后才会出现的,比如击中音效,特效,添加Buff等),当然了,既然在服务端做好了碰撞检测等逻辑判断工作,客户端这里就不会再去跑创建碰撞体这个节点的逻辑了,客户端需要处理的就是一些表现上的工作。其实整个技能释放过程对于客户端来说也就分为了两部分,第一部分是完全可以让客户端先行的部分,第二部分是要依靠服务端判定之后结果的部分,需要等到服务器下发黑板脏数据再进行处理,这样就保证了我们整个技能 预测有度

但如果服务端判定玩家此次技能释放无效(可能被眩晕了,沉默了,死亡了等),那么我们上面这个模型就解决不了这个回滚需求了,因为服务器只会同步黑板脏数据不关心具体的黑板运行状态,所以我们要保证能根据服务端发回的黑板脏数据让客户端得到正确的表现。在这个例子中,我们要让客户端的玩家不再处于释放技能的状态,所以多设计一个黑板条件节点,专门用于处理这个技能释放状态(也就是下图中的中间那颗子树),当服务器判定技能无法进行时就不会让玩家进入技能释放状态,即IsInExcutingSkill这个黑板键值不会被改变,所以客户端这边会因为对比服务器黑板值与本地预测不一致而做黑板值的重设,从而达到让玩家退出技能释放状态的目的

当然在这里多放中间那个黑板条件节点可能显得有些多余,我们似乎完全可以直接修改Client_PlayerInput键值来强行让客户端玩家退出技能释放状态,但是请考虑这种情况,技能已经释放了成功了,处于吟唱阶段,可以被打断,那么这个打断行为其实也可以算作打断“技能释放状态”,这样在异常状态打断技能施法时只需要传递修改IsInExcutingSkill这个黑板键值,行为树就能做出正确的响应,而不必考虑修改玩家输入

unity战斗系统的实现_第2张图片

image-20211225140828797

最后,可能有的读者已经注意到了,我们这种方案,其实每次释放技能一定会涉及到行为树的回滚的,因为服务端回包永远落后于客户端帧数,这样会不会有卡顿和拉扯感呢?其实这个担心是多余的,还是那句话,我们是事件驱动的行为树,依据黑板键值变化来推动逻辑的执行。在这个例子中,假设客户端在105帧收到了服务端100帧的消息,它里面的脏数据为

CSHARP

1
2
3
Client_PlayerInput : Q
Server_PlayerInput : Q
IsInExcutingSkill : true

但是由于我们客户端本地先行的时候已经将Client_PlayerInput,IsInExcutingSkill设置为Q和true了,所以其实我们在回滚到100帧的时候,只会执行Server_PlayerInput里的内容(里面可能是一些重要状态和Buff的添加),而不会重复执行中间和右边的行为树

跨行为树实例的黑板赋值

考虑这样一种情况,目前客户端和服务端同时运行有行为树A,B。A的某个分支条件触发后,会修改B的黑板值。

假设客户端处于30帧,服务端处于28帧

服务端的变化过程

  1. 28帧:在服务端的A行为树黑板值已经发生变化,所以会往客户端发送命令
  2. 29帧,正式执行A目标分支的逻辑,修改B的黑板值,B的黑板值改变,回望客户端发送消息

客户端的变化过程

  1. 32帧,收到服务端28帧行为树A黑板值改变事件,回滚到28帧进行处理,设置行为树A的黑板值,然后进行追帧,运行到29帧时,正式执行A的分支逻辑,设置B的黑板值,运行到30帧时,正式执行B的分支逻辑,31,32帧都正常追帧逻辑
  2. 33帧,收到服务端29帧行为树B黑板值改变事件,正式执行A目标分支的逻辑,修改B的黑板值 TODO

Timeline

在之前的一篇 ParadoxNotion-Slate学习笔记与拓展计划 已经提到过了所有Timeline相关注意点,直接移步查看即可

文章作者: 烟雨迷离半世殇

文章链接: 基于行为树的MOBA技能系统:基于状态帧的战斗,技能编辑器与录像回放系统开发手札 | 登峰造极者,殊途亦同归。

版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 登峰造极者,殊途亦同归。!

你可能感兴趣的:(unity,游戏引擎,战斗)