跳到主要内容
信息
· 文章中可能会出现一些错误,希望大佬们可以在评论区指出;

07 - Gameplay Ability

本文主要说明了UE5中Gameplay技能系统(Gameplay Ability System, GAS)中Gameplay Ability (GA) 的相关内容。

概述

Gameplay Ability(下文简称GA),继承自UGameplayAbility,定义了游戏内技能的效果、使用技能付出的代价(如果有),以及使用的条件和冷却等。GA支持异步运行多个任务,包括角色动画、粒子声效,基于分支的用户输入和交互等。GA可以通过网络同步实现客户端预测支持、变量复制同步和执行远程过程调用RPC。

GA-1

下图内容是GA的总结:

  • GA是用于定义一个技能或能力的类;
  • GA必须被授予和激活才能使用,其中授予过程在服务端上进行,然后将GASpec同步到拥有该GA的客户端上;
  • GA可以拥有启用条件,例如花费和冷却时间;
  • GA可以异步执行一些任务,这需要Ability Task的支持;

GA-2

配置项

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执行生命周期的相关函数如下:

  1. CanActivateAbility():即使Actor没有尝试执行特定GA,该函数也可让Actor知道它是否能执行特定GA。
  2. 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可以运行一些被取消时的逻辑。
  3. TryActivateAbility():执行GA的典型方式,是上面两个函数的结合。通过CanActivateAbility()判断是否能执行GA,如果能的话就调用CallActivateAbility()
  4. 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

  1. 定义数据结构:为了在编辑器中方便定义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;
    // ...
    };
  2. 编辑数据:接下来就是在编辑器中编辑Cost GE的相关数据了。例如我在发射火球的 GA_Firebolt中定义了耗蓝耗血的数据。

  3. 新建 Cost GE:接下来在编辑器中创建可复用的GE_Cost_Genetic,并将其填入GA_Firebolt中的Cost Gameplay Effect Class配置项中。

  4. 重写函数GetCostGameplayEffect():该函数用于让GAS获取Cost GE,为了实现复用,需要在此函数中根据上述数据结构在运行时动态创建Modifier。

    AuraGameplayAbility.cpp点击展开/折叠代码
    UGameplayEffect* UAuraGameplayAbility::GetCostGameplayEffect() const
    {
    if (CostGameplayEffectClass)
    {
    UGameplayEffect* CostGE = NewObject<UGameplayEffect>(
    GetTransientPackage(),
    CostGameplayEffectClass
    );
    CostGE->DurationPolicy = EGameplayEffectDurationType::Instant;

    // 动态创建消耗Modifier
    int32 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无法应用于角色本身,于是便多了下文的第五步。

  5. 【可选】重写函数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创建的GameplayEffectSpecGAS 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,具体步骤参考如下:

  1. 在编辑器中新建泛用Cooldown GE,并修改相关属性:

  2. 在GA子类中定义冷却时间和唯一冷却GameplayTag:

    AuraGameplayAbility.h点击展开/折叠代码
    UPROPERTY(EditDefaultsOnly, Category = "AuraGA|Properties|Cooldown")
    FScalableFloat CooldownDuration;

    UPROPERTY(EditDefaultsOnly, Category = "AuraGA|Properties|Cooldown")
    FGameplayTagContainer CooldownTag;

    // 临时容器, 用于在GetCooldownTags()中返回最终结果
    mutable FGameplayTagContainer CachedCooldownTags;
  3. 重写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;
    }
  4. 重写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);
    }
    }
  5. 最后在编辑器中修改相关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函数中还顺便获取了冷却时间。

参考资料