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 在使用前都有一个"类 → 实例"的过程。UGameplayAbility 和 UGameplayEffect 都是类 (蓝图资产或 C++ 类),是静态的数据模板;运行时会生成对应的 Spec (FGameplayAbilitySpec / FGameplayEffectSpec),里面填入具体的上下文信息(发起者是谁、等级多少)。ApplyGE 操作的对象从来不是 GE 类本身,而是从 GE 类创建出来的 GE Spec。理解了这一点,后面的 MakeOutgoingGameplayEffectSpec 和 MakeEffectContainerSpec 就完全不神秘了。
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 实现:

蓝图里调用的 MakeEffectContainerSpec 和 ApplyEffectContainerSpec 就是本文要剖析的核心。
二、两个核心数据结构
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 里玩家按攻击键 → ActivateAbilitiesWithItemSlot → TryActivateAbility 就是主动调用。
被动调用(通过 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();
}
用 ContainerTag 在 EffectContainerMap 里查表,找到 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_ApplyGameplayEffectSpecToTarget 对 TargetData 里的所有目标逐一施加。返回的 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 做游戏逻辑"的最佳实践展示。