4、GAS相关概念4.4 Attribute Set

4.4 Attribute Set

4.4.1 定义Attribute Set

AttributeSet 定义、持有并管理 Attributes 的变化。开发者应该从 UAttributeSet 继承。创建一个 AttributeSetOwnerActor 的构造函数中会自动将其注册到 ASC这必须在 C++ 中完成

4.4.2 设计Attribute Set

一个 ASC 可以有一个或多个 AttributeSetsAttributeSets 的内存开销可以忽略不计,因此使用多少个 AttributeSets 是开发者的组织决策。

可以接受的是拥有一个大型的单一 AttributeSet,它被游戏中的每个 Actor 共享,并且只在需要时使用属性,而忽略未使用的属性。

或者,你可以选择拥有多个 AttributeSet,代表 Attributes 的分组,并根据需要选择性地添加到你的 Actors 中。例如,你可以有一个用于健康相关 AttributesAttributeSet,一个用于法力相关 AttributesAttributeSet,等等。在 MOBA 游戏中,英雄可能需要法力,但小兵可能不需要。因此,英雄会获得法力 AttributeSet,而小兵则不会。

此外,AttributeSets 可以被子类化,作为选择 Actor 拥有的 Attributes 的另一种方式。Attributes 在内部被称为 AttributeSetClassName.AttributeName。当你子类化一个 AttributeSet 时,父类的所有 Attributes 仍然会以父类的名称作为前缀。

虽然你可以拥有多个 AttributeSet,但你不应该在一个 ASC 上拥有多个相同类的 AttributeSet。如果你有多个相同类的 AttributeSet,它将不知道使用哪个 AttributeSet,并且只会选择一个。

4.4.2.1 具有单独Attribute的子组件

在你有多个可损坏组件的 Pawn 场景中,比如单独可损坏的盔甲件,我建议如果你知道 Pawn 可能拥有的最大可损坏组件数量,则在一个 AttributeSet 上创建那么多健康 Attributes - DamageableCompHealth0, DamageableCompHealth1 等,以表示这些可损坏组件的逻辑“槽位”。在你的可损坏组件类实例中,分配可以被 GameplayAbilitiesExecutions 读取的槽位编号 Attribute,以知道将伤害应用到哪个 Attribute。拥有少于最大数量或零个可损坏组件的 Pawns 是可以的。仅仅因为一个 AttributeSet 有一个 Attribute,并不意味着你必须使用它。未使用的 Attributes 占用的内存量微不足道。

如果你的子组件每个需要许多 Attributes,可能有无限数量的子组件,子组件可以分离并被其他玩家使用(例如武器),或者由于任何其他原因这种方法对你不起作用,我建议切换离开 Attributes,而是将普通的浮点数存储在组件上。参见 物品属性

4.4.2.2 在运行时添加和移除AttributeSet

AttributeSets 可以在运行时从 ASC 中添加和移除;然而,移除 AttributeSets 可能是危险的。例如,如果一个 AttributeSet 在客户端上被移除,而在服务器上没有,并且一个 Attribute 值的变化被同步到客户端,Attribute 将找不到其 AttributeSet 并导致游戏崩溃。

在武器添加到库存时:

AbilitySystemComponent->GetSpawnedAttributes_Mutable().AddUnique(WeaponAttributeSetPointer);
AbilitySystemComponent->ForceReplication();

在武器从库存中移除时:

AbilitySystemComponent->GetSpawnedAttributes_Mutable().Remove(WeaponAttributeSetPointer);
AbilitySystemComponent->ForceReplication();

4.4.2.3 Item Attribute(武器弹药)

有几种方法可以实现具有 Attributes 的可装备物品(武器弹药、盔甲耐久性等)。所有这些方法都将值直接存储在物品上。这对于可以在其生命周期内由多个玩家装备的物品是必要的。

  1. 在物品上使用普通浮点数(推荐
  2. 在物品上使用单独的 AttributeSet
  3. 在物品上使用单独的 ASC

4.4.2.3.1 在Item上使用普通 Floats

代替 Attributes,在物品类实例上存储普通浮点值。Fortnite 和 GASShooter 以这种方式处理枪支弹药。对于枪支,直接在枪支实例上存储最大弹夹大小、弹夹中的当前弹药、储备弹药等为同步的浮点数(COND_OwnerOnly)。如果武器共享储备弹药,你可以将储备弹药移动到角色上,作为共享弹药 AttributeSet 中的一个 Attribute(重新加载能力可以使用 Cost GE 从储备弹药中拉取到枪支的浮点弹夹弹药中)。由于你没有使用 Attributes 来表示当前弹夹弹药,你将需要重写一些 UGameplayAbility 中的函数,以检查和应用对枪支浮点数的弹药成本。

为了防止枪支在自动射击期间同步回弹药量并覆盖本地弹药量,在 PreReplication() 中禁用同步,同时玩家拥有 IsFiring GameplayTag。你实际上是在这里进行自己的本地预测。

void AGSWeapon::PreReplication(IRepChangedPropertyTracker& ChangedPropertyTracker)
{
	Super::PreReplication(ChangedPropertyTracker);
 
	DOREPLIFETIME_ACTIVE_OVERRIDE(AGSWeapon, PrimaryClipAmmo, (IsValid(AbilitySystemComponent) && !AbilitySystemComponent->HasMatchingGameplayTag(WeaponIsFiringTag)));
	DOREPLIFETIME_ACTIVE_OVERRIDE(AGSWeapon, SecondaryClipAmmo, (IsValid(AbilitySystemComponent) && !AbilitySystemComponent->HasMatchingGameplayTag(WeaponIsFiringTag)));
}

优点:

  1. 避免使用 AttributeSets 的限制(见下文)

限制:

  1. 不能使用现有的 GameplayEffect 工作流(弹药使用的 Cost GEs 等)
  2. 需要工作来重写 UGameplayAbility 上的关键函数,以检查和应用对枪支浮点数的弹药成本

4.4.2.3.2 在物品上使用 AttributeSet

在物品上使用单独的 AttributeSet,并在将其添加到玩家的库存时将其 添加到玩家的 ASC 可以工作,但它有一些主要的限制。我在 GASShooter 的早期版本中为武器弹药实现了这一点。武器存储其 Attributes,如最大弹夹大小、弹夹中的当前弹药、储备弹药等在一个 AttributeSet 中,该 AttributeSet 存在于武器类上。如果武器共享储备弹药,你可以将储备弹药移动到角色中,作为共享弹药 AttributeSet 中的一个 Attribute。当武器在服务器上被添加到玩家的库存时,武器会将其 AttributeSet 添加到玩家的 ASC::SpawnedAttributes。然后服务器会将其同步到客户端。如果武器从库存中移除,它会从 ASC::SpawnedAttributes 中移除其 AttributeSet

AttributeSet 存在于 OwnerActor 之外的其他东西上(比如武器)时,你最初会在 AttributeSet 中遇到一些编译错误。解决方法是在 BeginPlay() 中构造 AttributeSet,而不是在构造函数中,并在武器上实现 IAbilitySystemInterface(在将武器添加到玩家库存时设置指向 ASC 的指针)。

void AGSWeapon::BeginPlay()
{
	if (!AttributeSet)
	{
		AttributeSet = NewObject<UGSWeaponAttributeSet>(this);
	}
	//...
}

你可以通过查看这个 GASShooter 的旧版本 来看到它的实际应用。

优点:

  1. 可以使用现有的 GameplayAbilityGameplayEffect 工作流(弹药使用的 Cost GEs 等)
  2. 对于非常小的一组物品,设置简单

限制:

  1. 你必须为每种武器类型创建一个新的 AttributeSet 类。由于 ASCs 只能在功能上拥有一个 AttributeSet 类的实例,因为对 Attribute 的更改会在 ASCsSpawnedAttributes 数组中查找其 AttributeSet 类的第一个实例。相同 AttributeSet 类的其他实例会被忽略。
  2. 由于前述原因,每种类型的武器在玩家的库存中只能有一个。
  3. 移除 AttributeSet 是危险的。在 GASShooter 中,如果玩家通过火箭自杀,玩家会立即从他的库存中移除火箭发射器(包括其 AttributeSetASC 中)。当服务器同步火箭发射器的弹药 Attribute 发生变化时,AttributeSet 不再存在于客户端的 ASC 上,游戏崩溃。

4.4.2.3.3 在物品上使用 ASC

在每个物品上放置一个完整的 AbilitySystemComponent 是一种极端的方法。我个人没有这样做过,也没有在实际中见过。要使其工作需要大量的工程工作。

是否可以拥有多个 AbilitySystemComponents,它们具有相同的所有者但不同的 avatars(例如在 pawn 和武器/物品/投射物上,所有者设置为 PlayerState)?

我看到的第一个问题是实现 IGameplayTagAssetInterfaceIAbilitySystemInterface 在拥有的 actor 上。前者可能是可能的:只需从所有 ASCs 中聚合标签(但要注意 - HasAllMatchingGameplayTags 可能仅通过跨 ASC 聚合来满足。仅将调用转发到每个 ASC 并将结果 OR 在一起是不够的)。但后者更棘手:哪个 ASC 是权威的?如果有人想应用一个 GE - 应该接收它的是哪个?也许你可以解决这些问题,但这个问题的这一方面将是最困难的:所有者将有多个 ASCs 在其下。

在 pawn 和武器上使用单独的 ASCs 本身可能是有意义的。例如,区分描述武器的标签与描述拥有的 pawn 的标签。也许确实有意义的是,授予武器的标签也“适用于”所有者,而不适用于其他任何东西(例如,属性和 GEs 是独立的,但所有者将聚合拥有的标签,如我上面描述的那样)。这可能会奏效,我相信。但拥有多个具有相同所有者的 ASCs 可能会变得棘手。

Dave Ratti 来自 Epic 的对 社区问题 #6 的回答

优点:

  1. 可以使用现有的 GameplayAbilityGameplayEffect 工作流(弹药使用的 Cost GEs 等)

  2. 可以重用 AttributeSet 类(每个武器的 ASC 上有一个)

局限:

  1. 未知的研发成本

  2. 方案是否可行?

4.4.3 定义Attribute

Attributes 只能在 C++ 中定义AttributeSet 的头文件中。建议在每个 AttributeSet 头文件的顶部添加这段宏块。它将自动为你的 Attributes 生成 getter 和 setter 函数。

// 使用 AttributeSet.h 中的宏
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
	GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
	GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
	GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
	GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)

一个同步的健康Attribute将这样定义:

UPROPERTY(BlueprintReadOnly, Category = "Health", ReplicatedUsing = OnRep_Health)
FGameplayAttributeData Health;
ATTRIBUTE_ACCESSORS(UGDAttributeSetBase, Health)

还要在头文件中定义 OnRep 函数:

UFUNCTION()
virtual void OnRep_Health(const FGameplayAttributeData& OldHealth);

AttributeSet 的 .cpp 文件应使用预测系统使用的 GAMEPLAYATTRIBUTE_REPNOTIFY 宏填充 OnRep 函数:

void UGDAttributeSetBase::OnRep_Health(const FGameplayAttributeData& OldHealth)
{
	GAMEPLAYATTRIBUTE_REPNOTIFY(UGDAttributeSetBase, Health, OldHealth);
}

最后,需要将 Attribute 添加到 GetLifetimeReplicatedProps

void UGDAttributeSetBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
 
	DOREPLIFETIME_CONDITION_NOTIFY(UGDAttributeSetBase, Health, COND_None, REPNOTIFY_Always);
}

REPNOTIFY_Always 告诉 OnRep 函数如果本地值已经等于从服务器同步下来的值(由于预测),则触发。默认情况下,如果本地值与从服务器同步下来的值相同,它不会触发 OnRep 函数。

如果 Attribute 没有被同步,比如 Meta Attribute,则可以跳过 OnRepGetLifetimeReplicatedProps 步骤。

4.4.4 初始化Attributes(Initializing Attributes)

有多种方法可以初始化 Attributes(将其 BaseValue 和因此其 CurrentValue 设置为某个初始值)。Epic 推荐使用即时 GameplayEffect。这也是示例项目中使用的方法。

请参见示例项目中的 GE_HeroAttributes Blueprint,了解如何制作一个即时 GameplayEffect 来初始化 Attributes。此 GameplayEffect 的应用在 C++ 中进行。

如果你在定义 Attributes 时使用了 ATTRIBUTE_ACCESSORS 宏,则会在 AttributeSet 上自动生成一个初始化函数,你可以在 C++ 中随意调用。

// InitHealth(float InitialValue) 是为使用 `ATTRIBUTE_ACCESSORS` 宏定义的属性 'Health' 自动生成的函数
AttributeSet->InitHealth(100.0f);

请参见 AttributeSet.h 了解更多初始化 Attributes 的方法。

注意: 在 4.24 之前,FAttributeSetInitterDiscreteLevels 不适用于 FGameplayAttributeData。它是在 Attributes 是原始浮点数时创建的,并会抱怨 FGameplayAttributeData 不是 Plain Old Data (POD)。这在 4.24 中已修复 https://issues.unrealengine.com/issue/UE-76557。

4.4.5 PreAttributeChange()

PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)AttributeSet 中的主要函数之一,用于在 AttributeCurrentValue 发生变化之前响应变化。它是夹住通过引用参数 NewValueCurrentValue 变化的理想位置。

例如,为了夹住移动速度修饰符,示例项目是这样做的:

if (Attribute == GetMoveSpeedAttribute())
{
	// 不能低于 150 单位/秒,也不能超过 1000 单位/秒
	NewValue = FMath::Clamp<float>(NewValue, 150, 1000);
}

GetMoveSpeedAttribute() 函数是我们添加到 AttributeSet.h 的宏块创建的(定义属性)。

这从任何对 Attributes 的更改中触发,无论是使用 Attribute 设置器(由 AttributeSet.h 中的宏块定义(定义属性))还是使用 GameplayEffects

注意: 这里发生的任何夹住不会永久更改 ASC 上的修饰符。它只会更改从查询修饰符返回的值。这意味着任何从所有修饰符重新计算 CurrentValue 的东西,比如 GameplayEffectExecutionCalculationsModifierMagnitudeCalculations 需要再次实现夹住。

注意: Epic 对 PreAttributeChange() 的评论说不要将其用于游戏事件,而主要用于夹住。推荐的 Attribute 变化游戏事件位置是 UAbilitySystemComponent::GetGameplayAttributeValueChangeDelegate(FGameplayAttribute Attribute) (响应Attribute变化)。

4.4.6 PostGameplayEffectExecute()

PostGameplayEffectExecute(const FGameplayEffectModCallbackData & Data) 仅在从即时 GameplayEffectAttributeBaseValue 进行更改后触发。这是当 AttributesGameplayEffect 变化时进行更多 Attribute 操作的有效位置。

例如,在示例项目中,我们在这里从健康 Attribute 中减去最终伤害 Meta Attribute。如果有一个护盾 Attribute,我们会先从中减去伤害,然后再从健康中减去剩余部分。示例项目还在此位置应用命中反应动画、显示浮动伤害数字,并将经验和金币赏金分配给杀手。根据设计,伤害 Meta Attribute 将始终通过即时 GameplayEffect 传递,而不是 Attribute 设置器。

其他仅从即时 GameplayEffects 更改其 BaseValueAttributes,如法力和耐力,也可以在此处夹住到其最大值对应的 Attributes

注意:PostGameplayEffectExecute() 被调用时,对 Attribute 的更改已经发生,但尚未同步回客户端,因此在此处夹住值不会导致对客户端的两次网络更新。客户端只会在夹住后接收更新。

4.4.7 OnAttributeAggregatorCreated()

OnAttributeAggregatorCreated(const FGameplayAttribute& Attribute, FAggregator* NewAggregator) 在为此集合中的 Attribute 创建 Aggregator 时触发。它允许自定义设置 FAggregatorEvaluateMetaDataAggregatorEvaluateMetaDataAggregator 用于根据应用于它的所有 Modifiers 评估 AttributeCurrentValue。默认情况下,AggregatorEvaluateMetaData 仅由 Aggregator 用于确定哪些 Modifiers 符合条件,例子是 MostNegativeMod_AllPositiveMods,它允许所有正 Modifiers,但限制负 Modifiers 仅为最负的一个。这是由 Paragon 使用的,以便无论玩家身上有多少减速效果,始终只允许最负的移动速度减速效果应用于玩家,同时应用所有正的移动速度增益。那些不符合条件的 Modifiers 仍然存在于 ASC 上,它们只是没有被聚合到最终的 CurrentValue 中。它们可能在条件改变后符合条件,比如在最负的 Modifier 过期的情况下,下一个最负的 Modifier(如果存在)则符合条件。

要在仅允许最负的 Modifier 和所有正的 Modifiers 的示例中使用 AggregatorEvaluateMetaData:

virtual void OnAttributeAggregatorCreated(const FGameplayAttribute& Attribute, FAggregator* NewAggregator) const override;
void UGSAttributeSetBase::OnAttributeAggregatorCreated(const FGameplayAttribute& Attribute, FAggregator* NewAggregator) const
{
	Super::OnAttributeAggregatorCreated(Attribute, NewAggregator);
 
	if (!NewAggregator)
	{
		return;
	}
 
	if (Attribute == GetMoveSpeedAttribute())
	{
		NewAggregator->EvaluationMetaData = &FAggregatorEvaluateMetaDataLibrary::MostNegativeMod_AllPositiveMods;
	}
}

你的自定义 AggregatorEvaluateMetaData 应该作为静态变量添加到 FAggregatorEvaluateMetaDataLibrary 中。