前言

终于准备开新坑啦!终于使用了一次正常封面 在 STL 系列基本写完之后就摸了挺久的,其实这段时间也没有闲着,把 MP-TPS 的那个教程基本看完了,现在只剩第一部分的联机插件没有实现,准备最近开始继续更新 MP-TPS 系列,把这个项目的内容好好总结一下。

说回正题,目前的准备是将 GAS 结合到这个项目中,因此有必要好好学习一下 GAS 系统。但是总所周知,GAS 系统虽功能强大,学习曲线却异常陡峭,本来打算通过看 ActionRPG 项目的方式来学习 GAS,奈何这个项目本身也十分复杂。最后,在机缘巧合之下终于找到了这个项目———— GASDocumentation 以及某位好心人翻译的中文版 GASdocumentation_Chinese,感谢这些开源贡献者。

GASDocumentation 这个项目提供了十分完善的 GAS 文档,包含了 GAS 系统的各个模块的详细介绍,虽说没有细到分析源码的地步,但作为入门阶段的百科全书已经十分优秀了。除此之外,该项目还自带了一个同名的 UE4 工程,简单展示了 GAS 的使用方法,可以说是无比良心了。

相比于 ActionRPG,GASDocumentation 更加地简单,且较为完整地展示了 GAS 的基本用法,很适合用于熟悉 GAS 框架。话不多说,就让我们一同揭开 GAS 的秘密吧。

几个重要的类

在分析具体技能的实现之前,需要先明白与 GAS 相关的几个类在项目中是如何组织的,下面开始一一分析。

GDAbilitySystemComponent

项目中使用的 ASC(AbilitySystemComponent) 组件为一个自定义的从 AbilitySystemComponent 中派生的子类,查看该类的定义:

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
UCLASS()
class GASDOCUMENTATION_API UGDAbilitySystemComponent : public UAbilitySystemComponent
{
GENERATED_BODY()

public:
bool CharacterAbilitiesGiven = false;
bool StartupEffectsApplied = false;

FReceivedDamageDelegate ReceivedDamage;

// Called from GDDamageExecCalculation. Broadcasts on ReceivedDamage whenever this ASC receives damage.
virtual void ReceiveDamage(
UGDAbilitySystemComponent* SourceASC,
float UnmitigatedDamage,
float MitigatedDamage
);
};

该类有两个 bool 类型的成员变量与一个 ReceiveDamage 的虚函数。

c++
1
2
3
4
5
void UGDAbilitySystemComponent::ReceiveDamage(
UGDAbilitySystemComponent * SourceASC, float UnmitigatedDamage, float MitigatedDamage)
{
ReceivedDamage.Broadcast(SourceASC, UnmitigatedDamage, MitigatedDamage);
}

该函数中广播了一个 FReceivedDamageDelegate 类型的动态委托,目前还看不出广播的作用。

c++
1
2
3
4
5
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FReceivedDamageDelegate, 
UGDAbilitySystemComponent*, SourceASC,
float, UnmitigatedDamage,
float, MitigatedDamage
);

GDAttributeSetBase

GDAttributeSetBase 类中,定义了一些项目中使用到的属性,如 Health、Mana 等,但有很多属性实际上是没有用到的。同样地,与 GAS 关系较为密切的有两个函数:

c++
1
2
3
// AttributeSet Overrides
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override;
virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;

查看这两个函数的原始定义:

c++
1
2
3
4
5
6
7
8
/**
* Called just before any modification happens to an attribute. This is lower level than PreAttributeModify/PostAttribute modify.
* There is no additional context provided here since anything can trigger this. Executed effects, duration based effects, effects being removed, immunity being applied, stacking rules changing, etc.
* This function is meant to enforce things like "Health = Clamp(Health, 0, MaxHealth)" and NOT things like "trigger this extra thing if damage is applied, etc".
*
* NewValue is a mutable reference so you are able to clamp the newly applied value as well.
*/
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) { }

PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)AttributeSet中的主要函数之一, 其在修改发生前响应AttributeCurrentValue变化, 其是通过引用参数NewValue限制(Clamp)CurrentValue即将进行的修改的理想位置。Epic对于PreAttributeChange()的注释说明不要将该函数用于游戏逻辑事件, 而主要在其中做限制操作。

c++
1
2
3
4
5
/**
* Called just before a GameplayEffect is executed to modify the base value of an attribute. No more changes can be made.
* Note this is only called during an 'execute'. E.g., a modification to the 'base value' of an attribute. It is not called during an application of a GameplayEffect, such as a 5s second +10 movement speed buff.
*/
virtual void PostGameplayEffectExecute(const struct FGameplayEffectModCallbackData &Data) { }

PostGameplayEffectExecute(const FGameplayEffectModCallbackData & Data)仅在即刻(Instant)GameplayEffectAttributeBaseValue修改之后触发, 当GameplayEffect对其修改时, 这就是一个处理更多Attribute操作的有效位置。当PostGameplayEffectExecute()被调用时, 对Attribute的修改已经发生, 但是还没有被同步回客户端, 因此在这里限制值不会造成对客户端的二次同步, 客户端只会接收到限制后的值。

总之 PreAttributeChange 发生在属性修改前,PostGameplayEffectExecute 发生在 Effect 执行(Execute)之后,具体这两个函数做了什么,等分析到的时候再具体说明。

GDGameplayAbility

项目中使用的所有 Ability 也全都派生自 GDGameplayAbility 而不是 GameplayAbility 本身,来看看 GDGameplayAbility 实现了哪些新的功能。

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
UCLASS()
class GASDOCUMENTATION_API UGDGameplayAbility : public UGameplayAbility
{
GENERATED_BODY()

public:
UGDGameplayAbility();

// Abilities with this set will automatically activate when the input is pressed
UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "Ability")
EGDAbilityInputID AbilityInputID = EGDAbilityInputID::None;

// Value to associate an ability with an slot without tying it to an automatically activated input.
// Passive abilities won't be tied to an input so we need a way to generically associate abilities with slots.
UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "Ability")
EGDAbilityInputID AbilityID = EGDAbilityInputID::None;

// Tells an ability to activate immediately when its granted.
// Used for passive abilities and abilities forced on others.
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Ability")
bool ActivateAbilityOnGranted = false;

// If an ability is marked as 'ActivateAbilityOnGranted', activate them immediately when given here
// Epic's comment: Projects may want to initiate passives or do other "BeginPlay" type of logic here.
virtual void OnAvatarSet(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec) override;
};

这里定义了三个新的成员变量与一个 override 的函数。其中,比较重要的是 AbilityInputID,这一变量将技能与 EGDAbilityInputID 的枚举类型对应了起来。查看项目的输入设置就会发现,输入的名称与 EGDAbilityInputID 中的枚举类型是一一对应的,这就意味着输入与 EGDAbilityInputID 这个枚举类也存在着一一对应的关系,这里先记住这件事情,在下文中会说明这样做的目的。

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
UENUM(BlueprintType)
enum class EGDAbilityInputID : uint8
{
// 0 None
None UMETA(DisplayName = "None"),
// 1 Confirm
Confirm UMETA(DisplayName = "Confirm"),
// 2 Cancel
Cancel UMETA(DisplayName = "Cancel"),
// 3 LMB
Ability1 UMETA(DisplayName = "Ability1"),
// 4 RMB
Ability2 UMETA(DisplayName = "Ability2"),
// 5 Q
Ability3 UMETA(DisplayName = "Ability3"),
// 6 E
Ability4 UMETA(DisplayName = "Ability4"),
// 7 R
Ability5 UMETA(DisplayName = "Ability5"),
// 8 Sprint
Sprint UMETA(DisplayName = "Sprint"),
// 9 Jump
Jump UMETA(DisplayName = "Jump")
};

2.png

GDPlayerState

项目中 ASC 位于 PlayerState 上,而 Character 本身只实现 AbilitySystemInterface 的接口,即提供一个函数访问 ASC 组件。即 PlayerState 为 ASC 的 OwnActor 而 Character 为 AvatarActor。

首先,在构造函数中构造了 ASC 组件与 AttributeSet,并设置了网络同步的模式。

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
AGDPlayerState::AGDPlayerState()
{
// Create ability system component, and set it to be explicitly replicated
AbilitySystemComponent = CreateDefaultSubobject<UGDAbilitySystemComponent>(TEXT("AbilitySystemComponent"));
AbilitySystemComponent->SetIsReplicated(true);

// Mixed mode means we only are replicated the GEs to ourself, not the GEs to simulated proxies.
// If another GDPlayerState (Hero) receives a GE, we won't be told about it by the Server.
// Attributes, GameplayTags, and GameplayCues will still replicate to us.
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);

// Create the attribute set, this replicates by default
// Adding it as a subobject of the owning actor of an AbilitySystemComponent
// automatically registers the AttributeSet with the AbilitySystemComponent
AttributeSetBase = CreateDefaultSubobject<UGDAttributeSetBase>(TEXT("AttributeSetBase"));

// Set PlayerState's NetUpdateFrequency to the same as the Character.
// Default is very low for PlayerStates and introduces perceived lag in the ability system.
// 100 is probably way too high for a shipping game, you can adjust to fit your needs.
NetUpdateFrequency = 100.0f;

// Cache tags
DeadTag = FGameplayTag::RequestGameplayTag(FName("State.Dead"));
}

其次,在 BeginPlay() 中绑定了许多的回调函数,用于处理 UI 的显示,这里就不贴上来了。

由于项目中的 OwnActor 与 AvatarActor 是不同的 Actor,因此项目中选择了在 Character 中初始化 ASC 组件,即指定相应的 OwnActor 与 AvatarActor 以及绑定输入。

GDCharacterBase

该类直接派生自 Character 并且实现了 AbilitySystemInterface 的接口。

c++
1
class GASDOCUMENTATION_API AGDCharacterBase : public ACharacter, public IAbilitySystemInterface

在该类中,AbilitySystemComponent 与相应的属性集合 AttributeSetBase 的指针均以 TWeakObjectPtr 的类型保存,这种弱引用的方式更加轻量级,但有可能会被 GC。

c++
1
2
3
4
5
6
7
8
9
// Instead of TWeakObjectPtrs, you could just have UPROPERTY() hard references or no references at all 
// and just call GetAbilitySystem() and make a GetAttributeSetBase() that can read from the PlayerState
// or from child classes. Just make sure you test if the pointer is valid before using.
// I opted for TWeakObjectPtrs because I didn't want a shared hard reference here and I didn't want an extra
// function call of getting the ASC/AttributeSet from the PlayerState or child classes every time I
// referenced them in this base class.

TWeakObjectPtr<class UGDAbilitySystemComponent> AbilitySystemComponent;
TWeakObjectPtr<class UGDAttributeSetBase> AttributeSetBase;

与 GAS 强相关的有以下的一些变量与函数:

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Default abilities for this Character. These will be removed on Character death and 
// regiven if Character respawns.
UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "GASDocumentation|Abilities")
TArray<TSubclassOf<class UGDGameplayAbility>> CharacterAbilities;

// Default attributes for a character for initializing on spawn/respawn.
// This is an instant GE that overrides the values for attributes that get reset on spawn/respawn.
UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "GASDocumentation|Abilities")
TSubclassOf<class UGameplayEffect> DefaultAttributes;

// These effects are only applied one time on startup
UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "GASDocumentation|Abilities")
TArray<TSubclassOf<class UGameplayEffect>> StartupEffects;

// Grant abilities on the Server. The Ability Specs will be replicated to the owning client.
virtual void AddCharacterAbilities();

// Removes all CharacterAbilities. Can only be called by the Server.
// Removing on the Server will remove from Client too.
virtual void RemoveCharacterAbilities();

// Initialize the Character's attributes. Must run on Server but we run it on Client too
// so that we don't have to wait. The Server's replication to the Client won't matter since
// the values should be the same.
virtual void InitializeAttributes();

virtual void AddStartupEffects();

其中,CharacterAbilities 为开局就赋予玩家的默认能力,DefaultAttributes 用于初始化玩家的各项属性,StartupEffects 是开局就起作用的其他 GE。由于该项目也考虑到了联机的场景,因此 AddCharacterAbilities() 只在 Server 进行,而客户端则需要等待 Ability Specs 同步;同样的,RemoveCharacterAbilities() 也只在 Server 进行;InitializeAttributes() 在 Server 与 Client 都会进行而不是等待同步。

AddCharacterAbilities() 负责赋予玩家默认能力,查看该函数的实现:

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// @brief 赋予角色默认 Abilities,并完成按键的绑定
void AGDCharacterBase::AddCharacterAbilities()
{
// Grant abilities, but only on the server
if (GetLocalRole() != ROLE_Authority || !AbilitySystemComponent.IsValid() || AbilitySystemComponent->CharacterAbilitiesGiven)
{
return;
}

for (TSubclassOf<UGDGameplayAbility>& StartupAbility : CharacterAbilities)
{
AbilitySystemComponent->GiveAbility(
FGameplayAbilitySpec(
StartupAbility,
GetAbilityLevel(StartupAbility.GetDefaultObject()->AbilityID),
static_cast<int32>(StartupAbility.GetDefaultObject()->AbilityInputID),
this
)
);
}

AbilitySystemComponent->CharacterAbilitiesGiven = true;
}

AGDCharacterBase::AddCharacterAbilities() 中,会遍历 CharacterAbilities 即默认能力,并将 FGameplayAbilitySpec(可以理解为能力的实例)赋予玩家(GiveAbility)。

c++
1
2
3
4
5
6
FGameplayAbilitySpec::FGameplayAbilitySpec(
UGameplayAbility * InAbility,
int32 InLevel,
int32 InInputID,
UObject * InSourceObject
)

查看 FGameplayAbilitySpec 的函数签名,会发现与 int32 InInputID 这个参数对应的是 static_cast<int32>(StartupAbility.GetDefaultObject()->AbilityInputID)。在 GDGameplayAbility 的分析中说过,这一参数将技能与对应的 EGDAbilityInputID 枚举类联系了起来。

但是想要建立输入到技能的联系,还缺少了 输入 -> EGDAbilityInputID 之间的联系,这又是在哪里完成的呢?答案就在 GDHeroCharacter 中。

GDHeroCharacter

这个类派生自 GDCharacterBase,为游戏中玩家与 AI 直接控制的角色,相比于基类多了很多成员,这里只分析与 GAS 相关的一些函数:

输入绑定

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void AGDHeroCharacter::BindASCInput()
{
if (!ASCInputBound && AbilitySystemComponent.IsValid() && IsValid(InputComponent))
{
AbilitySystemComponent->BindAbilityActivationToInputComponent(InputComponent,
FGameplayAbilityInputBinds(
FString("ConfirmTarget"),
FString("CancelTarget"),
FString("EGDAbilityInputID"),
static_cast<int32>(EGDAbilityInputID::Confirm),
static_cast<int32>(EGDAbilityInputID::Cancel)
)
);

ASCInputBound = true;
}
}

前面说到,FGameplayAbilitySpec::FGameplayAbilitySpec 将技能与 AbilityInputID 联系了起来,而 BindASCInput() 则实现了输入与 AbilityInputID 之间的绑定,如此一来,输入与技能之间就通过 EGDAbilityInputID 这个桥梁,间接联系了起来。觉得不明白的读者可以参考 绑定输入到 ASC

ASC 的初始化

由于该项目具有联机功能,且 ASC 同时存在于 Character 与 PlayerState 上,因此,ASC 的初始化与输入绑定的时机就很重要了。

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Server only
void AGDHeroCharacter::PossessedBy(AController * NewController)
{
Super::PossessedBy(NewController);

AGDPlayerState* PS = GetPlayerState<AGDPlayerState>();
if (PS)
{
// Set the ASC on the Server. Clients do this in OnRep_PlayerState()
AbilitySystemComponent = Cast<UGDAbilitySystemComponent>(PS->GetAbilitySystemComponent());

// AI won't have PlayerControllers so we can init again here just to be sure.
// No harm in initing twice for heroes that have PlayerControllers.
PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);

// Set the AttributeSetBase for convenience attribute functions
AttributeSetBase = PS->GetAttributeSetBase();

// If we handle players disconnecting and rejoining in the future,
// we'll have to change this so that possession from rejoining doesn't reset attributes.
// For now assume possession = spawn/respawn.
InitializeAttributes();

AddStartupEffects();

AddCharacterAbilities();

// ...
}
}

// Client only
void AGDHeroCharacter::OnRep_PlayerState()
{
Super::OnRep_PlayerState();

AGDPlayerState* PS = GetPlayerState<AGDPlayerState>();
if (PS)
{
// Set the ASC for clients. Server does this in PossessedBy.
AbilitySystemComponent = Cast<UGDAbilitySystemComponent>(PS->GetAbilitySystemComponent());

// Init ASC Actor Info for clients. Server will init its ASC when it possesses a new Actor.
AbilitySystemComponent->InitAbilityActorInfo(PS, this);

// Bind player input to the AbilitySystemComponent.
// Also called in SetupPlayerInputComponent because of a potential race condition.
BindASCInput();

// Set the AttributeSetBase for convenience attribute functions
AttributeSetBase = PS->GetAttributeSetBase();

// If we handle players disconnecting and rejoining in the future, we'll have to change this so that posession from rejoining doesn't reset attributes.
// For now assume possession = spawn/respawn.
InitializeAttributes();

// ...
}
}

在 ASC 的初始化方面,作者选择了在 PossessedBy() 中初始化 Server 端的 ASC,在 OnRep_PlayerState() 中初始化 Client 端的 ASC,作者的解释是:在客户端,BeginPlay 要早于 Possession,因此在 BeginPlay 中不能执行任何 ASC 相关的逻辑,因为此时 PlayerState 还没有被同步到客户端。

c++
1
2
3
4
5
6
7
8
9
// Called to bind functionality to input
void AGDHeroCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
// ...

// Bind player input to the AbilitySystemComponent.
// Also called in OnRep_PlayerState because of a potential race condition.
BindASCInput();
}

此外,在 SetupPlayerInputComponent() 中也执行了 BindASCInput() 用于绑定输入到 ASC。

总结

本节主要分析了 GASDocumentation 中与 GAS 相关的几个重要的类,包括 GDAbilitySystemComponentGDAttributeSetBaseGDGameplayAbility 以及 GDCharacterBase。本来想继续写的,但是发现篇幅似乎有点长了,具体技能的分析就放到下一节吧!绝对不是要水文章数