07 - Gameplay Ability
本文主要说明了UE5中Gameplay技能系统(Gameplay Ability System, GAS)中Gameplay Ability (GA) 的相关内容。
概述
Gameplay Ability(下文简称GA),继承自UGameplayAbility,定义了游戏内技能的效果、使用技能付出的代价(如果有),以及使用的条件和冷却等。GA支持异步运行多个任务,包括角色动画、粒子声效,基于分支的用户输入和交互等。GA可以通过网络同步实现客户端预测支持、变量复制同步和执行远程过程调用RPC。
下图内容是GA的总结:
- GA是用于定义一个技能或能力的类;
- GA必须被授予和激活才能使用,其中授予过程在服务端上进行,然后将GASpec同步到拥有该GA的客户端上;
- GA可以拥有启用条件,例如花费和冷却时间;
- GA可以异步执行一些任务,这需要Ability Task的支持;
配置项
Tags
可通过Gameplay Tags帮助决定GA间的交互方式,每个GA均拥有一组可用来标记和分类的Gameplay Tags,这些Tags会影响它的行为。具体配置项详见下表:
| Gameplay Tag 类型 | 描述 |
|---|---|
| Ability Tags | 该GA拥有的Tag |
| Cancel Abilities With Tag | 该GA执行后,拥有这些Tag的GA会被取消 |
| Block Abilities With Tag | 该GA激活时,拥有这些Tag的GA会被阻止激活 |
| Activation Owned Tags | 该GA激活时,将这些Tag给予GA所有者; 如果 AbilitySystemGlobals中的ReplicateActivationOwnedTags启用,这些Tag会被网络同步 |
| Activation Required Tags | 如果GA所有者的Actor/Component拥有全部这些Tag,该GA才能被激活 |
| Activation Blocked Tags | 如果GA所有者的Actor/Component拥有任一这些Tag,该GA会被阻止激活 |
| Source Required Tags | 如果GA施加者的Actor/Component拥有全部这些Tag,该GA才能被激活 |
| Source Blocked Tags | 如果GA施加者的Actor/Component拥有任一这些Tag,该GA会被阻止激活 |
| Target Required Tags | 如果GA承受者的Actor/Component拥有全部这些Tag,该GA才能被激活 |
| Target Blocked Tags | 如果GA承受者的Actor/Component拥有任一这些Tag,该GA会被阻止激活 |
Input
- Replicate Input Directly:如果选中该项,该GA将总会向服务器同步输入按下/松开的事件。
Advanced
GA的高级设置部分,分为网络和实例化两大类。
网络
-
Replication Policy:决定GA是否会复制自身实例、更新状态或跨网络传输Gameplay事件。但该配置项不会影响复制GA的激活与结束。
-
Server Respects Remote Ability Cancellation:当本地客户端的GA结束时,结束服务器的对应GA。
-
Net Execution Policy:对于启用复制GA的多人游戏,有如下处理网络复制的策略:
| 配置项 | 描述 |
|---|---|
| Local Predicted | 在客户端激活GA后执行并进行预测,根据服务器执行GA的结果确认GA造成的效果是否该回滚改正。这样做可以在数据准确和游戏延迟间取得较好的平衡。 |
| Local Only | 只在客户端执行GA,服务器不会执行GA。 |
| Server Initiated | 先在服务器执行GA,然后在客户端执行。这样做虽然准确,但会产生一定的延迟。 |
| Server Only | 只在服务器执行GA。 |
- Net Security Policy:该配置项将决定客户端和服务器对GA的激活/结束权限:
| 配置项 | 描述 |
|---|---|
| Client Or Server | 客户端和服务器均能激活/结束GA |
| Server Only Execution | 只有服务器被允许激活GA |
| Server Only Termination | 只有服务器被允许结束GA |
| Server Only | 只有服务器被允许激活/结束GA |
实例化
- Instancing Policy:该项控制GA实例化时的策略,这会在限制它在代码实现中的行为。具体配置项如下表所示:
| 配置项 | 描述 |
|---|---|
| Instanced Per Execution | 每次激活GA时都会创建新实例。该实例化策略可以自由使用蓝图或成员变量,并且这些内容都会在执行前被初始化为默认值,因此不建议存储一些持久性数据。由于开销较大,该策略更适合不会频繁运行的技能,例如MOBA游戏中的英雄大招; |
| Instanced Per Actor | 首次激活GA时为Actor创建一个实例,之后激活时会重复使用此示例。该实例化策略的变量必须每次要手动重置,因此能存储一些持久化数据。该策略适用于大规模的情况,因为大量使用技能的Actor(例如在大型战斗中)只会在第一次使用技能时产生对象; |
| Non-Instanced | 是性能最高的实例化逻辑,仅使用该GA的类默认对象(Class Default Object,CDO)。该实例化策略不能存储状态、不能向Ability Task绑定委托,只能实现纯C++代码逻辑。该策略适合频繁运行且被许多角色使用的GA,例如大型RTS或MOBA作品中部队使用的基本攻击; |
- Retrigger Instanced Ability:如果选中该项,并且激活一个已经激活的GA实例,那么该GA实例就会被结束并重新激活。
Costs
- Cost Gameplay Effect Class:提供一个GE,GA需要花费对应的代价才能被激活。
Triggers
- Ability Triggers:根据特定事件触发GA。
Cooldowns
- Cooldown Gameplay Effect Class:提供一个GE,GA需要等待对应的冷却时间才能被激活。
常用操作
授予和移除GA
要想让Actor使用GA,需要向它的Ability System Component(下文简称ASC)授予GA,并且只能在服务端上授予或移除GA。ASC提供如下函数进行授予或移除GA的操作:
| 函数 | 说明 |
|---|---|
GiveAbility() | 使用 FGameplayAbilitySpec 指定要添加的GA,并返回 FGameplayAbilitySpecHandle。 |
GiveAbilityAndActivateOnce() | 使用 FGameplayAbilitySpec 指定要添加的GA,并返回 FGameplayAbilitySpecHandle。该GASpec必须实例化,并且必须能够在服务器上运行。如果该GA没有满足所需条件,或者无法执行,返回值将无效,并且ASC将不会被赋予该GA。 |
ClearAbility() | 从ASC中移除指定GA。 |
SetRemoveAbilityOnEnd() | 在GA结束执行后将其从ASC种移除。如果此时GA未执行,立即移除;如果GA正在执行,则清除输入,让玩家无法重新激活或与它交互。 |
ClearAllAbilities() | 从ASC中移除所有GA。 |
GA的生命周期
GA执行生命周期的相关函数如下:
CanActivateAbility():即使Actor没有尝试执行特定GA,该函数也可让Actor知道它是否能执行特定GA。CallActivateAbility():执行GA的相关逻辑,但不会检查该GA是否可用。需要注意的是:- GA的自定义逻辑需要在
ActivateAbility()C++函数或Activate Ability蓝图事件中实现。 - GA的主要工作不在Tick函数中完成,而是通过激活Ability Task来异步完成相关逻辑。执行完这些Ability Task后需要通过C++的委托或蓝图的输出引脚来处理后续逻辑。
- 如果在GA激活期间调用
CommitAbility()函数,它将会应用执行GA的消耗或冷却,例如减少Attribute资源等。 CancelAbility()函数可以在GA逻辑外调用,它可以取消当前执行的GA,尽管CanBeCanceled()可以拒绝这个请求。成功取消GA后,将会广播OnGameplayAbilityCancelled委托,让该GA可以运行一些被取消时的逻辑。
- GA的自定义逻辑需要在
TryActivateAbility():执行GA的典型方式,是上面两个函数的结合。通过CanActivateAbility()判断是否能执行GA,如果能的话就调用CallActivateAbility()。EndAbility()函数或End Ability蓝图节点:是GA逻辑正常结束后必须执行的C++函数/蓝图节点,不执行的话会让GAS系统认为该GA仍在运行。
激活执行GA
ASC提供了很多激活执行GA的函数:
TryActivateAbility()
AbilitySystemComponent.h点击展开/折叠代码
/**
* Attempts to activate the given ability, will check costs and requirements before doing so.
* Returns true if it thinks it activated, but it may return false positives due to failure later in activation.
* If bAllowRemoteActivation is true, it will remotely activate local/server abilities, if false it will only try to locally activate the ability
*/
UFUNCTION(BlueprintCallable, Category = "Abilities")
bool TryActivateAbility(FGameplayAbilitySpecHandle AbilityToActivate, bool bAllowRemoteActivation = true);
尝试激活给定的GA,会先检查成本和需求。如果认为成功激活了,会返回true,但由于激活过程中后期失败,可能会返回false。如果bAllowRemoteActivation为true,它将远程激活本地/服务器GA;如果为false,它将仅尝试本地激活GA。
在Aura项目中,该函数用于激活大部分基于玩家输入的GA:
AuraAbilitySystemComponent.cpp点击展开/折叠代码
void UAuraAbilitySystemComponent::AbilityInputTagOnHeld(const FGameplayTag& InputTag)
{
if (!InputTag.IsValid())
{
return;
}
for (TArray<FGameplayAbilitySpec> ActivatableGASpecs = GetActivatableAbilities();
FGameplayAbilitySpec& GASpec : ActivatableGASpecs)
{
// 寻找该按键输入能触发的GA
if (GASpec.DynamicAbilityTags.HasTagExact(InputTag))
{
// 标识该GA的输入触发为: 已按下
AbilitySpecInputPressed(GASpec);
// 如果该GA仍未激活, 激活它
if (!GASpec.IsActive())
{
TryActivateAbility(GASpec.Handle);
}
}
}
}
TryActivateAbilitiesByTag()
AbilitySystemComponent.h点击展开/折叠代码
/**
* Attempts to activate every gameplay ability that matches the given tag and DoesAbilitySatisfyTagRequirements().
* Returns true if anything attempts to activate. Can activate more than one ability and the ability may fail later.
* If bAllowRemoteActivation is true, it will remotely activate local/server abilities, if false it will only try to locally activate abilities.
*/
UFUNCTION(BlueprintCallable, Category = "Abilities")
bool TryActivateAbilitiesByTag(const FGameplayTagContainer& GameplayTagContainer, bool bAllowRemoteActivation = true);
尝试激活每个符合给定GameplayTag和DoesAbilitySatisfyTagRequirements()的GA。如果任何尝试激活,则返回 true。可以激活多个GA,并且可能在稍后失败。如果bAllowRemoteActivation为true,它将远程激活本地/服务器GA;如果为false,它将仅尝试本地激活GA。
在Aura项目中,角色受到伤害时会通过此函数激活带有相应GameplayTag的GA,用于实现受击逻辑:
AuraAttributeSet.cpp点击展开/折叠代码
void UAuraAttributeSet::PostGameplayEffectExecute(const struct FGameplayEffectModCallbackData& Data)
{
// ...
FGameplayTagContainer TagContainer;
TagContainer.AddTag(AuraGameplayTags::GE::HitReact.GetTag());
EffectProperties.TargetASC->TryActivateAbilitiesByTag(TagContainer);
// ...
}
添加Cost类型GE
有时我们希望GA触发的前提条件为消耗某些资源(例如魔力值等属性),此时就可以通过配置Cost Gameplay Effect Class实现这项需求。在执行CommitAbility()函数后,GA将检查它是否满足Cost和Cooldown类型GE的要求,如果符合要求就会被激活。Cost GE通常是Instant类型的,且带有一个或多个修改属性的Modifier。
在Aura项目中,每个GA都有它的Cost GE,但其实只需要重用一个Cost GE即可(只适用于 Instanced 类型的GA),通过使用GA特定的数据(在GA上定义某属性的消耗值)动态修改从Cost GE创建的GameplayEffectSpec即可。根据GAS Documentation的解释,主要有两种重用Cost GE的技术:
- 使用
MMC。 这是最简单的方法。创建一个MMC,从GameplayAbility实例中读取消耗值,你可以从GameplayEffectSpec中获取该实例。 - 重写
UGameplayAbility::GetCostGameplayEffect()。 重写此函数并在运行时创建一个GameplayEffect,该效果读取GameplayAbility上的消耗值。
方法1对只修改一种属性的情况较方便有效,具体实现详见GAS Documentation。方法2的可扩展性似乎比方法1好,而且在GAS Documentation中没有过多详细介绍。接下来看看如何利用方法2重用Cost GE:
-
定义数据结构:为了在编辑器中方便定义GA消耗的属性类型及数值,可以定义数据结构
FAbilityCost如下。AuraGameplayAbility.h点击展开/折叠代码
USTRUCT(BlueprintType)struct FAbilityCost{GENERATED_BODY()UPROPERTY(EditDefaultsOnly)FGameplayAttribute CostAttribute;UPROPERTY(EditDefaultsOnly)FScalableFloat CostValue;};UCLASS()class AURA_API UAuraGameplayAbility : public UGameplayAbility{GENERATED_BODY()public:// ...UPROPERTY(EditDefaultsOnly, Category = "AuraGA|Properties|Cost")TArray<FAbilityCost> Costs;// ...}; -
编辑数据:接下来就是在编辑器中编辑
Cost GE的相关数据了。例如我在发射火球的GA_Firebolt中定义了耗蓝耗血的数据。 -
新建
Cost GE:接下来在编辑器中创建可复用的GE_Cost_Genetic,并将其填入GA_Firebolt中的Cost Gameplay Effect Class配置项中。 -
重写函数
GetCostGameplayEffect():该函数用于让GAS获取Cost GE,为了实现复用,需要在此函数中根据上述数据结构在运行时动态创建Modifier。AuraGameplayAbility.cpp点击展开/折叠代码
UGameplayEffect* UAuraGameplayAbility::GetCostGameplayEffect() const{if (CostGameplayEffectClass){UGameplayEffect* CostGE = NewObject<UGameplayEffect>(GetTransientPackage(),CostGameplayEffectClass);CostGE->DurationPolicy = EGameplayEffectDurationType::Instant;// 动态创建消耗Modifierint32 Idx = CostGE->Modifiers.Num();CostGE->Modifiers.SetNum(Idx + Costs.Num());for (const FAbilityCost& Cost : Costs){FGameplayModifierInfo Modifier;Modifier.Attribute = Cost.CostAttribute;Modifier.ModifierOp = EGameplayModOp::Additive;Modifier.ModifierMagnitude =FGameplayEffectModifierMagnitude(FScalableFloat(Cost.CostValue));CostGE->Modifiers[Idx] = Modifier;++Idx;}return CostGE;}return nullptr;}在让GA执行
CommitAbility()函数后,要想激活GA就得先根据Cost GE消耗相关属性资源了,并且该Cost GE是可复用的。但我使用的UE5.4.4版本可能有bug,导致创建好的Cost GE无法应用于角色本身,于是便多了下文的第五步。 -
【可选】重写函数
ApplyCost():该函数用于让GAS激活Cost GE,由于我碰到了上述问题,只能尝试重写该函数解决问题了。AuraGameplayAbility.cpp点击展开/折叠代码
void UAuraGameplayAbility::ApplyCost(const FGameplayAbilitySpecHandle Handle,const FGameplayAbilityActorInfo* ActorInfo,const FGameplayAbilityActivationInfo ActivationInfo) const{if (UGameplayEffect* CostGE = GetCostGameplayEffect()){UAbilitySystemComponent* ASC = ActorInfo->AbilitySystemComponent.Get();FGameplayEffectContextHandle Context = ASC->MakeEffectContext();FGameplayEffectSpec Spec(CostGE, Context, GetAbilityLevel());ASC->ApplyGameplayEffectSpecToSelf(Spec);}}
最终结果如下所示,可见角色的血和蓝都被顺利削减了:
其实该方法还有一个小问题,就是在多人联机情况中,客户端在发射火球后会出现如下警告:
LogNetPackageMap: Warning: FNetGUIDCache::SupportsObject: GE_Cost_Genertic_C /Engine/Transient.GE_Cost_Genertic_C_162 NOT Supported.由于我个人对网络联机这块还是不大懂,询问AI得知这是临时创建的GE无法网络同步,但GE导致的属性变化仍支持网络同步,目前看来这个问题对我没有什么影响,还请希望评论区的大佬赐教。
添加Cooldown类型GE
有时我们有激活GA后让它有数秒冷却的需求,此时就可以通过配置Cooldown Gameplay Effect Class来实现对该GA的冷却。Cooldown GE通常是Duration类型的,没有Modifiers,并且为每个需要冷却的GA提供一个唯一的GameplayTag。如果需要对冷却时间进行复杂计算,推荐使用MMC而不是ExecutionCalculations,因为后者不可预测。
在Aura项目中,也是为每个GA单独定制了它的Cooldown GE,但也能像上文Cost GE一样将同一个Cooldown GE复用到多个GA(只适用于 Instanced 类型的GA)。这需要用GA特定的数据(例如冷却时间和冷却GameplayTag)动态修改从Cooldown GE创建的GameplayEffectSpec。GAS Documentation提供了复用Cooldown GE的两种方法:
- 使用
SetByCaller:这是最简单的方法,需要将Cooldown GE的持续时间和唯一冷却GameplayTag在GA子类中定义,并把Cooldown GE中将Duration Magnitude修改为Set By Caller。 - 使用
MMC:和方法1差不多,就是将Cooldown GE中的Duration Magnitude修改为Custom Calculation CLass,并在MMC中自定义冷却时间计算逻辑。
由于Aura项目中目前没有比较复杂的冷却时间计算逻辑,我打算使用方法1,具体步骤参考如下:
-
在编辑器中新建泛用
Cooldown GE,并修改相关属性: -
在GA子类中定义冷却时间和唯一冷却GameplayTag:
AuraGameplayAbility.h点击展开/折叠代码
UPROPERTY(EditDefaultsOnly, Category = "AuraGA|Properties|Cooldown")FScalableFloat CooldownDuration;UPROPERTY(EditDefaultsOnly, Category = "AuraGA|Properties|Cooldown")FGameplayTagContainer CooldownTag;// 临时容器, 用于在GetCooldownTags()中返回最终结果mutable FGameplayTagContainer CachedCooldownTags; -
重写
UGameplayAbility::GetCooldownTags(),以返回唯一冷却GameplayTag和已有冷却GameplayTag的并集:AuraGameplayAbility.cpp点击展开/折叠代码
const FGameplayTagContainer* UAuraGameplayAbility::GetCooldownTags() const{CachedCooldownTags.Reset();if (const FGameplayTagContainer* Parent = Super::GetCooldownTags()){CachedCooldownTags.AppendTags(*Parent);}CachedCooldownTags.AppendTags(CooldownTag);return &CachedCooldownTags;} -
重写
UGameplayAbility::ApplyCooldown(),在此函数中需要将相关信息注入到GameplayEffectSpec中:AuraGameplayAbility.cpp点击展开/折叠代码
void UAuraGameplayAbility::ApplyCooldown(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo,const FGameplayAbilityActivationInfo ActivationInfo) const{if (UGameplayEffect* CooldownGE = GetCooldownGameplayEffect()){FGameplayEffectSpecHandle CooldownGESpecHandle = MakeOutgoingGameplayEffectSpec(CooldownGE->GetClass(), GetAbilityLevel());CooldownGESpecHandle.Data->DynamicGrantedTags.AppendTags(CooldownTag);CooldownGESpecHandle.Data->SetSetByCallerMagnitude(AuraGameplayTags::Cooldown::Root,CooldownDuration.GetValueAtLevel(GetAbilityLevel()));ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, CooldownGESpecHandle);}} -
最后在编辑器中修改相关GA子类的属性即可:
监听冷却开始和结束
GAS提供了若干方法来让我们比较容易地监听到冷却的开始和结束,常用的方法如下:
- 监听冷却开始:绑定
AbilitySystemComponent->OnActiveGameplayEffectAddedDelegateToSelf委托,当Cooldown GE被激活时该委托会被广播,因此可以用来监听冷却开始。 - 监听冷却结束:绑定
AbilitySystemComponent->RegisterGameplayTagEvent委托,当冷却GameplayTag被移除时该委托会广播,因此可以用来监听冷却结束。
在Aura项目中,为了方便在蓝图中通过异步节点来监听GA冷却开始和结束,基于UBlueprintAsyncActionBase类创建了UWaitCooldownChange类:
WaitCooldownChange.h点击展开/折叠代码
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnCooldownChangeDelegate, float, TimeRemaining);
UCLASS()
class AURA_API UWaitCooldownChange : public UBlueprintAsyncActionBase
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintAssignable)
FOnCooldownChangeDelegate OnCooldownStart;
UPROPERTY(BlueprintAssignable)
FOnCooldownChangeDelegate OnCooldownEnd;
UFUNCTION(BlueprintCallable, meta = (DisplayName ="Wait for Cooldown Change", BlueprintInternalUseOnly = "true"))
static UWaitCooldownChange* WaitCooldownChangeProxy(UAbilitySystemComponent* InASC, const FGameplayTag& InCooldownTag);
UFUNCTION(BlueprintCallable)
void EndTask();
protected:
UPROPERTY()
TObjectPtr<UAbilitySystemComponent> ASC;
FGameplayTag CooldownTag;
// 冷却Tag被移除时的回调, 可推断出此时冷却已经结束
// 原型: FOnGameplayEffectTagCountChanged, const FGameplayTag, int32
void OnCooldownTagChanged(const FGameplayTag InCooldownTag, int32 NewCount);
// 冷却GE被添加时的回调, 可推断出此时冷却已经开始
// 原型: FOnGameplayEffectAppliedDelegate, UAbilitySystemComponent*, const FGameplayEffectSpec&, FActiveGameplayEffectHandle
void OnActiveGEAdded(UAbilitySystemComponent* TargetASC, const FGameplayEffectSpec& GESpec, FActiveGameplayEffectHandle ActiveGEHandle);
};
WaitCooldownChange.cpp点击展开/折叠代码
UWaitCooldownChange* UWaitCooldownChange::WaitCooldownChangeProxy(UAbilitySystemComponent* InASC, const FGameplayTag& InCooldownTag)
{
UWaitCooldownChange* WaitCooldownChange = NewObject<UWaitCooldownChange>();
WaitCooldownChange->ASC = InASC;
WaitCooldownChange->CooldownTag = InCooldownTag;
if (!IsValid(InASC) || !InCooldownTag.IsValid())
{
WaitCooldownChange->EndTask();
return nullptr;
}
WaitCooldownChange->ASC->RegisterGameplayTagEvent(WaitCooldownChange->CooldownTag, EGameplayTagEventType::NewOrRemoved)
.AddUObject(WaitCooldownChange, &UWaitCooldownChange::OnCooldownTagChanged);
WaitCooldownChange->ASC->OnActiveGameplayEffectAddedDelegateToSelf
.AddUObject(WaitCooldownChange, &UWaitCooldownChange::OnActiveGEAdded);
return WaitCooldownChange;
}
void UWaitCooldownChange::EndTask()
{
if (IsValid(ASC))
{
ASC->RegisterGameplayTagEvent(CooldownTag, EGameplayTagEventType::NewOrRemoved).RemoveAll(this);
}
SetReadyToDestroy();
MarkAsGarbage();
}
void UWaitCooldownChange::OnCooldownTagChanged(const FGameplayTag InCooldownTag, int32 NewCount)
{
if (NewCount == 0)
{
OnCooldownEnd.Broadcast(0.f);
}
}
void UWaitCooldownChange::OnActiveGEAdded(UAbilitySystemComponent* TargetASC, const FGameplayEffectSpec& GESpec,
FActiveGameplayEffectHandle ActiveGEHandle)
{
// 在触发冷却GE后, 该回调会被本地和网络预测激活两次, 因此需要筛除一种情况
if (bool bIsReplicatedGE = !GESpec.GetContext().GetAbilityInstance_NotReplicated();
bIsReplicatedGE)
{
return;
}
FGameplayTagContainer AssetTags, GrantedTags;
GESpec.GetAllAssetTags(AssetTags);
GESpec.GetAllGrantedTags(GrantedTags);
if (AssetTags.HasTagExact(CooldownTag) || GrantedTags.HasTagExact(CooldownTag))
{
FGameplayEffectQuery GEQuery = FGameplayEffectQuery::MakeQuery_MatchAnyOwningTags(CooldownTag.GetSingleTagContainer());
float TimeRemaining = FMath::Max(ASC->GetActiveEffectsTimeRemaining(GEQuery));
OnCooldownStart.Broadcast(TimeRemaining);
}
}
其中,在监听冷却开始的OnActiveGEAdded函数中还顺便获取了冷却时间。






