Gameplay Ability. 前文已经提到过是对于流程的编排。它的执行依赖于 Task,异步完成大部分工作。通过委托处理剩余逻辑。
以下是我项目的平A代码。

平A 需要播放蒙太奇,所以这里需要 AT UAbilityTask_PlayMontageAndWait创建一个 MontageTask,将该 Task 完成后的OnCompleted绑定到 OnMontageCompleted 函数(自定义),在该函数中实现后续逻辑。
这里我平A 的 GE 是通过蒙太奇播放过程中的动画通知中去施加的,没有直接写在 Ability 中。
属性
Tags
Ability Tags:Ability 所拥有的 Tag,是用于描述 Ability。比如闪避时,我会加 Ability.Dodge 这个 Tag.
Cancel Ability with Tags:拥有这些 Tag 的技能会在当前技能激活时被取消。
Block Ability with Tags:拥有这些 Tag 的技能在当前技能激活时无法被激活。
Activation Owned Tags:与 Ability Tags 不同,当技能被激活时所有者才会获得这些 Tag。是所有者,而不是 Ability 本身。
Activation Required Tags:所有者激活技能所必须的Tag。
Activation Blocked Tags:所有者激活技能所必须没有的Tag。
以下是通过 FGameplayEvent 触发时,所需的。
Source Required Tags:Gameplay Event 携带 Payload 来激活技能,一般是 Payload 中的 InstigatorTags。
Source Blocked Tags:Payload里包含指定Tag则技能无法被激活。
Target Required Tags:同样使用Payload。但是这里使用Payload结构体中的TargetTags而非InstigatorTags。
Target Blocked Tags:同上规则但是是Blocked。
其他
实例化策列
- NonInstanced:无技能Task,无个体状态和变量,功能受限,但是开销小;能用则用;非实例化的 Ability,总是可以被 Cancel,也总是会 Block 其他的 Ability,无需设置
- Instanced Per Excecution:每次激活都会产生Instance,个体状态在单次激活有效,变量可以被复制;但是不推荐;
- Instanced Per Actor:为每个拥有 Ability 的 Actor 实例化,开销最大,变量可以被复制且状态能在每次激活间保留,大多数功能完整。
复制策略
- Local Predicted:指技能将立即在客户端执行,执行过程中不会询问服务端的正确与否,但是同时服务端也起着决定性的作用,如果服务端的技能执行失败了,客户端的技能也会随之停止并"回滚"。
- Local Only:仅在本地客户端运行的技能。
- Server Initiated:技能将在服务器上启动并PRC到客户端也执行。可以更准确地复制服务器上实际发生的情况,但是客户端会因缺少本地预测而发生短暂延迟。这种延迟相对来说是比较低的,但是相对于Local Predicted来说还是会牺牲一点流畅性。
- Server Only:技能将在只在服务器上运行,客户端不会。服务端的技能被执行后,技能修改的任何变量都将被复制,并会将状态传递给客户端。
重要的类默认变量
Cost GameplayEffect:用于CommitAbility()Ability Triggers:可以用于远程激活技能。选择让所有者获得某个Tag时激活技能,失去它时自动结束技能。Cooldown GameplayEffect:代表冷却时间。它会在技能被确认使用时生效,且在该效果结束前,该技能无法再次使用。
GA 结构
如下图所示,在技能使用的过程中,其实一直都是将 GA 封装在 FGameplayAbilitySpec 中来完成的。

-
GA 的 COD 对象:在蓝图中通过
TSubclassOf<UGameplayAbility>配置,获取到静态数据。在游戏运行时,当FGameplayAbilitySpec构造时,通过GetDefaultObject()获得 CDO 对象 -
Handle: 句柄,当FGameplayAbilitySpec构造时,会调用静态对象自加,确保唯一性 -
GA 的实例化对象:取决于选择的实例化方案,即上文提及的。
-
other info,例如Level,SourceObject等,可以在构造结束后按需设置。

调用流程

让我们了解一下 Ability 的调用流程。
在 Ability 的头文件中有一些 function 专门被标注,称其为重点功能。
CanActivateAbility(), 判断当前能力是否可用。比如需要UI显示时,可以直接调用该函数。
CallActivateAbility(), 是受保护的非虚函数。先执行PreActivate(),然后调用ActivateAbility()
ActivateAbility(), 这个 Ability 的流程编排,也是子类需要去重载的函数。
CommitAbility(), 提交资源/冷却时间等。ActivateAbility() 必须调用这个!
CancelAbility(), 从外部调用,取消 Ability.
EndAbility(), 当 Ability 需要结束时调用。
上述的函数都是 Ability 中的函数,而TryActivateAbility()是在 ASC 中,其作用是尝试激活该 Ability 。调用CanActivateAbility()。还处理每个执行的实例化逻辑以及复制/预测调用。
我们现在已经了解 Ability 相关的重要函数,我们开始尝试理解调用流程。
在 ASC 的 TryActivateAbility(FGameplayAbilitySpecHandle, bAllowRemoteActivation)

可以看到传入的是参数是 FGameplayAbilitySpecHandle即上文提到的 Handle,通过 FindAbilitySpecFromHandle 找到具体的 Spec*,然后做一些逻辑处理,最后调用 InternalTryActivateAbility(...),传入 Handle。
值得一提的是,中间会有一些逻辑分支,会调用到不同的函数,但是最终都会进入 InternalTryActivateAbility(...)中。

这个函数太长了,就不放出来。索性其注释讲解了该函数的具体功能。
- 调用
CanActivateAbility,如果失败,直接返回false - 处理 GA 的实例
- 处理网络同步和预测相关的逻辑(稍后讲解)
- 如果上述全部正常,调用
CallActivateAbility进入能力流程
其次是 CanActivateAbility 是能够激活该能力,该能力是否满足激活条件。也很长,可以自己去看一下源码。
会依次检查下面条件
AvatarActor是否有效 并且 当前网络角色是否允许激活(网络政策)AbilitySystemComponent是否存在Handle是否能找到对应技能- 当前是否全局禁止用户激活技能(比如进入 UI 界面时)
- 技能是否在冷却中
- 如果消耗资源的话是否满足
- Tag 条件是否满足
- 输入是否被屏蔽
- 蓝图里有没有额外拒绝激活
全部满足后开始返回true
接下来是调用 CallActivateAbility(...),该函数非常简答,调用 PreActive() 和 ActivateAbility(...),提供了大体框架,如果需要自定逻辑,可以重载 PreActivate(...) 和 ActivateAbility(...) 函数

对于 PreActivate(...) 函数,大概的操作如下
- 同步客户端和服务器的移动状态(会调用 CMC 组件相关,调用同步,不太理解)
- 根据是否实例化,把技能标记成 Active
- 设置当前技能的上下文信息
- 记录触发事件数据
- 给角色加上激活期间的 Tag
- 注册技能结束回调
- 通知 ASC:技能已经激活
- 应用"阻止/取消其他技能"的规则(关键)

调用 ActivateAbility(...),正式触发技能流程。同时该函数也是我们去重写的最多的函数。
- 如果是有事件数据,并且蓝图实现了从事件激活的流程,则进入该分支
- 如果实现了蓝图激活流程(非事件驱动),则进入该分支
- 如果没有事件数据,但是蓝图实现了从事件激活的流程,则
EndAbility() - 自定义逻辑. 纯 C++ 逻辑。

注意,自己实现的 ActivateAbility 必须调用 CommitAbility(...)用于确实消耗技能资源和更新CD。


其中 CommitExecute、ApplyCooldown 和 ApplyCost 可以重载
在 ActivateAbility(...) 中实现具体逻辑后,可以编写技能逻辑,通过 AbilityTask 去执行,执行完毕后,调用 EndAbility(...)。
EndAblity(...) 详情请看源码,这里依旧简单说说在干什么。
- 检查现在是否允许结束这个技能
- 如果当前有作用域锁,延迟结束。这里是通过委托实现。
- 通知蓝图 Ability 要结束了
- 清掉这个技能相关的定时器、latent Action、委托
- End All Ability Task
- 移除激活期间加上的 Tag 和 GameplayCue
- 解除"可取消/阻挡其他技能"的状态
- 通知 ASC 这个技能真的结束了
而还剩一个 CancelAbility(...). 上文提到过,在 PreActivate(...) 去取消掉阻挡的能力。
调用链路如下所示:

而 CancelAbility(...) 做的事情如下
- 作用域锁,有锁延迟执行
- 同步复制被取消状态
- 广播能力被取消的委托
- 调用
EndAbility(...)

网络预测
预测是为了优化客户端体感,让客户端先运行,同时给服务器发请求,服务器作为权威判断是否驳回。
于是乎,网络预测只有在网络模式为:LocalPredicted 才执行。
LocalOnly:纯本地,不需要服务器确认,不叫网络预测ServerOnly/ServerInitiated:服务器权威执行,客户端不能先本地跑
客户端

- 刷新在该技能激活前发生的服务器移动,以便服务器按正确顺序接收RPC,这对于防止触发动画根运动或影响移动的技能导致网络修正很有必要
FScopedPredictionWindow(this, true)生成新的唯一的PredictionKey,作为技能预测行为关联的标识。- 根据是否有
TriggerEventData调用不同的分支,最终通过RPC调用到Server上的InternalServerTryActivateAbility(...)函数。 - 将之前生成的
PredictionKey绑定委托,当服务器确认或拒绝时调用。 - 客户端进入正常的激活流程。这里即是预测。
服务器

服务器接受到 RPC 后,调用 InternalServerTryActivateAbility(...)
- 通过
Handle判断Spec是否存在,不存在则走失败流程ClientActivateAbilityFailed(...) Spec的Ability是否存在,不存在走失败流程- 看
Ability的网络政策,非预测的政策,走失败流程 - 删除所有缓存的来自客户端的数据。
- 调用
InternalTryActivateAbility(...),失败则进入失败流程。 - 在
InternalTryActivateAbility(...),若成功则会调用ClientActivateAbilitySucceed(...),同时服务端正式执行技能。

对于成功流程,通过 PredictionKey 找到哦 Ability,然后通过通过委托传递确认成功的信息(详细过程较为复杂,这梳理大体流程,详情可以看参考文章和源码)。
流程示意图。

如何设计连招系统?
我自己的项目中是有一套连招系统的,但是设计过于简陋,不太优美。于是在这里再度思考一下如何设计。
连招是指在游戏中快速输入按键指令,主角能够打出一连串流畅招式;当输入有停顿或者触发其他条件时,连招会自动重置。再次重复操作。
对于普通的连招系统一般而言会分为三个功能区。基础区间,重置区间,输入区间。

基础区间。通常情况下我们都可以把攻击动作拆分为三个部分,即攻击前摇、攻击区间以及攻击后摇。在攻击后摇时间段内,判定是否满足进入下一段攻击的状态,如果满足则触发,不满足则正常播放后摇。这样就形成了连招最基本的逻辑。
重置区间。如果不再输入,连招中断,则需要等待后摇完全结束才能进行后续的操作,导致玩家体验极其不好。因此引入重置区间的概念。在攻击后摇区间插入一个重置节点 该节点将攻击后摇分割成了连招输入响应区域以及重置区域。如果在响应区结束,依旧没有输入响应,则后面进入重置区间时,任意输入都可以中断当前的动画播放,并执行新的输入响应。
输入区间。连招允许区间越短,连招操作就越困难。除了攻击窗口后的输入响应区间以外,还可以使用预输入区间来降低操作难度。预输入区间如果有输入响应,等攻击窗口结束后立即判断,能否衔接连招(判定时间取决于具体实现)。如果没有预输入,再走正常的输入响应流程。预输入和输入响应的区别在于是否立即进行判断。
思考这一块功能如何实现?
关于输入,预输入的输入存储在什么地方?或许可存放在专门的 InputComponent 中,又或者存放在 ASC 中。
通过 ANS 可以控制输入状态,在预输入开始时,给 InputComponent 通知,让其之后的输入流程都走预输入处理函数(自定义优先级逻辑,如闪避优先或后覆盖)。在预输入结束时,给 InputComponent 结束通知。
当开始输入响应时,也通过 ANS来进行处理。输入响应开始,给 InputComponet 通知,如果有预输入,将预输入转发到激活技能接口;如果没有预输入,走正常的激活响应逻辑。
关于激活连招,在接收到输入时,判断当前是否是在重置区间(依旧通过ANS控制状态实现)。如果不在重置区间,则衔接连招,如果不是,则重新开始连招。
重点来了,如何设计一套好的优雅的连招系统呢?这是让我十分困扰的点。
在网上找相关的东西,有位 up 视频讲解只狼受击反馈将的非常好,链接后文贴出。
在视频中讲到,fs社设计时,是一套事件判定系统,是状态转移;同时在这个过程中资源与决策分离。具体的可以去看视频。
于是想着如何把这一套系统搬到我这里来。
讲解一下大体思路。
在 Router 层,维护当前战斗链路,并把输入/事件送去申请状态切换。
在 Arbiter 层,经过同一的优先级和条件检查,由全局规则规定,判断这次事件能够导致角色状态转移。
在 NodeAbility 层,只负责当前动作执行逻辑,不关心连招实现。
在 Func/Source 层(可有可无),动作逻辑和动画资源不绑定。
这边以 轻 - 重 连招做一个大体示例,而 重 输入时的招数时特殊招式:
当前处于 轻连招结尾,可以接受重击输入响应的阶段。
InputComponet将Input.Attack.Heavy传入到Router层中。Router层现在所处状态为,CurrentNodeId = Combo.Light.1、bBranchWindowOpen = true、bHitConfirmed = true/false、InputTag = Input.Attack.Heavy和其他上下文信息(如是否命中),从配置表中查询可转移节点。把这些东西打包给Arbiter申请转移,如果允许转移,则Router主动结束当前技能,调用新技能
配置表大概可以分为两个,一是节点表,二是转移规则表。
节点表通常配置 Node 的静态信息,比如节点 id, 对应的节点类,优先级,Montage, tags 之类的
转移表一般是 NodeId 可以转移到的 NodeId们, 转移优先级。
以上感觉算是一个还不错的连招系统。如果有不同意见,欢迎讨论。
参考文章/视频:
https://zhuanlan.zhihu.com/p/440168260
https://www.bilibili.com/video/BV1LH4y1d7zo/?spm_id_from=333.337
https://www.bilibili.com/video/BV1BAhdz2Ewv/?spm_id_from=333.1387