GAS 系统深度学习指南UE 5.4 / 5.5

08 GameplayCues 表现层

目标:掌握"逻辑与表现解耦"的 GAS 设计精髓——Cue 的三种触发时机、Static vs Actor 两种实现与完整代码、GE 自动触发 vs 手动触发、本地 GameplayCue 优化、FGameplayCueParameters 参数体系、GameplayCueManager 按需加载、以及阻止 Cue 触发的手段。


8.1 为什么需要 GameplayCue

多人游戏中,服务端负责逻辑,客户端负责表现。如果表现逻辑(播放特效、音效、屏幕震动)全部写在服务端逻辑里,会有三个问题:

  1. 服务端不应该播放客户端本地特效(谁也看不到、还浪费带宽);
  2. 每个客户端对同一事件的表现可能不同(离得远的不播放、命中自己的高亮);
  3. 表现代码会污染数值/逻辑代码。

GameplayCue(GC)的答案:逻辑层只发一个"事件信号"(带 Tag 和参数),所有客户端自行决定要不要播、怎么播。实现类继承 UGameplayCueNotify_Static 或 UGameplayCueNotify_Actor。

职责边界:GameplayCue 永远不应该修改属性、添加/移除标签或影响任何游戏逻辑——它的唯一职责是"让玩家看到和听到发生了什么"。如果你发现在 Cue 里改游戏状态,请重新考虑架构。

为什么需要分离表现层(四个理由)

  1. 逻辑与表现解耦:服务器处理伤害、属性修改等逻辑,客户端播放视觉/听觉反馈;
  2. 网络优化:表现层通过不可靠 NetMulticast RPC 发送,丢一两个粒子不影响游戏;
  3. 可预测性:客户端可预测性地播放 Cue,提升响应感;
  4. 可维护性:策划可独立调整表现效果,不影响底层逻辑。

不用 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 的网络复制(本资料重点修正)

官方权威结论:

  1. GameplayCue 在所有复制模式(Full/Mixed/Minimal)下,都以不可靠 NetMulticast 广播到所有客户端。Cue 是 GAS 中唯一"全员可见"的表现通道,不受 GE 复制模式影响。

  2. 每个 GameplayCue 事件 = 一次多播 RPC。GAS 限制每个网络帧内相同 Cue 最多发送 2 条 RPC(超过的合并/丢弃),所以高频特效(如连续受击)会触发合并机制。

  3. 监听服务器(Listen Server)的主机玩家,Cue 事件可能触发两次(一次本地、一次多播回环)——处理逻辑要保证幂等(重复播放不叠加效果)。

  4. 来源与目标需一起发送: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 是否双触发,理解幂等处理。

下一章:09 TargetData 目标与瞄准