08 GameplayCues 表现层
目标:掌握"逻辑与表现解耦"的 GAS 设计精髓——Cue 的三种触发时机、Static vs Actor 两种实现与完整代码、GE 自动触发 vs 手动触发、本地 GameplayCue 优化、FGameplayCueParameters 参数体系、GameplayCueManager 按需加载、以及阻止 Cue 触发的手段。
8.1 为什么需要 GameplayCue
多人游戏中,服务端负责逻辑,客户端负责表现。如果表现逻辑(播放特效、音效、屏幕震动)全部写在服务端逻辑里,会有三个问题:
- 服务端不应该播放客户端本地特效(谁也看不到、还浪费带宽);
- 每个客户端对同一事件的表现可能不同(离得远的不播放、命中自己的高亮);
- 表现代码会污染数值/逻辑代码。
GameplayCue(GC)的答案:逻辑层只发一个"事件信号"(带 Tag 和参数),所有客户端自行决定要不要播、怎么播。实现类继承 UGameplayCueNotify_Static 或 UGameplayCueNotify_Actor。
职责边界:GameplayCue 永远不应该修改属性、添加/移除标签或影响任何游戏逻辑——它的唯一职责是"让玩家看到和听到发生了什么"。如果你发现在 Cue 里改游戏状态,请重新考虑架构。
为什么需要分离表现层(四个理由)
- 逻辑与表现解耦:服务器处理伤害、属性修改等逻辑,客户端播放视觉/听觉反馈;
- 网络优化:表现层通过不可靠 NetMulticast RPC 发送,丢一两个粒子不影响游戏;
- 可预测性:客户端可预测性地播放 Cue,提升响应感;
- 可维护性:策划可独立调整表现效果,不影响底层逻辑。
不用 Cue 会怎样:你需要在 GA/GE 里直接 Spawn 粒子和播音效,导致逻辑代码膨胀、网络同步复杂、策划难以独立调整。
8.2 Cue 的 Tag 命名
Cue 的 GameplayTag 必须以 GameplayCue. 开头,否则不被 GameplayCueManager 识别:
GameplayCue.Hit.Fire // 火焰命中效果
GameplayCue.Hit.Ice // 冰冻命中效果
GameplayCue.Status.Burn // 持续燃烧效果
GameplayCue.Status.Stun // 眩晕效果
GameplayCue.Ability.Meteor // 陨石术效果
GameplayCue.Environment.Rain // 环境下雨效果
在 C++ 属性上用 Meta = (Categories = "GameplayCue") 过滤标签选择器:
UPROPERTY(BlueprintReadOnly, EditAnywhere, Meta = (Categories = "GameplayCue"))
FGameplayTag GameplayCueTag;
8.3 三种触发时机
| 事件 | 触发时机 | 典型实现回调 |
|---|---|---|
| Execute | 事件发生的瞬间(Instant 效果、命中、受伤) | OnExecute() |
| Add / WhileActive | 效果开始生效时(Duration/Infinite GE 施加) | OnActive()(WhileActive() 持续执行) |
| Remove | 效果移除/到期时 | OnRemove() |
示例:一个 5 秒减速 Debuff 的效果:
施加 GE → OnActive(飘字"减速!"+ 变蓝)
持续中 → WhileActive(每帧/每段时间更新)
到期 → OnRemove(恢复正常颜色)
GE 持续类型 → Cue 事件映射
| GE 持续类型 | GameplayCue 事件 | 说明 |
|---|---|---|
| Instant | Execute | 一次性效果,如即时伤害 |
| Duration | Add + Remove | 持续期间显示效果,移除时清理 |
| Infinite | Add + Remove | 同上,但效果不自动过期 |
| Periodic | Execute(每次 Tick) | 每次周期触发执行一次 |
8.4 两种实现类型
| 类型 | 生命周期 | 响应事件 | 适用 GE | 典型效果 | 性能 |
|---|---|---|---|---|---|
UGameplayCueNotify_Static |
无实例(CDO 运行) | Execute | Instant / Periodic | 命中火花、弹孔、暴击闪光 | 极低 |
UGameplayCueNotify_Actor |
每个事件生成 Actor 实例 | OnActive / WhileActive / Removed | Duration / Infinite | 冲刺线条、眩晕星星、燃烧火焰 | 较高 |
选择原则:能用 Static 就不用 Actor(Actor 有生成开销);需要"存活跟随/计时/更新"才用 Actor。
Static Cue 完整实现(OnExecute)
UCLASS()
class MYGAME_API UGC_HitImpact : public UGameplayCueNotify_Static
{
GENERATED_BODY()
public:
UGC_HitImpact()
{
GameplayCueTag = FGameplayTag::RequestGameplayTag(FName("GameplayCue.Hit.Impact"));
}
virtual bool OnExecute_Implementation(
AActor* Target, const FGameplayCueParameters& Parameters) const override
{
const FHitResult* HitResult = Parameters.EffectContext.GetHitResult();
if (HitResult && HitParticle)
{
UGameplayStatics::SpawnEmitterAtLocation(
Target->GetWorld(), HitParticle,
HitResult->ImpactPoint, HitResult->ImpactNormal.Rotation(), true);
}
if (HitSound)
{
UGameplayStatics::PlaySoundAtLocation(Target->GetWorld(), HitSound, Parameters.Location);
}
return false; // 返回 false 表示不阻止后续处理
}
protected:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly) UParticleSystem* HitParticle;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly) USoundBase* HitSound;
};
Actor Cue 完整实现(OnActive / WhileActive / OnRemove)
UCLASS()
class MYGAME_API AGC_BurnEffect : public AGameplayCueNotify_Actor
{
GENERATED_BODY()
public:
AGC_BurnEffect()
{
GameplayCueTag = FGameplayTag::RequestGameplayTag(FName("GameplayCue.Status.Burn"));
bAutoDestroyOnRemove = true; // 必须:OnRemove 后自动销毁
PrimaryActorTick.bCanEverTick = false; // 默认事件驱动,不 Tick
}
virtual bool OnActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
// 在角色身上附加燃烧粒子
if (BurnParticle)
BurnParticleComponent = UGameplayStatics::SpawnEmitterAttached(
BurnParticle, Target->GetRootComponent(), NAME_None,
FVector::ZeroVector, FRotator::ZeroRotator,
EAttachLocation::SnapToTarget, true);
if (BurnSound)
BurnAudioComponent = UGameplayStatics::SpawnSoundAttached(BurnSound, Target->GetRootComponent());
return false;
}
virtual bool WhileActive_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
// WhileActive 用于晚加入的玩家:若粒子还没创建(晚加入),在这里补建
if (!BurnParticleComponent && BurnParticle)
BurnParticleComponent = UGameplayStatics::SpawnEmitterAttached(
BurnParticle, Target->GetRootComponent(), NAME_None,
FVector::ZeroVector, FRotator::ZeroRotator,
EAttachLocation::SnapToTarget, true);
return false;
}
virtual bool OnRemove_Implementation(AActor* Target, const FGameplayCueParameters& Parameters) override
{
if (BurnParticleComponent) BurnParticleComponent->DeactivateSystem();
if (BurnAudioComponent) BurnAudioComponent->Stop();
return false;
}
protected:
UPROPERTY(VisibleAnywhere) UParticleSystemComponent* BurnParticleComponent;
UPROPERTY(VisibleAnywhere) UAudioComponent* BurnAudioComponent;
UPROPERTY(EditDefaultsOnly) UParticleSystem* BurnParticle;
UPROPERTY(EditDefaultsOnly) USoundBase* BurnSound;
};
常见陷阱:使用
GameplayCueNotify_Actor必须勾选Auto Destroy on Remove(bAutoDestroyOnRemove = true),否则后续对同一 CueTag 的 Add 调用无法正常工作。WhileActive专门用于处理晚加入的玩家——他们错过了 OnActive,需要在 WhileActive 里补建表现。
8.5 触发 Cue 的两种方式
方式一:通过 GE 自动触发
GE 上配置的 GameplayCue 标签在 GE 成功应用(未被标签/免疫阻挡)时自动触发,按 GE 持续类型映射到对应事件(见 8.3 表)。
GameplayEffect Blueprint → GameplayCues → Add
GameplayCue Tag: GameplayCue.Status.Burn
Magnitude Attribute:(可选)选一个属性用于传递数值
方式二:通过 GA / ASC 手动触发
// ASC 上的函数
void ExecuteGameplayCue(const FGameplayTag CueTag, FGameplayEffectContextHandle Ctx);
void ExecuteGameplayCue(const FGameplayTag CueTag, const FGameplayCueParameters& Params);
void AddGameplayCue(const FGameplayTag CueTag, ...); // Duration/Infinite
void RemoveGameplayCue(const FGameplayTag CueTag);
void RemoveAllGameplayCues();
// GA 内(或蓝图节点 K2_ExecuteGameplayCue / K2_AddGameplayCue / K2_RemoveGameplayCue)
ASC->ExecuteGameplayCue(FGameplayTag::RequestGameplayTag(FName("GameplayCue.Damage.Fire")), CueParams);
手动触发适用:没有 GE 也要播表现的情况——动画蒙太奇触发的特效、投射物爆炸特效等。不必为每个表现都建 GE。
8.6 本地 GameplayCue(重要优化)
问题:默认触发会通过 NetMulticast RPC 网络复制,每次触发一次广播;高频场景开销大。GAS 还限制每个网络更新中同一 Cue RPC 最多发送两次。
本地 GameplayCue:只在当前客户端执行,不通过网络发送。适用于每个客户端都能本地检测到的场景:
- 投射物命中(本地碰撞检测);
- 近战碰撞(动画驱动本地检测);
- 动画触发的特效(蒙太奇 Notify)。
实现:在自定义 ASC 中添加本地函数,直接调用 GameplayCueManager:
UFUNCTION(BlueprintCallable, Category = "GameplayCue",
Meta = (AutoCreateRefTerm = "GameplayCueParameters", GameplayTagFilter = "GameplayCue"))
void ExecuteGameplayCueLocal(const FGameplayTag CueTag, const FGameplayCueParameters& Params);
// AddGameplayCueLocal / RemoveGameplayCueLocal 同理
void UMyAbilitySystemComponent::ExecuteGameplayCueLocal(
const FGameplayTag CueTag, const FGameplayCueParameters& Params)
{
UAbilitySystemGlobals::Get().GetGameplayCueManager()->HandleGameplayCue(
GetOwner(), CueTag, EGameplayCueEvent::Type::Executed, Params);
}
void UMyAbilitySystemComponent::AddGameplayCueLocal(
const FGameplayTag CueTag, const FGameplayCueParameters& Params)
{
UAbilitySystemGlobals::Get().GetGameplayCueManager()->HandleGameplayCue(
GetOwner(), CueTag, EGameplayCueEvent::Type::OnActive, Params);
UAbilitySystemGlobals::Get().GetGameplayCueManager()->HandleGameplayCue(
GetOwner(), CueTag, EGameplayCueEvent::Type::WhileActive, Params);
}
void UMyAbilitySystemComponent::RemoveGameplayCueLocal(
const FGameplayTag CueTag, const FGameplayCueParameters& Params)
{
UAbilitySystemGlobals::Get().GetGameplayCueManager()->HandleGameplayCue(
GetOwner(), CueTag, EGameplayCueEvent::Type::Removed, Params);
}
关键:本地 Add 的 Cue 必须本地 Remove;网络 Add 的网络 Remove。混合使用会导致效果"卡住"不消失。
8.7 FGameplayCueParameters 参数体系
GE 触发时自动填充的字段:
| 字段 | 说明 |
|---|---|
AggregatedSourceTags |
来源所有聚合标签 |
AggregatedTargetTags |
目标所有聚合标签 |
GameplayEffectLevel |
GE 等级 |
AbilityLevel |
触发 GA 等级 |
EffectContext |
命中结果、来源 Actor 等 |
Magnitude |
GE 配置的 Cue 数值属性 |
手动触发时自行构建:
FGameplayCueParameters CueParams;
CueParams.Location = HitLocation; // 播放位置
CueParams.Normal = HitNormal; // 命中法线(粒子朝向)
CueParams.EffectContext = EffectContext; // 含 HitResult
CueParams.RawMagnitude = DamageValue; // 伤害量(飘字)
CueParams.SourceObject = MyCustomDataObject; // 传任意 UObject
子类化 EffectContext 传递更多数据:可在 Cue 中读取暴击、伤害类型等自定义字段(子类化步骤见第 05 章 5.15):
bool UGC_HitImpact::OnExecute_Implementation(AActor* Target,
const FGameplayCueParameters& Parameters) const
{
const FMyGameplayEffectContext* MyContext =
static_cast<const FMyGameplayEffectContext*>(Parameters.EffectContext.Get());
if (MyContext && MyContext->bIsCriticalHit)
SpawnCriticalHitEffect(Parameters.Location);
return false;
}
8.8 GameplayCueManager 与资源加载
UGameplayCueManager 管理所有 Cue。默认行为是启动时扫描整个项目目录并全部加载到内存——在大型游戏(Paragon)可能加载数百 MB 不需要的资源,导致启动卡顿。
按需异步加载(推荐):
- 重写
ShouldAsyncLoadRuntimeObjectLibraries()返回false禁用启动全量加载; - 首次触发某 Cue 时才加载(SSD 上延迟几乎可忽略)。
UCLASS()
class UMyGameplayCueManager : public UGameplayCueManager
{
GENERATED_BODY()
public:
virtual bool ShouldAsyncLoadRuntimeObjectLibraries() const override { return false; }
};
; DefaultGame.ini
[/Script/GameplayAbilities.AbilitySystemGlobals]
GlobalGameplayCueManagerClass="/Script/MyGame.UMyGameplayCueManager"
; 也可限定扫描路径,而不是整个项目:
GameplayCueNotifyPaths="/Game/MyGame/GameplayCues"
常见陷阱:按需加载时编辑器里首次加载 Cue 可能有轻微卡顿(粒子系统编译),打包版本无此问题。
8.9 阻止 GameplayCue 触发
在 ExecCalc 中阻止(按需手动处理)
void UMyDamageExecCalc::Execute_Implementation(...)
{
if (bWasBlocked)
{
OutExecutionOutput.MarkGameplayCuesHandledManually(); // 阻止默认 Cue
// 手动触发格挡特效...
}
else
{
OutExecutionOutput.AddOutputModifier(...); // 正常输出,自动触发关联 Cue
}
}
全局抑制
AbilitySystemComponent->bSuppressGameplayCues = true; // 该 ASC 完全不触发任何 Cue
AI 控制的角色特别有用——服务器端 AI 不需要播放表现效果。
8.10 Cue 的网络复制(本资料重点修正)
官方权威结论:
-
GameplayCue 在所有复制模式(Full/Mixed/Minimal)下,都以不可靠 NetMulticast 广播到所有客户端。Cue 是 GAS 中唯一"全员可见"的表现通道,不受 GE 复制模式影响。
-
每个 GameplayCue 事件 = 一次多播 RPC。GAS 限制每个网络帧内相同 Cue 最多发送 2 条 RPC(超过的合并/丢弃),所以高频特效(如连续受击)会触发合并机制。
-
监听服务器(Listen Server)的主机玩家,Cue 事件可能触发两次(一次本地、一次多播回环)——处理逻辑要保证幂等(重复播放不叠加效果)。
-
来源与目标需一起发送:Cue 事件必须带上"谁触发、打到谁",客户端据此判断在自己附近才播放(
Parameters.AggregatedSourceTags/Target)。
旧资料中网络矩阵把"GameplayCue(Static) 在 Minimal/Mixed 下标记为不同步"是错误的。Cue 在所有模式下都会多播给所有客户端。
8.11 本章验证
- [ ] 做一个 Static Cue:火球命中生成爆炸特效 + 音效 + 伤害飘字(读 HitResult);
- [ ] 做一个 Actor Cue:5 秒减速 Buff 的持续光环(OnActive/WhileActive/OnRemove 完整走一遍,含晚加入补建);
- [ ] 在 GA 里手动触发 Cue(
K2_ExecuteGameplayCue),并填好 Parameters 位置/数值; - [ ] 用
ExecuteGameplayCueLocal做投射物本地命中特效,观察无网络广播; - [ ] 配置自定义 GameplayCueManager 按需加载并验证;
- [ ] 在 ExecCalc 中
MarkGameplayCuesHandledManually()做格挡分支; - [ ] 在 Listen Server 双人联机下观察主机 Cue 是否双触发,理解幂等处理。