【UE源码精读-ActionRPG】GAS基础:能力与效果容器

TOC

GAS(GameplayAbilities)最难啃的一关,不是属性系统,也不是网络复制,而是**"效果是如何从一个攻击动作流到目标身上的"**这条隐形管线。ActionRPG 在 C++ 里做了一个优雅的抽象:EffectContainerMap------把"这个能力在哪种时机、对哪些目标、施加哪些效果"统一配置进一张 TMap 里,留给设计师在蓝图里配置,留给程序在运行时查表执行。本文精读这套机制的数据结构和两个核心方法。


前置知识:GAS 五大件速览

在进入 ActionRPG 的具体实现前,先用一张表把 GAS 的五大核心组件和它们之间的关系交代清楚:

组件 全称 职责 在 ActionRPG 中
ASC AbilitySystemComponent 整个 GAS 的中枢组件,管理 GA/GE/AS 的增删查改 URPGAbilitySystemComponent,挂在角色上
GA GameplayAbility 一个"行为"------攻击、使用药水、翻滚、施法都是 GA URPGGameplayAbility,本文主角
GE GameplayEffect 对属性的修改------扣血、加速、回蓝都是 GE。GE 是修改 Attribute 的唯一正规渠道 伤害 GE、被动属性初始化 GE 等
AS AttributeSet 属性集合------Health/Mana/AttackPower 等 float 属性的容器 URPGAttributeSet(上一篇精读过)
GC GameplayCue 特效/音效等纯表现层,不影响逻辑 ActionRPG 未使用 GC

它们的协作关系可以用一句话概括:GA 通过 Apply GE 来修改 AS 上的 Attribute。 ASC 是管理这一切的中枢。

另一个必须预先理解的概念是 Class → Spec 模式 :GAS 里的 GA、GE 在使用前都有一个"类 → 实例"的过程。UGameplayAbilityUGameplayEffect 都是 (蓝图资产或 C++ 类),是静态的数据模板;运行时会生成对应的 SpecFGameplayAbilitySpec / FGameplayEffectSpec),里面填入具体的上下文信息(发起者是谁、等级多少)。ApplyGE 操作的对象从来不是 GE 类本身,而是从 GE 类创建出来的 GE Spec。理解了这一点,后面的 MakeOutgoingGameplayEffectSpecMakeEffectContainerSpec 就完全不神秘了。

GE 的三种持续类型

GE 按 Duration Policy 分三种,直接影响它修改的是 BaseValue 还是 CurrentValue:

Duration Policy 效果 修改的值 典型场景
Instant 瞬时执行,一帧完成 BaseValue(永久改变) 扣血、治疗、经验值结算
Has Duration 持续指定时长后自动移除 CurrentValue(临时叠加) 5 秒加速、10 秒减防
Infinite 永久生效,只能手动移除 CurrentValue(临时叠加) 按住跑步时的移速加成

关键点 :只有 Instant 类型 的 GE 会触发 PostGameplayEffectExecute(属性系统精读和伤害管线都在讲这个回调),因为它改的是 BaseValue。Duration/Infinite 类型改的是 CurrentValue,触发的是 PreAttributeChange。ActionRPG 的伤害 GE 是 Instant 类型------这就是为什么伤害管线最终走到 PostGameplayEffectExecute 里扣血。


一、从蓝图配置说起

在编辑器里打开任意一个近战能力蓝图(如 GA_Sword、GA_Axe),可以看到它的 Details 面板里有一个 EffectContainerMap

再往里点开一个近战能力,看到 GA_Melee_Base 里的 EventActivateAbility 实现:

蓝图里调用的 MakeEffectContainerSpecApplyEffectContainerSpec 就是本文要剖析的核心。


二、两个核心数据结构

2.1 FRPGGameplayEffectContainer:静态模板

cpp 复制代码
USTRUCT(BlueprintType)
struct FRPGGameplayEffectContainer
{
    GENERATED_BODY()
public:
    /** Sets the way that targeting happens */
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = GameplayEffectContainer)
    TSubclassOf<URPGTargetType> TargetType;

    /** List of gameplay effects to apply to the targets */
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = GameplayEffectContainer)
    TArray<TSubclassOf<UGameplayEffect>> TargetGameplayEffectClasses;
};

它是静态配置 ,在蓝图或数据资产的 Details 面板里填好、存盘------"当这个容器被触发时,用 TargetType 选目标,对目标施加 TargetGameplayEffectClasses 里的所有效果"。关键点:

  • TargetType 是个类引用(TSubclassOf),不是对象------运行时才通过 GetDefaultObject() 拿实例。这样这个 struct 不持有任何运行时状态,可以在磁盘上序列化成数据资产。
  • TargetGameplayEffectClasses 是一组 GE 类,一次命中可以同时施加多个效果(伤害 + 减速 + 火焰)。

2.2 FRPGGameplayEffectContainerSpec:运行时实例

cpp 复制代码
USTRUCT(BlueprintType)
struct FRPGGameplayEffectContainerSpec
{
    GENERATED_BODY()
public:
    /** Computed target data */
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = GameplayEffectContainer)
    FGameplayAbilityTargetDataHandle TargetData;

    /** List of gameplay effects to apply to the targets */
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = GameplayEffectContainer)
    TArray<FGameplayEffectSpecHandle> TargetGameplayEffectSpecs;

    bool HasValidEffects() const;
    bool HasValidTargets() const;
    void AddTargets(const TArray<FHitResult>& HitResults, const TArray<AActor*>& TargetActors);
};

它是 FRPGGameplayEffectContainer运行时展开版

  • TargetData 不再是"目标类型"的类引用,而是已经运行过目标逻辑后得到的实际目标列表(HitResult 列表或 Actor 列表)。
  • TargetGameplayEffectSpecs 不再是"GE 类引用列表",而是已经填好发起者/能力等级等上下文的 FGameplayEffectSpecHandle 列表------这些 Spec 随时可以被施加。

用表格对比两者:

FRPGGameplayEffectContainer FRPGGameplayEffectContainerSpec
性质 静态配置(蓝图里填) 运行时实例
目标 TSubclassOf<URPGTargetType>(类引用) FGameplayAbilityTargetDataHandle(已计算结果)
效果 TArray<TSubclassOf<UGameplayEffect>>(类引用) TArray<FGameplayEffectSpecHandle>(已实例化 Spec)
生命周期 磁盘序列化,常驻内存 运行时临时创建,用完丢弃

2.3 为什么要分两步

原因只有一个:时机不同

MakeSpec(建 Spec)的时机是能力激活时 :此时能知道发起者是谁、能力等级是多少,可以把这些信息烘进 FGameplayEffectSpec。但目标可能还不知道------攻击动画还没到命中帧,还不知道打中谁了。

ApplySpec(施加效果)的时机是命中帧 :动画 AnimNotify 触发 GameplayEvent,AbilityTask 收到事件,此时才有目标信息,把目标填进去,再施加效果。

这就是"分两步"的本质:把"和发起者相关的信息"(能力等级、来源对象)和"和目标相关的信息"(目标 Actor、HitResult)的填充分开,各在恰当时机完成。


三、URPGGameplayAbility:EffectContainerMap

URPGGameplayAbility 继承 UGameplayAbility,整个类就两件事:声明 EffectContainerMap,提供两对方法。

cpp 复制代码
UCLASS()
class ACTIONRPG_API URPGGameplayAbility : public UGameplayAbility
{
    GENERATED_BODY()
public:
    URPGGameplayAbility();

    /** Map of gameplay tags to gameplay effect containers */
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = GameplayEffects)
    TMap<FGameplayTag, FRPGGameplayEffectContainer> EffectContainerMap;

    virtual FRPGGameplayEffectContainerSpec MakeEffectContainerSpecFromContainer(...);
    virtual FRPGGameplayEffectContainerSpec MakeEffectContainerSpec(FGameplayTag ContainerTag, ...);
    virtual TArray<FActiveGameplayEffectHandle> ApplyEffectContainerSpec(...);
    virtual TArray<FActiveGameplayEffectHandle> ApplyEffectContainer(FGameplayTag ContainerTag, ...);
};

EffectContainerMap 是核心:FGameplayTag → FRPGGameplayEffectContainer 的映射。设计师在蓝图里配一个近战攻击能力时,可以这样填:

bash 复制代码
"Event.Montage.Attack.Heavy" → Container{ TargetType: UseEventData, Effects: [GE_HeavyMeleeDamage] }
"Event.Montage.Attack.Light" → Container{ TargetType: UseEventData, Effects: [GE_LightMeleeDamage] }

不同的 AnimNotify 触发不同的 GameplayEvent(不同的 Tag),能力就知道该套用哪个 Container。一个能力 BP 可以配置多套不同强度、不同效果的命中逻辑,完全靠标签区分。

GA 的两种调用方式:主动与被动

值得补充的是,GAS 的 GA 有两种激活方式,ActionRPG 都有用到:

主动调用 :代码里显式调用 TryActivateAbility(by Handle)或 TryActivateAbilitiesByTag(by Tag)。ActionRPG 里玩家按攻击键 → ActivateAbilitiesWithItemSlotTryActivateAbility 就是主动调用。

被动调用(通过 GameplayEvent) :在 GA 蓝图的 Details 面板里设置 Trigger,当 ASC 收到一个匹配 Tag 的 GameplayEvent 时自动激活该 GA。这种方式的好处是可以通过 GameplayEventData 传递额外数据(如碰撞信息)。ActionRPG 的 GA_Melee_Base 用的就是事件驱动激活------它在蓝图里用 ActivateAbilityFromEvent 替代了普通的 ActivateAbility,这样就能直接从 EventData 里拿到 HitResult,传给 MakeEffectContainerSpec


四、MakeEffectContainerSpecFromContainer:建立 Spec

cpp 复制代码
FRPGGameplayEffectContainerSpec URPGGameplayAbility::MakeEffectContainerSpecFromContainer(
    const FRPGGameplayEffectContainer& Container,
    const FGameplayEventData& EventData,
    int32 OverrideGameplayLevel)
{
    FRPGGameplayEffectContainerSpec ReturnSpec;
    AActor* OwningActor = GetOwningActorFromActorInfo();
    ARPGCharacterBase* OwningCharacter = Cast<ARPGCharacterBase>(OwningActor);
    URPGAbilitySystemComponent* OwningASC =
        URPGAbilitySystemComponent::GetAbilitySystemComponentFromActor(OwningActor);

    if (OwningASC)
    {
        // 1. 如果配了 TargetType,现在就运行目标逻辑(可选,也可以稍后 AddTargets)
        if (Container.TargetType.Get())
        {
            TArray<FHitResult> HitResults;
            TArray<AActor*> TargetActors;
            const URPGTargetType* TargetTypeCDO = Container.TargetType.GetDefaultObject();
            AActor* AvatarActor = GetAvatarActorFromActorInfo();
            TargetTypeCDO->GetTargets(OwningCharacter, AvatarActor, EventData,
                HitResults, TargetActors);
            ReturnSpec.AddTargets(HitResults, TargetActors);
        }

        // 2. 确定能力等级
        if (OverrideGameplayLevel == INDEX_NONE)
            OverrideGameplayLevel = this->GetAbilityLevel();

        // 3. 为每个 GE 类建立 Spec(把发起者、等级、上下文信息烘进去)
        for (const TSubclassOf<UGameplayEffect>& EffectClass : Container.TargetGameplayEffectClasses)
        {
            ReturnSpec.TargetGameplayEffectSpecs.Add(
                MakeOutgoingGameplayEffectSpec(EffectClass, OverrideGameplayLevel));
        }
    }
    return ReturnSpec;
}

逐步拆解:

第一步 :如果 Container.TargetType 有值,就立刻调用目标逻辑(GetTargets),把 HitResult/Actor 填进 ReturnSpec.TargetData这是可选的 ------注释说 "targets can be added later"。对于近战攻击,通常在 EventReceived(命中帧)时才有 HitResult,所以这里可能不填,之后在蓝图里手动 AddTargets

第二步GetAbilityLevel()------能力等级来自 FGameplayAbilitySpec,是装备时由物品数据填进去的(武器等级)。如果调用者传了 OverrideGameplayLevel,则用覆盖值。

第三步MakeOutgoingGameplayEffectSpec 是 GAS 引擎方法,内部把"谁发的、能力等级多少、GE 上下文"全部烘进一个 FGameplayEffectSpec,返回 Handle。这个 Handle 之后可以传来传去,施加时不需要再重新构建上下文。

MakeEffectContainerSpec 是一层薄包装:

cpp 复制代码
FRPGGameplayEffectContainerSpec URPGGameplayAbility::MakeEffectContainerSpec(
    FGameplayTag ContainerTag, const FGameplayEventData& EventData, int32 OverrideGameplayLevel)
{
    FRPGGameplayEffectContainer* FoundContainer = EffectContainerMap.Find(ContainerTag);
    if (FoundContainer)
        return MakeEffectContainerSpecFromContainer(*FoundContainer, EventData, OverrideGameplayLevel);
    return FRPGGameplayEffectContainerSpec();
}

ContainerTagEffectContainerMap 里查表,找到 FRPGGameplayEffectContainer,再调真正的实现。


五、ApplyEffectContainerSpec:施加效果

cpp 复制代码
TArray<FActiveGameplayEffectHandle> URPGGameplayAbility::ApplyEffectContainerSpec(
    const FRPGGameplayEffectContainerSpec& ContainerSpec)
{
    TArray<FActiveGameplayEffectHandle> AllEffects;
    for (const FGameplayEffectSpecHandle& SpecHandle : ContainerSpec.TargetGameplayEffectSpecs)
    {
        AllEffects.Append(
            K2_ApplyGameplayEffectSpecToTarget(SpecHandle, ContainerSpec.TargetData));
    }
    return AllEffects;
}

遍历所有 SpecHandle,用 K2_ApplyGameplayEffectSpecToTargetTargetData 里的所有目标逐一施加。返回的 FActiveGameplayEffectHandle 列表可以用来之后移除(比如 Buff 需要被主动解除)。

ApplyEffectContainer 是一步到位的便捷方法:

cpp 复制代码
TArray<FActiveGameplayEffectHandle> URPGGameplayAbility::ApplyEffectContainer(
    FGameplayTag ContainerTag, const FGameplayEventData& EventData, int32 OverrideGameplayLevel)
{
    FRPGGameplayEffectContainerSpec Spec = MakeEffectContainerSpec(ContainerTag, EventData, OverrideGameplayLevel);
    return ApplyEffectContainerSpec(Spec);
}

六、AddTargets:填充目标数据

FRPGGameplayEffectContainerSpec::AddTargets 的实现也很有意思:

cpp 复制代码
void FRPGGameplayEffectContainerSpec::AddTargets(
    const TArray<FHitResult>& HitResults, const TArray<AActor*>& TargetActors)
{
    // 把每个 HitResult 包装成 FGameplayAbilityTargetData_SingleTargetHit
    for (const FHitResult& HitResult : HitResults)
    {
        FGameplayAbilityTargetData_SingleTargetHit* NewData =
            new FGameplayAbilityTargetData_SingleTargetHit(HitResult);
        TargetData.Add(NewData);
    }

    // 把 Actor 数组包装成 FGameplayAbilityTargetData_ActorArray
    if (TargetActors.Num() > 0)
    {
        FGameplayAbilityTargetData_ActorArray* NewData =
            new FGameplayAbilityTargetData_ActorArray();
        NewData->TargetActorArray.Append(TargetActors);
        TargetData.Add(NewData);
    }
}

FGameplayAbilityTargetDataHandle 内部是一个 TArray<TSharedPtr<FGameplayAbilityTargetData>>,支持多态------HitResult 和 Actor 数组用不同的子类存储,统一放进这个 Handle 里一起传递。K2_ApplyGameplayEffectSpecToTarget 在施加时会把 Handle 里所有的目标拿出来,逐一查 ASC 并施加效果。


七、小结:这套设计的价值

回头看整个 EffectContainerMap 体系:

环节 数据结构 时机
静态配置 FRPGGameplayEffectContainer(TMap 里) 设计师在编辑器里填
建 Spec FRPGGameplayEffectContainerSpec(Make 时) 能力激活时
填目标 TargetData.Add(AddTargets) AnimNotify 命中帧
施加 K2_ApplyGameplayEffectSpecToTarget 命中帧之后

这一套把"配置"、"发起者信息"、"目标信息"、"施加"彻底分离------设计师只管配表,程序员只管在正确时机调正确方法。新增一种攻击效果,不需要动任何 C++ 代码,只在蓝图 EffectContainerMap 里加一行、改一改 GE 资产就完成了。这正是 ActionRPG 示例项目"用 GAS 做游戏逻辑"的最佳实践展示。

相关推荐
夜猫逐梦8 小时前
【UE源码精读-ActionRPG】伤害管线:从捕获到扣血
ue·unreal·ue基础
郝学胜-神的一滴2 天前
[简化版 GAMES 101] 计算机图形学 16:纹理走样、Mipmap、三线性插值、各向异性过滤原理全解
unity·游戏引擎·godot·图形渲染·three.js·opengl·unreal
夜猫逐梦21 天前
【UE基础】03.蓝图与编辑器工作流
编辑器·ue·蓝图·ue编辑器
夜猫逐梦22 天前
【UE基础】02.基类与蓝图子类的分工
ue·蓝图·ue基础
夜猫逐梦22 天前
【UE基础】01.环境与引擎心智模型
游戏·逆向·ue·unreal·actionrpg
郝学胜-神的一滴23 天前
[简化版 GAMES 101] 计算机图形学 13:从光栅化到着色——赋予三维像素光影灵魂
c++·计算机视觉·unity·godot·图形渲染·opengl·unreal
数峦云数字孪生三维可视化25 天前
数字孪生可视化架构选型:基于UE像素流送与Web引擎的完整决策指南
数字孪生·ue·web3d·技术选型·像素推流
郝学胜-神的一滴1 个月前
中级OpenGL教程 009:用环境光告别模型死黑
前端·c++·unity·godot·图形渲染·opengl·unreal
挨代码1 个月前
UEC++ 数据类型及继承关系
c++·ue