技术博客

GameplayCue 派发机制:表现层事件与 Ability 的解耦

第 67 / 100 篇 · 2025-08-07 · ue5 · GASGameplayCue源码分析

GAS 里"技能打出去了,特效音效谁来播"这个问题,引擎给出的答案是 GameplayCue:一个挂在 GameplayTag 上的表现层事件。它刻意不管逻辑——能不能放技能是 Ability 的事,放出来长什么样是 Cue 的事。这个切分让策划调表现时不用动玩法代码。基于 UE5.3 源码(Engine/Plugins/Runtime/GameplayAbilities)走一遍派发链路。

从 Tag 到执行:三条入口路径

玩法代码侧永远只调三个函数(AbilitySystemComponent.h):

void ExecuteGameplayCueLocal(const FGameplayTag GameplayCueTag, ...);  // 瞬时
void AddGameplayCueLocal(const FGameplayTag GameplayCueTag, ...);      // 持续开始
void RemoveGameplayCueLocal(const FGameplayTag GameplayCueTag, ...);   // 持续结束

"瞬时/持续"的区分对应特效的生命周期:受击闪光是瞬时,燃烧 debuff 的火焰是持续(开始挂、结束摘)。GameplayCueManager(GameplayCueManager.h)内部把 tag 查成一组 FGameplayCueNotifyData——一个 tag 可以对应多个 Cue 蓝图,"一个事件炸出多种表现"就是这么实现的。查询走 GameplayTag 的层级匹配,所以 GameplayCue.Burn.Fire1 能同时命中注册在 GameplayCue.Burn 上的通用 Cue。

执行时分两条路,由 Cue 资源自身的父类决定:

  • FGameplayCueNotify_Static:不开 actor、不起 component,直接在 GameplayCueManager 里 Execute 回调里播放 Niagara/音效。适合一次性、无状态的特效。
  • FGameplayCueNotify_Actor:生成一个专门的 Cue actor(默认由 GameplayCueNotifyActor 派生),带着持续生命周期,OnExecute/OnActive/OnRemove 三个虚函数对应瞬时/开始/结束。适合要挂点、要跟目标、要中途换材质的表现。

选择的关键权衡:Actor Cue 每次触发是一个 SpawnActor + Tick(如果用 TickCondition 开了 tick),高频触发时 GC 和 actor 数都是压力;Static Cue 零对象开销但拿不到目标引用。我的项目里受击火花(每帧可能十几个)用 Static,目标身上的持续光环用 Actor。

本地与网络:谁负责播

多人游戏里 Cue 的难点不在本地,在于谁有权播。UE 的约定:Cue 默认由每个客户端各自在本地执行——服务端不播,客户端收到"这个 tag 该执行了"的信号后自己在本地放。为什么不服务端播?特效播给"谁看得到"取决于表现逻辑(距离、遮挡、画质档位),这些在服务端算既贵又不准;拆到客户端各自执行,相关性判断就只剩一条"这个 Cue 属不属于你关心的对象"。

所以网络路径上,Cue 不靠属性同步,也不走普通 RPC,而是借 GameplayCueManager 的转发:服务端把 Cue 事件塞进 FGameplayCueTranslator 翻译层,最终作为不可靠的数据沿 actor channel 的专用路径发给各客户端,客户端的 ASC 收到后本地 InvokeGameplayCueEvent。不可靠是故意的——一帧的受击火花丢了,下一帧的动画还在跑,用户几乎察觉不到;做成可靠反而会在丢包时堆积重传,特效延迟比丢失更难看。

本地预测(上一篇讲的 FPredictionKey)同样覆盖 Cue:预测技能命中时,预测端会立即 InvokeGameplayCueEvent 把特效放出来,等服务端确认后事件去重。5.x 里这个去重靠 GameplayCueParameters 里的 PredictionKey 匹配,所以自己封装"播 Cue"的辅助函数时,别把 PredictionKey 丢了,否则预测端会播两遍。

GameplayCue 派发链路:tag 查表,载体二选一,网络各自本地播 玩法代码触发 Execute/Add/Remove + Params GameplayCueManager tag 层级匹配 → NotifyData 列表 InvokeGameplayCueEvent 瞬时 / OnActive / OnRemove Static(无对象开销) Manager 内直接播 Niagara/音效 Actor Cue(有生命周期) SpawnActor → 跟目标 → OnRemove 回收 网络:服务端不播,事件沿不可靠通道发各客户端,各自本地执行 预测端立即播 + PredictionKey 去重;一帧火花丢了就丢了

为什么这个解耦值得学

第一,表现和逻辑独立演化。技能逻辑改命中判定、表现调粒子参数,互不阻塞,策划可以自己建一堆 BP_GameplayCueNotify 资源,不用改 C++。

第二,网络语义被简化成一条约定:每个客户端本地执行。这丢掉了"谁该看到"的集中式判断,换来相关性成本近乎为零。缺点是如果你想做"全房间同步的一次性演出"(比如 boss 大招震屏),Cue 的不可靠语义不够用,得另找可靠通道。

第三,测试友好。showdebug abilitysystem 里能直接看到 ASC 上挂着的活跃 Cue 列表,表现没播出来先查这里——tag 到了没有、Params 对不对,一眼定位是派发问题还是表现资源问题。

实操清单

  • 注册表:DefaultGame.ini 的 [/Script/GameplayAbilities.GameplayCueSet] 段落,或引擎自动扫描的 GameplayCueNotifies 目录;手动加的别忘配。
  • 自定义参数走 FGameplayCueParameters(Location、Normal、SourceObject 等),Static Cue 里拿原始 hit 信息全靠它。
  • 持续 Cue 的清理责任在 ASC 收到 Remove 时,如果表现资源被外部提前销毁(切图、角色死亡),要处理 OnRemove 里目标已失效的分支。
  • 高频伤害技能别让策划随手挂 Actor Cue,Static + Niagara 的 GPU 粒子足够撑大多数火花。