GAS精粹
项目地址: https://github.com/liuke101/ProjectGASRPG
GAS 插件
GAS 由多个组件构成:
- **
Ability System Component
**:核心组件,由 C++ 编写,维持所属 Actor 拥有的所有技能的列表,并处理激活。 Gameplay Ability
,表示角色的技能,包括攻击、疾跑、施法、翻滚、使用道具等,但不包括基础移动和 UI- 由
Gameplay Ability Tasks
以及其他函数构成。
- 由
Attribute Set
,附加到 ASC。- 包含
GameplayAttribute
,用于驱动计算或表示资源。
- 包含
Gameplay Effects
,处理 Actor 因使用技能而发生的属性更改。Gameplay Effect Calculations
,提供模块化、可复用的方法来计算效果。GameplayCue
,与 GE 关联,并提供数据驱动的方法来处理特效、音效。
GAS 指在处理所有这些用例,方法是将技能建模为负责自身执行的完全独立的实体。
- 谁可以释放技能? 持有 ASC 组件的 Actor
- 如何编写技能逻辑? 使用 GameAbility
- 如何技能效果? 使用 GameEffect (属性修改、增减 Buff, 并不是特效)
- 技能改变的什么属性? 使用 GamePlayAttribute 属性系统
- 技能释放的条件? 使用 GameplayTag 进行条件判断
- 技能的视角表现效果? 使用 GameplayCue
- 技能的长时行动? 使用 GameplayTask
- 技能消息事件? 使用 GameplayEvent
在多人游戏中, GAS提供客户端预测支持:
- Ability 激活
- 播放蒙太奇
- 对
Attribute
的修改 - 应用
GameplayTag
- 生成
GameplayCue
- 通过连接于
CharacterMovementComponent
的RootMotionSource
函数形成的移动
0 开启 GAS
使用GAS建立一个项目的基本步骤:
- 在编辑器中启用 GameplayAbilitySystem 插件.。
- 编辑
YourProjectName.Build.cs
, 添加"GameplayAbilities"
,"GameplayTags"
,"GameplayTasks"
到你的PrivateDependencyModuleNames
. - 刷新/重新生成Visual Studio项目文件.
- 从4.24开始, 需要强制调用
UAbilitySystemGlobals::InitGlobalData()
来使用TargetData
, 样例项目在UEngineSubsystem::Initialize()
中调用该函数. 参阅InitGlobalData()
获取更多信息.
这就是你启用GAS所需做的全部了. 从这里开始, 添加一个ASC
和AttributeSet
到你的Character
或PlayerState
, 并开始着手GameplayAbility
和GameplayEffect
!
1 Ability System Component
ASC 是 GAS 的核心组件(是一个 UActorComponent),用于处理整个框架下的交互逻辑,交互对象包括:
- GameAbility
- GameplayEffect
- GameplayAttribute
从上图的继承关系可以发现, ASC 继承自
UGameplayTasksComponent
, 所以它具有执行 Task 的能力; 同时实现了多个接口
所有期望使用 GA, 包含 Attribute, 或者接受 GE 的 Actor 都必须附加 ASC
. 这些对象都存于 ASC
并由其管理和复制(除了由 AttributeSet
复制的 Attribute
)
ASC
附加的Actor
被引用作为该ASC
的 **OwnerActor
**(逻辑上拥有这个 ASC 的 Actor)- 该
ASC
的物理代表Actor
被称为 **AvatarActor
**(通常是 Pawn,但也可以是塔楼、建筑、炮塔等). OwnerActor
和AvatarActor
可以是同一个Actor
, 比如 AIController 控制的敌人。- 它们也可以是不同的
Actor
, 比如玩家控制的 Character, 其中OwnerActor
是PlayerState
,AvatarActor
是Character
类。 - **绝大多数 Actor 的
ASC
都附加在其自身, 如果你的 Actor 会重生并且重生时需要持久化Attribute
或GameplayEffect
, 那么ASC
理想的位置就是PlayerState
*。 - 技能间的互相作用其实就是一个 Actor 上的 ASC 作用到另一个 Actor 上的 ASC
Pawn 被销毁时,身上的 ASC 和属性集也会被销毁。重生后 ASC 和属性集也是新创建的,为默认值。如果使用 PlayState 则可以保存数据!
比如怪物不需要持久化数据,我们可以直接在怪物自身的 Actor 上使用 ASC。而玩家角色需要持久化数据,我们就要使用 PlayState。
IAbilitySystemInterface
OwnerActor
需要继承并实现 **IAbilitySystemInterface
**(如果 AvatarActor 和 OwnerActor 是不同的 Actor, 那么 AvatarActor 也应该继承并实现 IAbilitySystemInterface
)。
- 该接口只有一个必须重写的 Getter 函数:
UAbilitySystemComponent* GetAbilitySystemComponent() const
, 其返回一个指向ASC
的指针,ASC
通过寻找该接口函数来和系统内部进行交互. UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(AActor *Actor)
方法也可以获取实现了该接口函数的 Actor 的 ASC1
2
3
4
5
6
7
8//常规方法:获取实现了接口函数的 Actor 的 ASC
if (IAbilitySystemInterface* ASInterface = Cast<IAbilitySystemInterface>(TestActor))
{
UAbilitySystemComponent* ASC = ASInterface->GetAbilitySystemComponent()
}
//⭐使用ASC蓝图库:更方便,若TestActor本身没有实现接口,还会遍历组件
UAbilitySystemComponent* ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(TestActor);
OwnerActor 与 AvatarActor 实现接口
项目中角色相关的类有三个,抽象基类 MageCharacterBase
,该基类由两个子类:
由 PlayerController 控制的
MageCharacter
(后文简称玩家角色)由 AIController 控制的
MageEnemy
。(后文简称敌人角色)MageCharacter
类需要持久化 Attribute 和 GE 数据,故采用 OwnerActor 和 AvatarActor 分离的方式:MagePlayerState
为 OwnerActor,MageCharacter
为 AvatarActorMageEnemy
类采用 OwnerActor 和 AvatarActor 合一的方式,OwnerActor, AvatarActor 都是自身
**OwnerActor 和 AvatarActor 都需要继承并实现 IAbilitySystemInterface
**,我们在角色基类中继承了 IAbilitySystemInterface
(这样子类 MageCharacter,MageEnemy 也完成了继承), 我们只需要再在 MagePlayerState 中继承即可。继承后需要实现 GetAbilitySystemComponent ()
方法
1 | //后文的 AMageCharacter、AMageEnemy 都继承自该基类 AMageCharacterBase |
1 | CLASS() |
创建
ASC
一般在 OwnerActor
的构造函数中创建并且多人游戏需要明确标记为 Replicated
. 必须在 C++中完成.
对于玩家角色,OwnerActor 为 PlayerState,即在 PlayerState 中创建 ASC:
1 | AMagePlayerState::AMagePlayerState() |
对于敌人角色,OwnerActor 为自身:
1 | AMageEnemy::AMageEnemy() |
初始化
OwnerActor
和 AvatarActor
的 ASC
在服务端和客户端上均需初始化,你应该在 Pawn
的 Controller
设置之后(Possess 之后)进行初始化, 单人游戏只需参考服务端的做法。
- 对于玩家角色且
ASC
位于自身(项目采用的是分离方案):- 在服务端
Pawn
的PossessedBy()
函数中初始化, - 在客户端
PlayerController
的AcknowledgePossession()
函数中初始化.1
2
3
4
5
6
7
8
9
10
11
12void MyCharacterBase::PossessedBy(AController * NewController)
{
Super::PossessedBy(NewController);
if (AbilitySystemComponent)
{
AbilitySystemComponent->InitAbilityActorInfo(this, this);
}
// ASC MixedMode replication requires that the ASC Owner's Owner be the Controller.
SetOwner(NewController);
}
- 在服务端
1 | void MyPlayerControllerBase::AcknowledgePossession(APawn* P) |
- @ ⭐对于玩家角色且
ASC
位于PlayerState
- 在服务端
Pawn
的PossessedBy()
函数中初始化, - 在客户端 PlayerController 的
OnRep_PlayerState()
函数中初始化 (在 Pawn 的OnRep_PlayerState()
也可以), 这将确保PlayerState
成功从服务器复制到客户端
- 在服务端
1 | /** 服务器初始化 */ |
如果你遇到了错误消息 LogAbilitySystem: Warning: Can't activate LocalOnly or LocalPredicted Ability %s when not local!
, 那么就表明 ASC
没有在客户端中初始化.
- @ ⭐对于敌人角色 ASC 位于自身,只需要在
BeginPlay()
中初始化 `1
2
3
4
5
6
7
8
9
10
11
12
13
14
15void AGDEnemy::BeginPlay()
{
Super::BeginPlay();
InitASC();
}
void AMageEnemy::InitASC()
{
if(AbilitySystemComponent == nullptr) return;
/** 初始化ASC */
AbilitySystemComponent->InitAbilityActorInfo(this, this);
...
}
FGameplayAbilityActorInfo
用于缓存 Ability 需要经常访问的 owning Actor 数据(Movement Component, Mesh Component, Anim Instance 等)。
Abilities 使用它来了解要对哪个 actor 进行操作,而不是与特定的 actor 类耦合。
项目可以重载 UAbilitySystemGlobals::AllocAbilityActorInfo
来创建的默认结构类型。
1 | //缓存的所有数据类型: |
两个容器
ASC 维护两个容器:
- 在
FActiveGameplayEffectContainer ActiveGameplayEffect
中保存其当前已经 Apply 的GE
- 在
FGameplayAbilitySpecContainer ActivatableAbility
中保存其已经授予的(GiveAbility)GA
ABILITYLIST_SCOPE_LOCK
当你想遍历 ActivatableAbility
时, 确保在循环体之上添加 ABILITYLIST_SCOPE_LOCK();
来锁定列表以防其改变 (比如移除一个 Ability).
1 | //GameplayAbilitySpec.h中的一个宏定义 |
FScopedAbilityListLock
用于阻止我们在迭代 Ability 时从 ASC 中移除 Ability
- 每个域中的
ABILITYLIST_SCOPE_LOCK()
会增加AbilityScopeLockCount
, 之后出域时会减量. - 不要尝试在
ABILITYLIST_SCOPE_LOCK()
域中移除某个 Ability (Ability 删除函数会在内部检查AbilityScopeLockCount
以防在列表锁定时移除 Ability).
示例:
1 | void UMageAbilitySystemComponent::ForEachAbility(const FForEachAbilityDelegate& AbilityDelegate) |
复制模式
ASC
定义了三种不同的复制模式用于复制GameplayEffect
到客户端,GameplayTag
和GameplayCue
:Full
,Mixed
和Minimal
.Attribute
由其AttributeSet
复制。
复制模式 | 何时使用 | 描述 |
---|---|---|
Full |
单人 | GameplayEffect 复制到所有客户端 |
Mixed |
多人, PlayerController控制的Actor | GameplayEffect 只复制到其所属(owning)客户端; GameplayTag 和GameplayCue 复制到所有客户端 |
Minimal |
多人, AIController控制的Actor | GameplayEffect 不复制;GameplayTag 和GameplayCue 复制到所有客户端. |
2023.10.6 根据 GAS 视频教程修正描述
Mixed
模式要求OwnerActor
的Owner
必须是Controller
。Pawn
在PossessedBy()
中自动设置,PlayerState
的Owner
默认是Controller
,但是Character
不是。- 如果
OwnerActor
不是PlayerState
时使用Mixed
模式, 那么需要使用PossessedBy()
设置新的Controller
为Pawn
的 Owner。PossessedBy()
:当该 Pawn 被 possess 时调用。仅在服务器(或单机)上调用。
NetUpdateFrequency
如果 ASC
位于 PlayerState, 那么你需要提高 PlayerState 的 NetUpdateFrequency
, 其默认是一个很低的值(在 PlayerState 构造函数中设为 100.0f 即可), 因此在客户端上发生 Attribute
和 GameplayTag
改变时会造成延迟或卡顿. 确保启用 Adaptive Network Update Frequency, Fortnite 就启用了该项.
1 | //在PlayerState构造函数中设置 |
2 Gameplay Tags
Gameplay Tags 有助于确定玩法技能之间的交互方式。每种技能都拥有一组标记,以可影响其行为的方式识别和分类技能,还有玩法标记容器和游戏标记查询,用于支持与其他技能进行交互。
FGameplayTag
是由 GameplayTagManager
注册的形似 Parent.Child.Grandchild...
的层级 FName
, 这些标签对于分类和描述对象的状态非常有用, 例如, 如果某个 Character 处于眩晕状态, 我们可以给一个 State.Debuff.Stun
的 GameplayTag
.
你会发现自己使用 GameplayTag
替换了过去使用布尔值或枚举值来编程, 并且需要对对象有无特定的 GameplayTag
做布尔逻辑判断. 比如 AI 在行为树的 Decorator 节点中,通过玩家拥有的 Gameplay Tag 来判断玩家是否处于无敌帧、喝药。
当给某个对象设置标签时, 如果它有 ASC
的话, 我们一般添加标签到 ASC
以与其交互。 UAbilitySystemComponent
继承 IGameplayTagAssetInterface
接口的函数来访问其拥有的 GameplayTag
.
IGameplayTagAssetInterface
如果 Actor 想要使用 FGameplayTagContainer 来保存 GameplayTag,需要继承 IGameplayTagAssetInterface
接口。
- 该 Actor 必须重载 Getter 函数
GetOwnedGameplayTags
,返回 Actor 中的FGameplayTagContainer
- 同时 Actor 会继承三个标签匹配函数,用于对 TagContainer 存储的标签进行匹配
1 |
|
FGameplayTagContainer
[!quote]
- 跟踪 Tag 应用的数量(次数)结构体。明确跟踪添加或删除的 Tag, 同时跟踪 Parent Tag 的数量。
- 每当任何 Tag(或其 Parent Tag)的标签计数被修改时,都会触发事件/委托。
多个 GameplayTag
可被保存于一个 FGameplayTagContainer
中, 内部封装了 TArray<FGameplayTag>
。不建议自定义 TArray 来保存 Tag, 因为 GameplayTagContainer
做了一些很有效率的优化,并且为我们实现了很多标签匹配相关的功能函数。
GameplayTagContainer
或 ASC 可以使用GetGameplayTagArray
函数返回指定FGameplayTag
的 TArray 用于遍历。GameplayTagContainer
使用GameplayTagCountMap
保存了每个GameplayTag
和其实例数量的映射,可以通过GetTagCount
访问实例数量。(或 ASC 得GetGameplayTagCount
函数)- 因为标签是标准的
FName
, 所以当在项目设置中启用Fast Replication
后, 它们可以高效地打包进FGameplayTagContainer
以用于复制。Fast Replication
要求服务端和客户端有相同的GameplayTag
列表, 这通常不是问题, 因此你应该启用该选项。 - 除了
Fast Replication
,GameplayTag
编辑器可以选择填充普遍需要复制的GameplayTag
以对其深度优化.
Tag 变化委托
FGameplayTagContainer
实现了两个委托返回函数,可以绑定这两个委托监听 Tag 变化(ASC 对其进行了封装,可以通过 ASC 调用)
1 | //监听指定Tag的变化 |
ASC 使用该委托监听 Tag 变化的例子:
监听 Effects_HitReact
Tag 的变化,来决定是否应用受击反馈
1 | /** 受击反馈, 当角色被GE授予 Tag 时触发 */ |
1 | void AMageEnemy::HitReactTagChanged(const FGameplayTag CallbackTag, const int32 NewCount) |
获取 Tag
C++中获取 GameplayTag
引用:
1 | FGameplayTag::RequestGameplayTag(FName("Your.GameplayTag.Name")) |
对于像获取父或子 GameplayTag
的高阶操作, 请查看 GameplayTagManager
提供的函数.
网络复制与 LooseGameplayTag
Loose:松散,意思就是这个 Tag 不是通过 GE 授予得 Tag,而是直接添加到 ASC 的。
如果 GameplayTag
由 GameplayEffect
添加, 那么其就是可复制的。
ASC
允许你添加不可复制的 LooseGameplayTag
且必须手动管理 TagCount 。ASC 为我们提供了一些函数来更新 LooseGameplayTag 数量:
1 | void AddLooseGameplayTag(const FGameplayTag& GameplayTag, int32 Count=1) |
过滤Tag
GameplayTag
和 GameplayTagContainer
有可选 UPROPERTY 元数据标签 Meta = (Categories = "GameplayCue")
用于在蓝图中过滤标签而只显示父标签为 GameplayCue
的 GameplayTag
, 当你知道 GameplayTag
或 GameplayTagContainer
变量应该只用于 GameplayCue
时, 这将是非常有用的.
作为选择, 有一单独的FGameplayCueTag
结构体可以包裹FGameplayTag
并且可以在蓝图中自动过滤GameplayTag
而只显示父标签为GameplayCue
的标签.
通过 C++ 创建和管理 Tag
使用 C++来创建和管理 Tag 可以使得代码更加灵活,这是除了使用 ini 或数据表配置 Tag 的另外一种方式。重要的是,我们可以在 C++中访问这些 Tag!
实现步骤如下:
- 创建饿汉式单例 C++ 类
FMageGameplayTags
1 |
|
1 | /** 全局变量,但是由于TagsInstance是private成员,其他类访问不到。所以这里只起到初始化静态成员变量的作用(创建实例) */ |
- 创建
UMageAssetManager
类继承UAssetManager
,调用InitNativeGameplayTags()
;
1 |
|
1 | UMageAssetManager& UMageAssetManager::Get() |
- 设置引擎的资产管理器类
或通过 ini 设置:title:DefaultEngine.ini 1
2
3
4
5[/Script/Engine.Engine]
//...
//添加 = /Script/项目名/资产管理类名
AssetManagerClassName = /Script/ProjectGASRPG.MageAssetManager
完成后,就可以在项目设置的 GameplayTagList 中查看到添加的 GameplayTag。
3 Attribute
01 定义
Attribute
是由 FGameplayAttributeData
结构体定义的浮点值,其可以表示生命值、等级、药水回复量等, 如果某项数值是属于某个 Actor 且游戏相关的, 你就应该考虑使用 Attribute
。
Attribute
一般应该只能由 GE
修改, 这样 ASC
才能预测(Predict)其改变. [[#与 Gameplay Effects 互动]]
![[GAS精粹#^nwhtjz]]
Attribute
也可以由 AttributeSet
定义并存于其中.。**AttributeSet
用于复制那些标记为 Replication 的 Attribute
**
[!tip]
如果你不想某个Attribute
显示在编辑器的Attribute
列表, 可以使用Meta = (HideInDetailsView)
元属性
02 BaseValue 与 CurrentValue
FGameplayAttributeData
定义了 BaseValue 和 CurrentValue 。并提供获取他们的函数。
一个 Attribute
是由两个值 : BaseValue
和 CurrentValue
组成的。
BaseValue
是Attribute
的永久值CurrentValue
是BaseValue
加上GameplayEffect
给的临时修改值后得到的。例如, 你的 Character 可能有一个
BaseValue
为600的移动速度Attribute
,BaseValue
和CurrentValue
为600。如果 Character 通过GE
获得了一个50 的移动速度加成, 那么BaseValue
仍然是600 ,而CurrentValue
是600+50=650, 当该移动速度加成消失后,CurrentValue
就会变回BaseValue
的600。
[!bug]
不能将BaseValue
作为Attribute
的最大值使用!可以修改或引用的 Ability/UI 中的Attribute
最大值应该是另外单独的一个Attribute
。
对于硬编码的最大值和最小值, 有一种方法是使用可以设置最大值和最小值的 FAttributeMetaData
定义一个 DataTable [[#使用数据表初始化(不推荐)]], 但是 Epic 在该结构体上的注释称之为”work in progress”, 详见 AttributeSet.h
.
为了避免这种疑惑, 我建议引用在 Ability 或 UI 中的最大值应该单独定义 Attribute
, 只用于限制(Clamp) Attribute
大小的硬编码最大值和最小值应该在 AttributeSet
中定义为硬编码浮点值。
关于 Clamp
Attribute
值的问题在 [[#06PreAttributeChange()
]] 中讨论了 CurrentValue 的修改, 在 [[#07PostGameplayEffectExecute()
]] 中讨论了GameplayEffect
对BaseValue
的修改。**
DurationType 与 Value 的关系:
(Instant)GE
可以永久性的修改BaseValue
(Periodic)GE
被视为(Instant)GE
并且可以修改BaseValue
。(Duration)GE
和(Infinite)GE
可以修改CurrentValue
.
03 MetaAttribute
首先说明,MetaAttribute 并没有对应的类,而只是一种特殊的 Attribute
,和其他属性一样都是 FGameplayAttributeData
。
我们通常定义伤害值为 MetaAttribute
, 项目中称为 MetaDamage
。使用 MetaDamage
作为临时占位符(可以理解为中间值,temp 值) , 而不是使用 GE
直接修改生命值属性, 使用这种方法,允许我们在造成伤害之前执行所有的数学运算(在 Execution Calculation 中计算,在属性集中处理)。处理结束后 MetaAttribute 归零。
伤害值就可以在
Execution(GameplayEffectExecutionCalculation)
中由 buff 和 debuff 修改, 并且可以在AttributeSet
中进一步操作, 例如, 在最终将生命值减去伤害值之前, 要将伤害值减去当前的护盾值.
- 伤害值
MetaAttribute
在GE
之间不是持久化的, 并且可以被任何一方重写。 MetaAttribute
是不可复制的.MetaAttribute
对于在”我们应该造成多少伤害?”和”我们该如何处理伤害值?”这种问题之中的伤害值和治疗值做了很好的解构, 这种解构意味着GE
和Execution Calculation
无需了解目标是如何处理伤害值的。
尽管 MetaAttribute
是一个很好的设计模式, 但其并不是强制使用的。如果你只有一个用于所有伤害实例的 Execution Calculation
和一个所有 Character 共用的 AttributeSet
类, 那么你就可以在 Exeuction Calculation
中分配伤害到生命, 护盾等等, 并直接修改那些 Attribute
, 这种方式你只会丢失灵活性, 但总体上并无大碍.
使用方法
项目中使用元属性计算伤害值和获得的经验值,下面以伤害值元属性 MetaDamage
为例:
在 AttributeSet 中创建 MetaAttribute(不需要设置复制)
1
2
3
4
5
6
7
8
9
10
11
12
13
14UCLASS()
class PROJECTGASRPG_API UMageAttributeSet : public UAttributeSet
{
GENERATED_BODY()
public:
/** Meta Attributes */
UPROPERTY(BlueprintReadOnly, Category = "Mage_Attributes|Meta")
FGameplayAttributeData MetaDamage;
ATTRIBUTE_ACCESSORS(UMageAttributeSet, MetaDamage)
}我们需要使用 GE 修改 MetaDamage 的值。首先建立一个 GE 蓝图,项目中命名为
GE_ExecCalc_Damage
。从名字也可以看出来,我们使用了 Execution Calculation 的方法计算伤害值。详细用法可见: [[#12 Execution Calculation]]- GE 设置如下:
- 其中 Calculation Class 指定我们创建的 C++类
ExecCalc_Damage
- 使用 Execution Calculation 方法允许我们捕获 GE 源对象和目标对象的 Attribute,从而我们可以知道玩家角色的攻击力和敌人角色的防御力等信息,完成伤害值的数学运算。
- 其中 Calculation Class 指定我们创建的 C++类
- GE 设置如下:
1 | void UExecCalc_Damage::Execute_Implementation(const FGameplayEffectCustomExecutionParameters& ExecutionParams, |
- 完成伤害计算后,我们可以在属性集
PostGameplayEffectExecute
函数中GetMetaDamage()
,进而判断角色是否受伤,是否死亡。 - 最后记得将元属性清零,防止干扰下一次伤害计算。
1 | void UMageAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) |
04 派生属性 Derived Attribute
**要使 Attribute
的部分或全部值派生于(继承)一个或多个其他 Attribute
, 可以使用基于一个或多个 Attribute
或 MMC Modifiers 的 (Infinite)GE
**。当 Derived Attribute
依赖的 Attribute
更新时它也会自动更新。
在 Derived Attribute
上的所有 Modifier
形成的最终公式和 Modifier Aggregators
的公式是一样的。 如果您需要按特定顺序进行计算,请在 MMC
内完成所有操作。
1 | ((CurrentValue + Additive) * Multiplicitive) / Division |
Backing : 备份的意思
一个关于对派生属性应用 (Infinite)GE
的 BUG(此 BUG 只影响 PIE,打包后正常):
本项目应用名为 GE_Secondary_Attributes
的 (Infinite)GE
,基于主属性来改变副属性。当主属性发生变化时,该 GE 会将其 Modifiers 应用到副属性中。这在单人游戏中有效。
但在多人游戏中,使用派生属性的 (Infinite)GE
只对最后进入游戏的客户端应用其 Modifiers。 无论 (Infinite)GE
是否应用于所有玩家,也无论是否应用于服务器,这种情况都会发生。由于 (Infinite)GE
无法应用于所有玩家,因此这对多人游戏来说并不理想,您需要以不同的方式实现派生属性。
最简单的解决方法是在 PostGameplayEffectExecute
中检查 Primary Attributes 的变化,并在此应用您对 Secondary Attributes 的自定义修改(本项目就采用这种方法更新副属性,舍弃了 MMC 方案, MMC 方案也会有派生属性的这个 BUG???)
1 | /** 根据 Primary Attribute 的变化更新 Secondary Attributes */ |
在这里,由于您可以访问 owning player 的相关类(包括 PlayerState),您可以在这里考虑任何值,例如玩家的等级等。这在多人游戏中适用于所有客户端和服务器上的 hosting player。实际上,这比创建 Infinite Duration Gameplay 和相应的 MMC 要省事得多。
[!BUG]
如果在 PIE 中打开多个窗口, 你需要在编辑器首选项中禁用Run Under One Process
, 否则当自动推导Attribute
所依赖的Attribute
更新时, 除了第一个窗口外其不会更新.
4 AttributeSet
[!NOTE]
只能用 C++ 中创建属性和属性集
GAS 主要通过 属性集(AttributeSet) 与 Actor 交互,其中包含 Gameplay Attribute。
使用 Attribute 可带来多项优势:
- 属性集提供了一组一致、可复用的属性,可用于构建系统。
- GA 可以通过反射访问 GameplayAttribute,以便可以直接在蓝图编辑器中创建简单的计算和效果。
- GameplayAttribute 会单独追踪 Base Value 和 Current Value,这样就更容易创建临时修改(增益和减益)以及持久效果。
- GameplayAttribute 还会将其值复制到所有客户端,适合直观地显示敌方血条等本地 UI。
使用多个属性集是完全可能的应用场景,比如玩家和敌人都拥有同一个属性集,包含 Health, Mana, Attack Damage, Defense。而玩家拥有一个额外的属性集包含常见的 RPG 属性,比如 Strength, Intelligence, Constitution 等等。很棒的一点是系统让你能够混用这些属性集,因为属性集注册到 ASC 后,系统可以自动识别属性集属于哪个ASC。
[!bug]
在某些情况下,GameplayAttribute 可以在没有属性集的情况下存在。这通常表明,某个 GameplayAttribute 被保存在一个 ASC 上,而这个组件没有一个包含适当类型 GameplayAttribute 的属性集。这种方法并不推荐,因为这种 GameplayAttribute 除了作为浮点值保存外,不会与 GAS 的任何部分交互。
01 注册 AttributeSet 到 ASC
AttributeSet
继承自 UAttributeSet
,用于定义, 保存以及管理对 Attribute
的修改。在 OwnerActor
的构造函数中创建的 AttributeSet
将会自动注册到 ASC
。在此之后,ASC
可以访问你分配给 AttributeSet
的属性。
必须在 C++中注册:
继承属性集类
UAttributeSet
,重载两个重要的函数:1
2
3
4
5
6
7
8
9
10
11
12UCLASS()
class PROJECTGASRPG_API UMageAttributeSet : public UAttributeSet
{
GENERATED_BODY()
public:
UMageAttributeSet();
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override;
virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
}在 OwnerActor 和 AvatarActor 上创建属性集,Getter 函数使用
const
关键字 来确保代码不能直接修改属性集:
- 对于玩家角色类,在 PlayerState 构造函数中创建属性集
title:在OwnerActor中创建属性集 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class PROJECTGASRPG_API AMagePlayerState : public APlayerState, public IAbilitySystemInterface
{
public:
AMagePlayerState();
//为了方便获取属性集,创建一个Getter函数
FORCEINLINE virtual UAttributeSet* GetAttributeSet() const { return AttributeSet; };
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
TObjectPtr<UAttributeSet> AttributeSet;
}
AMagePlayerState::AMagePlayerState()
{
AttributeSet = CreateDefaultSubobject<UMageAttributeSet>(TEXT("AttributeSet"));
}
在 AvatarActor 上拷贝 MagePlayerState 中的属性集,即 OwnerActor 和 AvatarActor 的属性集是相同的。前文 ASC 的创建也是这样实现。
1 | class PROJECTGASRPG_API AMageCharacterBase : public ACharacter, public IAbilitySystemInterface |
- 对于敌人角色类,直接在自身构造函数中创建
1
2
3
4AMageEnemy::AMageEnemy()
{
AttributeSet = CreateDefaultSubobject<UMageAttributeSet>(TEXT("AttributeSet"));
}
[!warning] 注意
- 一个
ASC
可以有多个属性集,但每个属性集必须与所有其它属性集的类不同。- 如果使用
GE
来修改ASC
没有的GamplayAttribute
,这样做会使技能系统组件为自己创建一个匹配的游戏玩法属性。然而,这个方法并不会创建一个属性集,也不会将游戏玩法属性添加到任何现有的属性集中。
02 定义 Attribute
Attribute 只能使用 C++ 在 AttributeSet 头文件中定义.
建议把下面这个 ATTRIBUTE_ACCESSORS
宏添加到每个 AttributeSet
头文件的顶部, 可以很方便的定义每个属性的 Getter、Setter、Init 函数。
1 | /** |
宏(带参数) | 生成函数的签名 | 行为/用途 |
---|---|---|
GAMEPLAYATTRIBUTE_PROPERTY_GETTER(UMyAttributeSet, Health) |
static FGameplayAttribute GetHealth() |
静态函数从虚幻引擎的反射系统中返回 FGameplayAttribute 结构 |
GAMEPLAYATTRIBUTE_VALUE_GETTER(Health) |
float GetHealth() const |
返回 CurrentValue |
GAMEPLAYATTRIBUTE_VALUE_SETTER(Health) |
void SetHealth(float NewVal) |
设置 BaseValue |
GAMEPLAYATTRIBUTE_VALUE_INITTER(Health) |
void InitHealth(float NewVal) |
初始化 BaseValue 和 CurrentVale |
添加完这些之后,你的属性集类定义应该是如下所示(以 Health 属性为例):
1 | UCLASS() |
AttributeSet
的. cpp 文件应该用预测系统使用的 GAMEPLAYATTRIBUTE_REPNOTIFY
宏填充 OnRep 函数。
该辅助宏用于 RepNotify 函数,以处理将被客户端预测修改的属性。
1 | void UMageAttributeSet::OnRep_Health(const FGameplayAttributeData& OldData) const |
最后, Attribute
需要添加到GetLifetimeReplicatedProps
:
1 | void UMageAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const |
REPTNOTIFY_Always
用于设置 OnRep 函数在客户端值已经与服务端复制的值相同的情况下触发(因为有预测), 默认设置下, 客户端值与服务端复制的值相同时, OnRep 函数是不会触发的。
如果 Attribute
无需复制(比如 MetaAttribute
), 那么 OnRep
和 GetLifetimeReplicatedProps
步骤可以跳过。
03 初始化 Attribute
有多种方法可以初始化 Attribute
(将 BaseValue 和 CurrentValue 设置为某初始值).
通过 GE(推荐)
建议使用 (Instant)GE
修改属性,这种方法可以支持预测, 这也是项目使用的方法。
创建 GE 并设置 Modifier 指定要修改的属性,然后将 GE 应用到角色类的 ASC 即可。
详情可查看角色类的 InitDefaultAttributes()
函数
通过 ATTRIBUTE_ACCESSORS 宏的 Init 方法
如果在定义Attribute
时使用了ATTRIBUTE_ACCESSORS
宏, 那么在AttributeSet
中会自动为每个Attribute
生成一个初始化函数.
1 | // InitHealth(float InitialValue) 是一个自动生成的函数,用于使用 "ATTRIBUTE_ACCESSORS "宏定义的属性 "Health"。 |
查看AttributeSet.h
获取更多初始化Attribute
的方法.
使用数据表初始化(不推荐)
如果你选择不通过调用有硬编码值的初始化函数来初始化你的属性集和游戏玩法属性,你可以使用一个数据表来初始化,使用 AttributeMetaData
类。你可以从外部文件导入数据,或者在编辑器中手动填充数据表。
注意命名,必须是已经在C++定义的属性
Character 的 ASC 组件中查看
导入数据表
开发者通常会从. csv 文件中导入数据表,如下所示:
1 | ---,BaseValue,MinValue,MaxValue,DerivedAttributeInfo,bCanStack |
“MinValue”和”MaxValue”栏不会在默认的 GAS 插件中执行,这些值不会有任何影响。
将. csv 文件导入为数据表资产时,请选择”AttributeMetaData”行类型。
手动填充数据表
如果你喜欢在虚幻编辑器中编辑数值,而不是在外部电子表格或文本编辑程序中编辑数值,你可以创建表格,然后像其它蓝图资产一样打开它来编辑数值。使用窗口顶部的”添加”按键为每个游戏玩法属性添加一行。请记住,命名惯例是”AttributeSetName.AttributeName
“,也就是”属性集名称. 属性名称”,而且是区分大小写的。
多属性集 FAttributeSetInitter
FAttributeSetInitter
是定义在 AttributeSet.h
中的Helper struct,有助于从电子表格(UCurveTable)初始化属性集默认值。
可以读取不同的属性集,适合多个属性集的项目
基本思想是在这种形式的电子表格中:
- 表格中行的形式为:[GroupName].[AttributeSetName].[Attribute]
GroupName
:用于标识 “组 “的任意名称AttributeSetName
:该属性属于哪个 UAttributeSet。(请注意,这只是 UClass 名称的简单部分匹配。”Health”匹配 “UMyGameHealthSet”)。Attribute
: 实际属性的名称(与全名匹配)。
- 列表示 “Level “。
1 | // 这将把 CurveTable 转换为更有效的格式,以便在运行时读取。例如,应从 UAbilitySystemGlobals 中调用。 |
代码示例:
1 | IGameplayAbilitiesModule::Get().GetAbilitySystemGlobals()->GetAttributeSetInitter()->InitAttributeSetDefaults(MyCharacter->AbilitySystemComponent, "Hero1", MyLevel); |
- 这样,系统设计人员就可以为属性指定任意值。它们可以基于任何他们想要的公式。
- 等级上限非常高的项目可能希望采用更简单的 “每级获得属性 “方法。
- 在此方法中初始化的任何东西都不应该被 GE 直接修改。例如,如果 MaxMoveSpeed 随等级变化,那么其他任何修改 MaxMoveSpeed 的行为都应使用non-instant 的GE
- “Default”目前是硬编码的后备组名。如果调用 InitAttributeSetDefaults 时没有有效的组名,我们将回退到默认组名。
04 使用 GE 修改 Attribute
- ! 我们可以直接修改 Attribute ,方法是使用
const_cast
移除 const。但不推荐在 GAS 框架中这样使用 ^nwhtjz1
2
3
4
5
6
7
8
9
10
11if(IAbilitySystemInterface* ASInterface = Cast<IAbilitySystemInterface>(OtherActor))
{
if(const UMageAttributeSet* MageAttributeSet = Cast<UMageAttributeSet>(ASInterface->GetAbilitySystemComponent()->GetAttributeSet(UMageAttributeSet::StaticClass())))
{
//使用const_cast移除 const,进而以一种Hack的方式修改Attribute,危险!
UMageAttributeSet* MutableMageAttributeSet = const_cast<UMageAttributeSet*>(MageAttributeSet);
MutableMageAttributeSet->SetHealth(MageAttributeSet->GetHealth() + 10.0f);
Destroy();
}
}
为了实现网络预测,必须通过与之相关的 GE
来修改。
05 生命周期函数
属性集提供了多个生命周期函数方法:
PreAttributeBaseChange ()
- 当属性的聚合器存在时,当属性 BaseValue 修改之前调用。
- 此函数应强制执行 Clamp
- 此函数不应调用与游戏相关的事件或回调。
- `PostAttributeBaseChange()
- 当属性的聚合器存在时,当属性的 BaseValue 修改之后调用
PreAttributeChange()
- 属性的 CurrentValue 修改之前调用
PostAttributeChange()
- 属性的 CurrentValue 修改之后调用
PreGameplayEffectExecute()
- 在 GE 通过 ExecutionCalculation 修改属性集之前调用
- 带有一个
FGameplayEffectModCallbackData
参数,由GameplayEffectExecutionCalculation
提供(包含它要修改那个状态,值为多少)。 - 通过返回一个 bool 值决定是否允许
GameplayEffectExecutionCalculation
影响属性集,默认为true
PostGameplayEffectExecute()
- 在
PreGameplayEffectExecute
返回 true 之后,允许 GE 通过 ExecutionCalculation 修改属性集。该函数发生在 GE 执行之后,Base Value 被修改之前调用。 - 可以利用被 ExecutionCalculation 修改过的属性来编写逻辑,比如项目中的伤害值元属性
MetaDamage
由 ExecutionCalculation 修改,可以使用MetaDamage
在这里判断角色是否死亡。
- 在
以下两个函数是最常用的
06 PreAttributeChange()
1 | PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) |
- **在
Attribute
的CurrentValue
被修改前触发。(Duration)GE
和(Infinite)GE
可以修改CurrentValue
.
- Clamp 属性值的理想位置。
例如项目中 Clamp 生命值属性:
1 | void UMageAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) |
在这里做的任何 Clamp 都不会永久性地修改 ASC
中的 Modifier
, 只会修改查询 Modifier
的返回值。
这意味着属性修改之后的操作,像 ExecutionCalculations
和 MMC
这种根据所有 Modifier
重新计算 CurrentValue
的函数需要再次执行限制 (Clamp)操作。执行的位置是 PostGameplayEffectExecute()
[!bug]
源码对于PreAttributeChange()
的注释说明不要将该函数用于游戏逻辑事件, 而主要在其中做 Clamp 操作. 对于修改Attribute
的游戏逻辑事件的建议位置是UAbilitySystemComponent::GetGameplayAttributeValueChangeDelegate(FGameplayAttribute Attribute)
[[#04 监听 Attribute 变化委托]].
07 PostGameplayEffectExecute()
1 | PostGameplayEffectExecute(const FGameplayEffectModCallbackData & Data) |
- 在
PreGameplayEffectExecute
返回 true 之后,允许 GE 通过 ExecutionCalculation 修改属性集。该函数发生在 GE 执行之后,Base Value
被修改之前调用。 - 可以利用被 ExecutionCalculation 修改过的属性来编写逻辑,比如项目中的伤害值元属性
MetaDamage
由 ExecutionCalculation 修改,可以使用MetaDamage
在这里判断角色是否死亡。
项目中在这里进行如下操作:
- 对由
(Instant)GE
进行初始化的主属性再次 Clamp(只由(Instant)GE
修改 BaseValue 的Attribute
都可以在这里Clamp) - 根据主属性的变化更新副属性(使用 Attribute 的 Getter,Setter 函数)
- 使用
MetaAttribute
进行了伤害计算和经验值计算
[!NOTE]
当PostGameplayEffectExecute()
被调用时, 对Attribute
的修改已经发生, 但是还没有被复制回客户端, 因此在这里限制值不会造成对客户端的二次复制, 客户端只会接收到限制后的值。
06 监听 Attribute 变化委托
[[#06 生命周期函数]] 该小节中的生命周期函数虽然可以感知属性的变化,但是不允许我们在其中执行游戏逻辑。比如执行诸如”生命值属性变化时,让血条 UI 更新”的操作。
为了监听 Attribute
何时变化以便更新 UI 和其他游戏逻辑, ASC 提供了一个委托和对应的 Getter 函数:
1 | /** Attribute 改变时广播 */ |
我们只需要调用这个 Getter 函数将其绑定一个当 Attribute
变化时需要调用的回调函数,在回调函数中实现游戏逻辑。
项目中 OverlayWidgetController 中使用该委托绑定生命值属性变化回调(没有使用回调函数,而是用的 Lambda 表达式),通过回调再次广播 OnHealthChanged 委托,将新的生命值属性值广播到控件蓝图 WBP_HealthBar
中以更新血条。
1 | void UOverlayWidgetController::BindCallbacks() |
WBP_HealthBar
中绑定了 OnHealthChanged 委托:
还可以通过创建蓝图异步节点 UBlueprintAsyncActionBase
[[异步节点#示例]],或 GAS 内置的 AbilityAsync
类 AbilityAsync_WaitAttributeChanged
实现上述功能。
07 聚合器 Aggregator
聚合器 “聚” 的是什么?
聚的是属性相关的修改信息。
从下图可知,一个 ASC 有一个 FActiveGameplayEffectsContainer
实例,一个 FActiveGameplayEffectsContainer
有一个 “属性 -> 聚合器”的映射。
也就是说,一个聚合器实例聚合了,针对一个 ASC 实例的一个属性的**所有修改信息。
聚合器的作用:
属性的计算
属性的计算方法,即 Modifier(代码中的 Mod)都在聚合器中,所以属性的计算都在 Aggregator 的一系列数据类型中完成。属性推导
属性之间可以建立相关性,例如,属性 A=f (属性 B)。在 B 发生变化后,A 的值也会自动变化。也就是说,我们只需要驱动 B 的变化,然后不需要再调用任何函数,就可以期待 A 的变化发生。
源码解析:[[ 03-1 GameplayEffect 标签面面观 - 属性与聚合器]]
OnAttributeAggregatorCreated()
OnAttributeAggregatorCreated()
会在 Aggregator 为集合中的某个 Attribute
创建时触发, 它允许 FAggregatorEvaluateMetaData 的自定义设置, AggregatorEvaluateMetaData
是 Aggregator 基于所有应用的 Modifier(Modifier)
评估 Attribute
的 CurrentValue 的。
默认情况下, AggregatorEvaluateMetaData 只由 Aggregator 用于确定哪些 Modifier 是满足条件的, 以 MostNegativeMod_AllPositiveMods 为例, 其允许所有正(Positive) Modifier
但是限制负(Negative) Modifier
(仅最负的那一个), 这在 Paragon 中只允许将最负移动速度减速效果应用到玩家, 而不用管应用所有正移动速度 buff 时有多少负移动效果. 不满足条件的 Modifier
仍存于 ASC
中, 只是不被总合进最终的 CurrentValue, 一旦条件改变, 它们之后就可能满足条件, 就像如果最负 Modifier
过期后, 下一个最负 Modifier
(如果存在的话)就是满足条件的.
为了在只允许最负Modifier
和所有正Modifier
的例子中使用AggregatorEvaluateMetaData
:
1 | virtual void OnAttributeAggregatorCreated(const FGameplayAttribute& Attribute, FAggregator* NewAggregator) const override; |
你的自定义AggregatorEvaluateMetaData
应该作为静态变量添加到FAggregatorEvaluateMetaDataLibrary
.
5 Gameplay Effect
01 定义
GE
可以改变 Attribute
和 GameplayTag
,其可以立即修改 Attribute
(例如伤害或治疗)或应用持续状态(buff/debuff)。
UGameplayEffect
只是一个定义单一游戏效果的数据类, 不应该在其中添加额外的逻辑(没有 blueprint graph)。GE
通常不重载基类UGameplayEffect
,被设计成完全通过变量来配置GE
通过Modifier
和ExecutionCalculation
修改Attribute
.
02 配置项
(1)Gameplay Effect
- Modifier 和 Executions 后面会讲
Conditional Gameplay Effects
:当 GE 成功应用时,可以应用其他 GE,嵌套调用 GE 的方法之一。
持续策略 Duration Policy
GE
有三种持续策略: (Instant)
, (Duration)
和 (Infinite)
。
Duration Policy 与 Value 的关系:
(Instant)GE
:立即永久性修改BaseValue
(Periodic)GE
:周期性应用(Instant)GE
修改BaseValue
。(Duration)GE
:在指定时间内修改CurrentValue
,时间结束后移除该GE
,CurrentValue 值恢复为与 BaseValue 相等。(Infinite)GE
:在无限时间内修改CurrentValue
,直到手动移除该GE
,CurrentValue 值恢复为与 BaseValue 相等。
(2)Period 周期
[!warning]
(Periodic) GE
不能被预测
(Duration)GE
和 (Infinite)GE
可以通过设置 Period 转变为 (Periodic)GE
, 其每过 X 秒(由周期定义)就应用一次 Modifier 和 EExecution
- 如果由
Duration
转变为Periodic
,则在持续时间内按周期执行 - 如果由
Infinite
转变为Periodic
,则一直按周期执行,直到手动移除。
周期性抑制策略 Periodic Inhibition Policy:GE 中断并恢复后的处理方式。
- Never Reset:从被打断时的位置开始计算周期,相当于暂停再播放。
- Reset Period:从 0 开始计算周期。
- Execute and Reset Period:打断时立即执行一次,下次从 0 开始计算周期。
(3)Application 设置 Apply 概率和条件
设置 GE 的应用概率和条件。
条件可以简单地用 Tag 去限制,也可以用 Application Requirement
进行更复杂的逻辑判断。
使用 Application Requirement
需要用 C++或蓝图实现 Gameplay Effect Custom Application Requirement
(简称 GECAR
)类,重载里面的唯一方法,用于判断是否可以应用 GE。
官方文档推荐在以下情况使用GECAR
- 目标需要有一定数量的
Attribute
. - 目标需要有一定数量的
GameplayEffect
堆栈. - 除此之外
GECAR
还能够做更多事情,比如检查Target
是否应用了一个GE
的实例,在应用一个新实例时如果同类型的实例已存在则只改变其持续时间而(CanApplyGameplayEffect()
要返回 false)。
(4)Stacking 堆叠
堆叠处理对已具有 buff 或 debuff(GE)的目标再次应用 buff 或 debuff ,以及处理”Overflow 溢出”的策略,溢出是指在原 Gameplay 效果的影响下已完全饱和的目标被应用了新的游戏性效果(例如,不断累积的毒药计时条只有在溢出后才会产生持续伤害效果)。
GE
默认会应用新的GE Spec
实例, 而不关心之前已经应用过的尚且存在的GE Spec
实例。**`GE
可以设置到的堆栈中, 新的GE Spec
实例不会添加到堆栈中, 而是修改当前已经存在的GE Spec
堆栈数.- 只适用于
(Duration)GE
和(Infinite)GE
堆叠类型
Stacking Type
:Aggregate(总计) by Source
和 Aggregate by Target
.
即叠加栈在目标身上 or 施法者身上。
堆栈类型 | 描述 |
---|---|
Aggregate by Source | Target上的每个SourceASC 都有一个单独的堆栈实例, 每个 Source 可以应用堆栈中的 N 个 GE . |
Aggregate by Target | Target 上只有一个堆栈实例而不管 Source 如何, 每个 Source 都可以在 Shared Stack Limit 内应用堆栈. |
每层 GE 如果是 Modifiers 来计算,则为直接叠加的效果,比如用 Modifiers 来增加 3 攻击力,则第一层为增加 3 攻击力,则第二层为增加 6 攻击力,则第三层为增加 9 攻击力,而如果需要根据层数不同而改变增加的值,则需要使用 Executions。
限制堆叠数量
Stack Limit Count
限制 Stack 数量:
- 对于
Aggregate by Source
:只限制单个 Source 下的堆栈数量,多个 Source 单独计算 - 对于
Aggregate by Source
,只限制 Target 上的堆栈数量
持续时间刷新、周期刷新、过期处理
Stack Duration Refresh Policy
:Apply 新 GE 时是否刷新持续时间,注意溢出的 Apply 也会刷新,想关闭可以在下面的 Overflow 条目关闭。Stack Period Reset Policy
:同上,是否刷新周期。Stack Expiration Policy
:当一层 GE 的 Duration 到期后的处理方式。Clear Entire Stack
:清空全部层数,如 LOL 征服者。Remove Single Stack and Refresh Duration
:清空一层,如 LOL 致命节奏。
Refresh Duration
:不清空,相当于无限长的 Duration,但可以通过调用{cpp}FActiveGameplayEffectsContainer::OnStackCountChange(FActiveGameplayEffect& ActiveEffect, int32 OldStackCount, int32 NewStackCount)
方法来自己处理细节,如一次掉两层。
监听堆栈变化
使用 GAS 自带的 AbilityTask WatiForGameplayEffectStackChange
例如,可以用它来更新玩家拥有的被动护盾堆栈 (层数)
溢出 Overflow
可以设置 Stack 溢出会 Apply 的 GE。通过 GE 应用 GE 的方法之一,需要配合 Stacking 来使用。Deny Overflow Application
:如果为 True,则溢出的 Apply 不会刷新 Duration。Clear Stack On Overflow
:溢出时清空堆栈,需要勾选上一个选项后才能选中。
(5)Expiration 到期应用 GE
仅能用于 (Duration)GE
Premature Expiration Effect Classes
:当(Duration)GE
的 Duration 被打断或结束时(如通过强制删除,清除标签等)应用 GERoutine Expiration Effect Classes
:当(Duration)GE
的正常到期时应用 GE
(6)Tags 标签
和 GA 的 Tag 条目类似,设置各种限制条件。
GE
可以带有多个 GameplayTagContainer
, 设计师可以编辑每个类别的 Added
和 Removed
GameplayTagContainer
- 结果会在编译后显示在
Combined GameplayTagContainer
中.Added
标签是该GameplayEffect
新增的之前其父类没有的标签,Removed
标签是其父类拥有但该类没有的标签.Combined Tag = Parent‘s Tag+ Added - Removed
GameplayEffectAssetTag
:GE 的 Tag,它们自身没有任何功能且只用于描述GE
。GrantedTags
:GE 会赋予目标 ASC 的 Tag,仅适用于(Infinite)GE
和(Duration)GE
。GrantedBlockedAbilityTags
:Ongoing Tag Requirements
:GE 满足 Tag 条件才能修改值(Modifier or Execution)。- 一旦
GE
应用后, 这些标签将决定GE
是开启还是关闭.GE
可以是关闭但仍然是应用的, 通过这项设置 GE 可以仅 Apply 而不修改值 - 关闭
GE
会移除其已应用地Modifier
和GameplayTag
, 但是不会移除该GE
, 重新打开GE
会重新应用其Modifier
和GameplayTag
. - 仅适用于
(Infinite)GE
和(Duration)GE
。 - Ongoing:持续存在的
- 一旦
Application Tag Requirements
:GE 满足 Tag 条件才能应用(Apply)。Removal Tag Requirements
:GE 满足 Tag 条件就会被移除。Remove Gameplay Effects with Tags
:Apply 后移除指定 Tag 的 GE。Remove Gameplay Effect Query
:上面一条的高级版,可以匹配 GE 的类(Effect Definition),匹配来源(Effect Source)以及匹配 GE 修改的属性(Modifying Attribute),移除成功匹配的 GE。
TagResponseTable(标签响应表)
这也是一种 Stacking 机制,并不在 GE 本身配置。
配置以后,可以自动根据标签的状况改变对应 GE 的 Stacking 数。
制作和配置方法如下:
- 制作响应表数据资产
再将资产路径配置到 DefaultGame. ini 中生效。
1 | [/Script/GameplayAbilities.AbilitySystemGlobals] |
- 配置资产数据
需要配置的内容是 Entries 数组,每一个成员分为 Possitive 和 Negative 两部分。在起作用时,会分别结算两者的标签层数,来决定最终效果。大致步骤如下(UGameplayTagReponseTable:: TagResponseEvent ):
- 分别获取 Positive(P)和 Negative(N)的标签层数
- 将读取的 P 和 N 值约束到不大于各自的 SoftCountCap(>0 为有效配置)值
- 得 | P-N|,即绝对值
- 若 P>=N,则移除 N 的 GE,若 N>=P,则移除 P 的 GE
- 施加 P 和 N 中较大者的 GE,层数为 | P-N|(根据 4,P=N 时,所有 GE 都会移除)
(7)Immunity 免疫
GE
可以基于 GameplayTag
实现免疫, 有效阻止其他 GE
应用。
尽管免疫可以由 Application Tag Requirements
等方式有效地实现, 但是使用该系统可以在 GE
被免疫阻止时提供 {cpp} UAbilitySystemComponent::OnImmunityBlockGameplayEffectDelegate
委托 (Delegate).
选项:
GrantedApplicationImmunityTags
会检查(Source)ASC
(包括源 Ability 的 AbilityTag, 如果有的话)是否包含特定的 Tag , 这是一种基于确定 Character 或源 (Source)的标签对其所有GE
提供免疫的方法.- 如果拥有 Require Tags 的所有 Tag,并且没有 Ignore Tags 的所有 Tag,则认为匹配成功,该 GE 不会被 Apply。
Granted Application Immunity Query
会检查传入的GameplayEffectSpec
是否与其查询条件相匹配, 从而阻止或允许其应用。更细粒度地匹配,更消耗性能。- 最后三个选项,分别是根据 GE 修改的 Attribute 匹配、根据 GE 来源匹配以及根据 GE 的类匹配。
(8)Display 显示
与特效相关的设置,调用 Gameplay Cue 的方式之一Require Modifier Success to Trigger Cues
:需要 GE 的 Modifier 应用成功来才触发 CueSuppress Stacking Cues
: 如果为 true,GameplayCues 将只在堆叠 GE 的第一个实例中被触发。
(9)Granted Abilities
使用 GE 添加 GA 的方式。仅适用于 (Infinite)GE
和 (Duration)GE
。
Level
:GA 的等级。Input ID
:如果使用旧版输入,每一个操作映射都对应着一个枚举值,输入对应的枚举值就可以将这个新 GA 绑定到输入上。Removal Policy
:设置当 GE 被移除时,GA 是否要移除。Cancel Ability Immediately
:移除,并立即触发 EndAbility,结束 GA。
Remove Ability on End
:移除,但是不立即触发 EndAbility,GA 继续运行逻辑直到 EndAbility。Do Nothing
:GA 不会被移除,直到手动移除。
用法思路:
- 当与 ExecCalc 配合使用时,可将它们用于设置高度特殊的游戏性组合。例如,某个 Actor 具有指示该 Actor 浸在油中的 Gameplay Tags 或属性,当它被以火为主题的 GE 击中时,它就可以获得”着火”技能,从而被动地烧毁附近的 Actor 并在接下来的十秒钟之内产生具有粒子和动态光照的视觉效果。
- 一个普遍用法是当想要强制另一个玩家做某些事的时候, 像击退或拉取时移动他们, 就会对它们应用一个
GE
来授予其一个自动激活的GA
(查看[[#被动Ability]] 来了解如何在 Ability 被授予时自动激活它), 从而使其做出相应的动作.
(10)GE 嵌套
涉及 3 个配置项,对应不同的条件:
- [[#(1) Gameplay Effect]] :
Conditional Gameplay Effects
:当 GE 成功应用时,可以应用其他 GE。
- [[#溢出 Overflow]]: Stack 溢出会 Apply 的 GE
- [[#(5) Expiration 到期应用 GE]]:GE 中断或正常结束时,应用配置好的 GE
03 GameplayEffectSpec
[!NOTE] 应用场景
不需要立即 Apply 的 GE,例如发射物:直到命中目标后再对目标应用 GE。
Specification:规格/格式
Make Outgoing Spec
源码,内部主要实现了:
1
2
3FGameplayEffectSpec* NewSpec = new FGameplayEffectSpec(GameplayEffect, Context, Level);
return FGameplayEffectSpecHandle(NewSpec);
注意,返回值不是 FGameplayEffectSpec
,而是 FGameplayEffectSpecHandle
,Handle 内部保存有指向 FGameplayEffectSpec
的智能指针,{cpp} TSharedPtr<FGameplayEffectSpec> Data;
GE Spec
默认属性和创建时指定的 GE 的 CDO 相同,在 Apply 之前可以在运行时自由的创建和修改。
- 内部保存了
FGameplayEffectContextHandle
, 表明该GameplayEffectSpec
由谁创建。 GE Spec
不是FActiveGameplayEffect
,后者是GE Spec
Apply 后返回的新实例,被添加到目标 ASC 的FActiveGameplayEffectContainer
容器- 除了
GE
中设置的授予标签,GE Spec
还会授予目标 (Target)DynamicGrantedTags
- 存储
TMap<FGameplayTag, float> SetByCallerTagMagnitudes
用于 SetByCaller
FGameplayEffectSpecHandle
允许蓝图生成一个 GameplayEffectSpec,然后通过句柄的共享指针 Data
引用它,以便多次应用/应用多个目标。 创建 GameplayEffectSpec
需要先创建 FGameplayEffectSpecHandle
:
1 | //TSubclassOf<UGameplayEffect> GameplayEffectClass |
04 GameplayEffectContext
存有 GE Spec
的创建者 Instigator
和相关数据(如 TargetData
), 在 GE Spec 执行的整个过程中都会传递,因此它是追踪执行过程中瞬时信息的绝佳位置,可以在以在 MMC/ ExecutionCalculation, AttributeSet 和 GameplayCue) 之间传递任意数据。
默认可以传递的数据:
- Instigator
- EffectCauser
- AbilityCDO
- AbilityLevel
- SourceObject
- Actors
- HitResult
- WorldOrigin
- …
几个可以添加传递数据的函数
Instigator 和 EffectCauser
在 ASC->MakeOutgoingSpec()
前,我们会调用 ASC->MakeEffectContex()
创建 FGameplayEffectContextHandle
,MakeEffectContex()
会将 OwnerActor
设置为 Instigator
,将 AvartarActor
设置为 EffectCauser
。
Instigator
是指拥有产生该效果的 Ability 的对象EffectCauser
是指 Effect 的物理来源(如武器)。它们可以是相同的。
1 | // 获取EffectCauser |
创建 EffectContext 子类
可以对该结构进行子类化,并添加特定于游戏的信息
步骤:
- 继承
FGameplayEffectContext
. - 重写
FGameplayEffectContext::GetScriptStruct()
. - 重写
FGameplayEffectContext::Duplicate()
. - 如果新数据需要复制的话, 重写
FGameplayEffectContext::NetSerialize()
. - 对子结构体实现
TStructOpsTypeTraits
, 就像父结构体FGameplayEffectContext
有的那样. - 继承
UAbilitySystemGlobals
类,重载AllocGameplayEffectContext()
以返回一个新的子结构体对象.
继承 GameplayEffectContext
本项目在 MageAbilityTypes.h
文件中创建了一个子结构体 FMageGameplayEffectContext
,用于监控自定义的瞬时信息。下面的例子在 EffectContext 中添加了 bool 值 IsCriticalHit
1 | /* 继承 FGameplayEffectContext, 添加自定义数据,在MageAbilitySystemGlobals中配置 */ |
NetSerialize
1 | bool FMageGameplayEffectContext::NetSerialize(FArchive& Ar, UPackageMap* Map, bool& bOutSuccess) |
继承 UAbilitySystemGlobals
参考本项目中的 MageAbilitySystemGlobals.h
需要在 DefaultGame.ini
中指定全局类
1 | /** 指定全局类 */ |
1 | UCLASS() |
1 | FGameplayEffectContext* UMageAbilitySystemGlobals::AllocGameplayEffectContext() const |
使用自定义的信息
完成上述步骤后,就可以通过访问 FMageGameplayEffectContext
来 Getter/Setter bIsCriticalHit
。
本项目在自定义的蓝图函数库中实现了静态 Getter/Setter 函数 , 用于访问 bIsCriticalHit
。
1 | /** |
在 ExecCalc_Damage 中计算伤害时,将爆击状态传入 EffectContext:
1 | /** 是否暴击 */ |
在属性集中计算 MetaDamage 时,获取爆击状态,根据是否暴击显示不同样式的伤害浮动数字
1 | /** 显示伤害浮动数字 */ |
05 应用 Apply
GameplayEffect
可以被 GA
和 ASC
中的多个函数应用, 其通常是 ApplyGameplayEffectTo
的形式。
这些函数最终都在目标上调用 ApplyGameplayEffectSpecToSelf()
。当调用 ApplyGameplayEffect
时, 内部会从 GE
创建 GE Spec
,实际 Apply 到目标 (Target)的是该 GE Spec
。
为了在 GA
之外应用 GE
, 例如对于某个投掷物, 你就需要获取到目标的 ASC
并使用它的函数之一来 ApplyGameplayEffectToSelf
。
1 | //项目中封装的函数,展示了应用GE的简单流程 |
06 移除 Remove
GameplayEffect
可以被 GameplayAbility
和 ASC
中的多个函数移除,本质上都是最终在目标上调用 RemoveActiveEffects()
函数。Remove
函数需要传入一个 FActiveGameplayEffectHandle
来引用指定的 GE,通常来自与 Apply
函数 的返回值。
07 修饰符 Modifier
[!NOTE] 理解
Modifier 就是一个数学运算的过程!变量 && 操作符 = Modifier
Modifier
可以修改 Attribute
并且是唯一可以预测修改 Attribute
的方法。
一个 GE
可以有0个或多个 Modifier
, 每个 Modifier
通过某个指定的操作只能修改一个 Attribute
。
运算符
ModifierOP | 描述 |
---|---|
Add | 将Modifier 指定的Attribute 加上计算结果. 使用负数以实现减法操作. |
Multiply | 将Modifier 指定的Attribute 乘以计算结果. |
Divide | 将Modifier 指定的Attribute 除以计算结果. |
Override | 使用计算结果覆盖Modifier 指定的Attribute . |
Attribute 的 CurrentValue
是其所有 Modifier
与其 BaseValue
计算得到的结果。
像下面这样的 Modifier
计算公式被定义在 GameplayEffectAggregator.cpp
中的 FAggregatorModChannel::EvaluateWithBase
:
1 | ((InlineBaseValue + Additive) * Multiplicitive) / Division |
Override Modifier
会优先覆盖最后应用的 Modifier
得出的最终值.
[!NOTE]
对于基于百分比的修改, 确保使用Multiply
操作以使其在加法操作之后
[!bug]
预测(Prediction)对于百分比修改有些问题.
计算类型
有四种 Magnitude Calculation Type
:
ScalableFloat
AttributeBased
CustomCalculationClass(MMC)
SetByCaller
,
它们全都生成一些浮点数, 用于之后基于各自的操作修改指定Modifier
的Attribute
.
Scalable Float
指定一个 FScalableFloat 直接计算
FScalableFloat 结构体可以指向某个横向为变量, 纵向为等级的 Data Table, Scalable Float
会以 Ability 的当前等级自动读取指定 Data Table 的某行值(或者在 GE Spec
中重写的不同等级), 读取到的值会乘以系数(默认为 1)
Attribute Based
基于已有的属性进行计算 [[#05 派生属性 Derived Attribute]]
Backing Attribute
:将 Source(GE Spec
的创建者)或 Target(GE Spec
的接收者)上的 CurrentValue 或 BaseValue 视为Backing Attribute
(备份属性),- 可以使用系数(Coefficient)和 Pre Add 与 Post Add 来修改它:
- 可以使用系数(Coefficient)和 Pre Add 与 Post Add 来修改它:
- 多个 Modifier 计算顺序:从上到下
Attribute Curve
:基于 CurveTable 计算,代替直接使用Backing Attribute
Attribute Calculation Type
:Attribute Magnitude
:使用最终的计算值(默认选项,即捕获的属性经过数 Coefficient 和 Pre Add 与 Post Add 计算后的值 )Attribute Base Value
:使用属性的BaseValueAttribute Bonus Magnitude
: 使用属性的加成值,即Attribute Magnitude - Attribute Base Value
CustomCalculationClass(MMC)
[!NOTE] 应用场景
可以捕获多个 Attribute,但只能修改一个 Atrribute。比如可以用于属性计算,攻击力由力量和敏捷属性共同影响,捕获力量和敏捷属性,然后修改攻击力属性。
CustomCalculationClass
需要指定 UGameplayModMagnitudeCalculation
类来计算 Modifier (简称 MMC)
- MMC 为复杂的
Modifier
提供了最大的灵活性 MMC
的优势在于能够完全访问GameplayEffectSpec
来读取GameplayTag
和SetByCaller
,从而能够捕获GE
的源(Source)
或目标(Target)
上任意数量的Attribute
值。
捕获 Attribute
会自 ASC
现有的 Modifier
重新计算它们的 CurrentValue
, 该重新计算不会执行 AbilitySet
中的 PreAttributeChange()
, 因此 Clamp 操作必须在 PostGameplayEffectExecute()
中重新处理。
UGameplayModMagnitudeCalculation
UGameplayModMagnitudeCalculation
是用于通过蓝图或 C++ 执行自定义 Modifier 计算的类。它的功能相比 GameplayEffectExecutionCalculation
要弱一些(只能修改一个 Attribute), 但优点是它是可预测的.
它唯一要做的就是自 CalculateBaseMagnitude_Implementation()
返回浮点值(可以使用系数(Coefficient)和 Pre 与 Post 进一步处理浮点值结果),你可以在 C++和蓝图中继承并重写该函数.
1 | UFUNCTION(BlueprintNativeEvent, Category="Calculation") |
内置宏,用于声明捕获属性的
1 |
|
以项目中的 MMC_Defence
类为例,该类继承自 UGameplayModMagnitudeCalculation
,用于计算副属性防御力。需要捕获 Source AttributeStamina
(主属性-体力),计算公式为 Defence = (Stamina * 4.8f) + PlayerLevel
1 | UCLASS() |
1 | UMMC_Defence::UMMC_Defence() |
如果你没有在 MMC
的构造函数中将 FGameplayEffectAttributeCaptureDefinition
添加到 RelevantAttributesToCapture
中并且尝试捕获 Attribute
, 那么将会得到一个关于捕获时缺失 Spec 的错误. 如果不需要捕获 Attribute
, 那么就不必添加什么到 RelevantAttributesToCapture
.
Set By Caller
直译就是由调用者(即 GA
或 GE Spec
的创建者 )设置
SetByCaller
Modifier 是运行时由 GA
或 GE Spec
的创建者于 GE
之外设置的值, 例如, 如果你想让伤害值随玩家蓄力技能的长短而变化(本项目的技能蓝图 GA_Laser
实现了该功能), 那么就需要使用 SetByCaller
。
1 | /** 设置SetbyCaller Magnitude,将float值与Tag一一对应,获取时指定Tag即可获取*/ |
SetByCaller
本质上是存于 GE Spec
中的 TMap<FGameplayTag, float>
, Modifier
只是告知 Aggregator
去寻找与提供的 GameplayTag
相关联的 SetByCaller
值。
[!tip]
另外还有根据 FName 标记 SetbyCaller 的函数,建议只使用GameplayTag
形式而不是FName
形式, 这可以避免蓝图中的拼写错误, 并且当GE Spec
复制时,GameplayTag
比FName
在网络传输中更有效率, 因为TMap
也会复制.
用法:
首先创建一个 Outgoing GE Spec, 在应用 GE 之前调用 SetSetByCallerMagnitude
,完成设置(GE 中设置的 DataTag
要与 Assign Tag Set by Caller Magenitude
输入的DataTag
一致)。后续在在 MMC 或者 ExecCalc 中计算结果时通过返回的 GE Spec
调用 GetSetByCallerMagnitude
就可以获取到设置的值。
我认为 Set By Caller 这种计算方法在蓝图的使用中更为灵活,比如有一个 GE 用来对敌人造成伤害,伤害的大小可以在该项技能的 GA 中通过 Caller 传入。当然 C++中也可以使用。
在本项目技能蓝图 GA_Laser
中,实现了根据蓄力时间增加伤害:
- 通过内置的 AbilityTask:
WaitInputRelease
获取了按键按压时间 - 然后使用 Set by Caller 设置蓄力后的伤害值
- 最后应用
GE_ExecCalc_Damage
通过 ExecCalc 获取 SetbyCaller Magnitude 来计算最终伤害。
Snapshot 快照
使用 Attribute Base 或 MMC、ExecCalc 时都可以设置是否开启 Snapshot,Snapshot
用于确定捕获属性的时机
Snapshot | 源 (Source)或目标 (Target) | 在 GE Spec 中捕获时机 | Attribute 被 (Infinite)或 (Duration) GE 修改时自动更新 |
---|---|---|---|
开 | Source | 创建时 | 否 |
开 | Target | 应用时 | 否 |
关 | Source | 应用时 | 是 |
关 | Target | 应用时 | 是 |
- 开启,则捕获
GE Spec
创建时的Attribute
- 这时只能捕获 Source 属性,因为刚创建也不知道 Target 是谁!
- 如果仍选择 Target 属性,则是在应用时捕获
- 不会自动更新
- 关闭,则捕获
GE Spec
应用时的Attribute
- 该
Attribute
被(Infinite)GE
或(Duration)GE
修改时会自动更新
- 该
Multiply 和 Divide Modifier
默认情况下, 所有的Multiply
和Divide
Modifier在对Attribute
的BaseValue乘除前都会先加到一起.
1 | float FAggregatorModChannel::EvaluateWithBase(float InlineBaseValue, const FAggregatorEvaluateParameters& Parameters) const |
1 | float FAggregatorModChannel::SumMods(const TArray<FAggregatorMod>& InMods, float Bias, const FAggregatorEvaluateParameters& Parameters) |
摘自GameplayEffectAggregator.cpp
在该公式中Multiply
和Divide
Modifier都有一个值为1的Bias
值(加法的Bias
值为0), 因此它看起来像:
1 | 1 + (Mod1.Magnitude - 1) + (Mod2.Magnitude - 1) + ... |
该公式会导致一些意料之外的结果, 首先, 它在对BaseValue乘除之前将所有的Modifier
都加到了一起, 大部分人都期望将其乘或除在一起, 例如, 你有两个值为1.5的Multiply
Modifier, 大部分人都期望将BaseValue乘上1.5 x 1.5 = 2.25
, 然而, 这里是将两个1.5加在一起再乘以BaseValue(50%增量 + 另一个50%增量 = 100%增量).拿GameplayPrediction.h
中的一个例子来说, 给基值速度500加上10%的加速buff就是550, 再加上另一个10%的加速buff就是600.
其次, 该公式还有一些对于可以使用哪些值而未说明的的规则, 因为这是考虑Paragon的情况而设计的.
译者注: 说实话, 我没有搞懂下文中原文档作者的逻辑, 可能是没有充分了解项目的原因? 比如在样例项目中, 删除BP_DamageVolume的GameplayEffect中的Executions, 并按照下文例4添加两个Multiply Multipliers, Attribute均为GDAttributeSetBase.XP, Modifier Magnitude均为Scalable Float/5.0, 回到游戏, 击杀一个小兵使XP增加到1, 然后进入BP_DamageVolume, 会发现XP依次变为25, 625…, 进行调试也会发现是Modifier依次相乘的, 并不是作者所说的乘法分配律逻辑. 还有就是为什么符合公式规则的1 + (0.5 - 1) + (1.1 - 1) = 0.6
是正确的而不符合公式规则的1 + (0.5 - 1) + (0.5 - 1) = 0
和1 + (5 - 1) + (5 - 1) = 9
就是错误预期? 这个正确和错误预期是以什么为评判标准的? 是否符合公式规则么? 如果各位明白其中道理, 还请不吝赐教, 在此感谢!*
对于Multiply
和Divide
中乘法加法公式的规则:
- (最多不超过1个值 < 1) AND (任意数量值位于[1, 2))
- OR (一个值 >= 2)
公式中的Bias基本上都会减去[1, 2)
区间中的整数位, 第一个Modifier的Bias
会从最开始的Sum
值减值(在循环体前设置Bias), 这就是为什么某个值它本身能起作用的原因以及某个小于1的值与[1, 2)
区间中的值起作用的原因.
Multiply
的一些例子:
Multipliers: 0.51 + (0.5 - 1) = 0.5
, 正确.
Multipliers: 0.5, 0.51 + (0.5 - 1) + (0.5 - 1) = 0
, 错误预期1
? 多个小于1的值在Modifier
相加中不起作用. Paragon这样设计只是为了使用Multiply Modifier的最负值, 因此最多只会有一个小于1的值乘到BaseValue.
Multipliers: 1.1, 0.51 + (0.5 - 1) + (1.1 - 1) = 0.6
, 正确.
Multipliers: 5, 51 + (5 - 1) + (5 - 1) = 9
, 错误预期10
. 它总会是Modifier值的和 - Modifier个数 + 1
.
很多游戏会想要它们的Modify
和Divide
Modifier在应用到BaseValue之前先乘或除到一起, 为了实现这种需求, 你需要修改FAggregatorModChannel::EvaluateWithBase()
的引擎代码.
1 | float FAggregatorModChannel::EvaluateWithBase(float InlineBaseValue, const FAggregatorEvaluateParameters& Parameters) const |
1 | float FAggregatorModChannel::MultiplyMods(const TArray<FAggregatorMod>& InMods, const FAggregatorEvaluateParameters& Parameters) |
Modifier 的 GameplayTag
每个 Modifier 都可以设置 SourceTag
和 TargetTag
, 它们的作用就像 GE
的 Application Tag requirements
, 因此只有当GE
应用后才会考虑标签, 对于周期性的 (Infinite)GE
, 这些标签只会在第一次应用 Effect 时才会被考虑, 而不是在每次周期执行时.
Attribute Based Modifier
可以额外设置 SourceTagFilter
和 TargetTagFilter
. 当确定的 (Source)Attribute
的 Magnitude 时, 这些过滤器就会用来将某些 Modifier
排除在该 Attribute 之外, 源(Source)或目标(Target)中没有过滤器所有标签的 Modifier
也会被排除在外.
这更详尽的意思是: 源(Source) ASC
和目标(Target) ASC
的标签都被 GE
所捕获, 当 GE Spec
创建时, 源(Source) ASC
的标签被捕获, 当执行 Effect 时, 目标(Target) ASC
的标签被捕获. 当确定 (Infinite)GE
或 (Duration)GE
的 Modifier
是否满足条件可以被应用(也就是聚合器条件(Aggregator Qualify))并且过滤器已经设置时, 被捕获的标签就会和过滤器进行比对.
如果这些要求无法满足游戏的需求,可以从
UGameplayEffectCustomApplicationRequirement
基类派生数据对象,在其中你可以编写可任意定义复杂应用规则的本地代码。
08 Execution Calculation (ExecCalc)
[!quote] 应用场景
需要Source
或Target
的大量属性和其他值的伤害计算,可以捕获和修改多个 Attribute。
UGameplayEffectExecutionCalculation
ExecutionCalculation
简称 Execution
或 ExecCalc
(我喜欢后者)。是 GE
对 ASC
进行修改最强有力的方式.
- 和
MMC
一样, 它也可以捕获Attribute
并选择性地为其创建 Snapshot。 - 和
MMC
不同的是, 它可以修改多个Attribute
并且基本上可以处理程序员想要做的任何事。 ExecCalc
只能由(Instant)GE
和(Periodic)GE
使用
网络:
- 不可预测
- 对于
Local Predicted
,Server Only
和Server Initiated
的GA
,ExecCalc
只在服务端调用.
ExecutionParams
UGameplayEffectExecutionCalculation
子类只需要重载 Execute
函数:
1 | /** |
该函数的第一个参数十分强大,通过该参数可以获取 OwningSpec
、TargetASC
、SourceASC
。几乎获得了一切计算资源!
1 | /** Simple accessor to owning gameplay spec */ |
使用方法
本项目中用于计算伤害的 GE_ExecCalc_Damage
使用了 ExecCalc,以此为例讲述一下 ExecCalc 的使用方法。
- 创建
UGameplayEffectExecutionCalculation
子类UExecCalc_Damage
,GE_ExecCalc_Damage
指定该类为 Calculation Class
1 | /** Execution Calculation 计算伤害 */ |
- 为了设置
Attribute
捕获, 我们采用 Epic 的 ActionRPG 样例项目使用的方式, 定义一个保存和声明如何捕获Attribute
的结构体, 并在该结构体的构造函数中创建一个它的副本(Copy). 每个ExecCalc
都需要有一个这样的结构体.- 项目中用于计算伤害的
GE_ExecCalc_Damage
使用的 Calc Class 中定义了一个结构体(以下为简化版本):该结构体指明了我们要捕获 Source 的攻击力和 Target 的防御力。
- 项目中用于计算伤害的
1 | /** 该结构体用于捕获属性的声明和定义 */ |
[!BUG]
每个结构体需要一个独一无二的名字, 因为它们共享同一个命名空间, 多个结构体使用相同名字在捕获Attribute
时会造成错误 (大多是捕获到错误的Attribute
值).
- 实现重载的 Execute 函数,输出计算结果到元属性
MetaDamage
1 | UExecCalc_Damage::UExecCalc_Damage() |
发送数据到 ExecCalc
除了捕获Attribute
, 还有几种方法可以发送数据到ExecutionCalculation
.
SetByCaller
通过 GetOwningSpec()
可以获取 OwningSpec
,任何设置在 GE Spec
中的 SetByCaller
都可以直接在 Execute()
中读取.
1 | const FGameplayEffectSpec& Spec = ExecutionParams.GetOwningSpec(); |
Calculation Modifier
如果你想硬编码值到 GE
, 可以使用 CalculationModifier
传递, 其使用捕获的 Attribute
之一作为 Backing 数据.
在这个截图例子中, 我们给捕获的伤害值 Attribute
增加了50, 你也可以将其设为 Override
来直接传入硬编码值.
当ExecutionCalculation
捕获该Attribute
时会读取这个值.
1 | float Damage = 0.0f; |
瞬时聚合器 Transient Aggregator
如果你想硬编码值到 GE
, 可以在 C++中使用 CalculationModifier
传递, 其使用一个 瞬时聚合器(Transient Aggregator)
作为临时变量, 该临时变量与 GameplayTag
相关联. GameplayTag
保存在名为 ValidTransientAggregatorIdentifiers
中。
1 | /** 该容器中的任何Tag都将显示为有效的 "临时变量",用于作用域修饰符;用于支持不依赖作用域修饰符的数据驱动变量 */ |
在这个截图例子中, 我们使用 Data.Damage GameplayTag
将50 加到一个临时变量.
添加Backing临时变量到你的ExecutionCalculation
构造函数:
1 | ValidTransientAggregatorIdentifiers.AddTag(FGameplayTag::RequestGameplayTag("Data.Damage")); |
ExecutionCalculation
会使用和Attribute
捕获函数相似的特殊捕获函数来读取这个值.
1 | float Damage = 0.0f; |
从 GameplayEffectContext 获取数据
你可以通过 GE Spec
中的自定义 GameplayEffectContext
发送数据到 ExecutionCalculation
.
具体案例可见:[[#创建 EffectContext 子类]]
在ExecutionCalculation
中, 你可以自FGameplayEffectCustomExecutionParameters
访问EffectContext
.
1 | const FGameplayEffectSpec& Spec = ExecutionParams.GetOwningSpec(); |
如果你需要修改 GE Spec
中的什么或者 EffectContext
:
1 | FGameplayEffectSpec* MutableSpec = ExecutionParams.GetOwningSpecForPreExecuteMod(); |
在 ExecutionCalculation
中修改 GE Spec
时要小心:
1 | /** 非 const 访问。使用时要小心,尤其是在属性捕获后修改 Spec 时。*/ |
09 花费(Cost)GE
GE
花费(Cost)是指 ASC
激活 GA 所必需的 Attribute
量. 如果某个 GA
不能提供 Cost GE
, 那么它就不能被激活.
该 Cost GE
应该是某个带有一个或多个减少 Attribute
的 Modifier 的 (Instant)GE
.
默认情况下, Cost GE
是可以被预测的, 建议保留该功能, 也就是不要使用 ExecCalc
, 对于复杂的花费计算推荐使用 MMC
Cost GE 复用
通常可以为每个有花费的 GA
都设置一个单独的 Cost GE
, 一个更高阶的技巧是对多个 GA
复用一个 Cost GE
, 只需修改自 GA
的 Cost GE
创建的 GE Spec
中指定的数据(花费值是在 GA
上定义的),
这只适用于 (Instanced)GA
复用 Cost GE
的两种方法:
方法一(本项目使用的方法):创建一个公用的 GE: GE_AbilityCost
,使用 MMC Modifier
,该 MMC
可以从 GA
实例读取花费值,你可以从 GE Spec
中获取到该实例.
1 | float UMMC_AbilityCost::CalculateBaseMagnitude_Implementation(const FGameplayEffectSpec& Spec) const |
在 GameplayAbility
子类 MageGameplayAbility
添加 FScalableFloat
类型的成员变量。创建一个 CurveTable CT_AbilityCost
为每个技能添加曲线
1 | UPROPERTY(BlueprintReadOnly, EditAnywhere) |
在 GA 中指定使用的 Cost GE
方法二:重写 UGameplayAbility::GetCostGameplayEffect()
. 重写该函数并在运行时创建一个用来读取 GameplayAbility
中花费值的 GameplayEffect
.
10 冷却(Cooldown) GE
GameplayEffect
冷却时间决定了激活 Ability 之后多久可以再次激活. 如果某个 GA
在冷却中, 那么它就不能被激活.
Cooldown GE
是一个不带有Modifier
的(Duration)GE
, 计算方式为Set By Caller
或MMC
。- 并且在
GE
的GrantedTags
中每个GA
或 Ability 插槽(Slot)(如果你的游戏有分配到插槽的可交换 Ability 且共享同一个冷却)都有一个独一无二的GameplayTag
(Cooldown Tag
). GA
实际上会检查Cooldown Tag
的存在而不是Cooldown GE
的存在, 在 Cooldown GE 持续期间,玩家的 ASC 组件就会携带对应技能的 Cooldown Tag,本质是通过 Tag 来限制的。
默认情况下, Cooldown GE
是可以被预测的, 建议保留该功能, 也就是不要使用 ExecCalc
, 对于复杂的冷却计算推荐使用 MMC
通常会为每个有冷却的 GA
都设置单独的 Cooldown GE
, 一个更高阶的技巧是对多个 GA
复用一个 Cooldown GE
, 只需修改自 GA
的 Cooldown GE
创建的 GameplayEffectSpec
中指定的数据(冷却时间和 Cooldown Tag
是在 GA
上定义的), 这只作用于 (Instanced)GA
Cooldown GE 复用
方法一:使用 SetByCaller
. 这是最简单的方式. 使用 GameplayTag
设置 SetByCaller
为共享 Cooldown GE
的持续时间.
在 GameplayAbility
子类中, 为持续时间定义一个浮点/ FScalableFloat
变量, 为独一无二的 Cooldown Tag
定义一个 FGameplayTagContainer
, 除此之外还要定义一个临时 FGameplayTagContainer
, 其用来作为 Cooldown Tag
与 Cooldown GE
标签并集的返回指针.
1 | UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "Cooldown") |
之后重写UGameplayAbility::GetCooldownTags()
以返回Cooldown Tag
和所有现有Cooldown GE
标签的并集.
1 | const FGameplayTagContainer * UPGGameplayAbility::GetCooldownTags() const |
最后, 重写UGameplayAbility::ApplyCooldown()
以注入我们自己的Cooldown Tag
, 并将SetByCaller
添加到Cooldown GameplayEffectSpec
.
1 | void UPGGameplayAbility::ApplyCooldown(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo * ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo) const |
下面图片中, 冷却时间 Modifier
被设置为 SetByCaller
, 其 Data Tag
为 Data.Cooldown
. Data.Cooldown
就是上面代码中的 OurSetByCallerTag
.
方法二:使用 MMC
. 它的设置与上文所提的一致, 除了不需要在 Cooldown GE
和 ApplyCost
中设置 SetByCaller
作为持续时间, 相反, 我们需要将持续时间设置为 Custom Calculation类
并将其指向新创建的 MMC
.
1 | UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "Cooldown") |
之后重写UGameplayAbility::GetCooldownTags()
以返回Cooldown Tag
和所有现有Cooldown GE
标签的并集.
1 | const FGameplayTagContainer * UPGGameplayAbility::GetCooldownTags() const |
最后, 重写UGameplayAbility::ApplyCooldown()
以将我们的Cooldown Tag
注入Cooldown GameplayEffectSpec
.
1 | void UPGGameplayAbility::ApplyCooldown(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo * ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo) const |
1 | float UPGMMC_HeroAbilityCooldown::CalculateBaseMagnitude_Implementation(const FGameplayEffectSpec & Spec) const |
获取 Cooldown GE 的剩余时间
1 | bool APGPlayerState::GetCooldownRemainingForTag(FGameplayTagContainer CooldownTags, float & TimeRemaining, float & CooldownDuration) |
在客户端上查询剩余冷却时间要求其可以接收复制的 GE
, 这依赖于它们 ASC
的复制模式.
监听冷却开始和结束
为了监听某个冷却何时开始, 你可以通过绑定以下两个委托。我建议监听 Cooldown GE
何时应用, 因为这时还可以访问应用它的 GameplayEffectSpec
. 由此你可以确定当前 Cooldown GE
是客户端预测的还是由服务端校正的.
1 | //在Cooldown GE应用时响应 |
为了监听某个冷却何时结束, 你可以通过绑定以下两个委托。我建议监听 Cooldown Tag
何时移除, 因为当服务端校正的 Cooldown GE
到来时, 会移除客户端预测的 Cooldown GE
, 这会响应 OnAnyGameplayEffectRemovedDelegate()
, 即使仍处于冷却过程中. 预测的 Cooldown GE
在移除时和服务端校正的 Cooldown GE
在应用时 Cooldown Tag
都不会改变.
1 | //在Cooldown GE移除时响应 |
[!NOTE]
在客户端上监听某个GameplayEffect
添加或移除要求其可以接收复制的GameplayEffect
, 这依赖于它们ASC
的复制模式
本项目包含一个用于监听冷却开始和结束的自定义蓝图异步节点 AsyncTaskCooldownChanged.h
, WBP_SkillIcon
使用它来更新技能的冷却显示环 UI, 该 AsyncTask
会一直响应直到手动调用 EndTask()
。
预测冷却时间
目前冷却时间不是真正可预测的. 我们可以在客户端预测的 Cooldown GE
应用时启动 UI 的冷却时间计时器, 但是 GA
的实际冷却时间是由服务端的冷却时间剩余决定的. 取决于玩家的延迟情况, 可能客户端预测的冷却已经结束, 但是服务端上的 GA
仍处于冷却过程, 这会阻止 GA
的立刻再激活直到服务端冷却结束.
GASDocumentation/AsyncTaskCooldownChanged.cpp 通过在客户端预测的冷却开始时灰化陨石技能的图标, 之后在服务端校正的 Cooldown GE
到来时启动冷却计时器处理该问题.
在实际游戏中导致的结果就是高延迟的玩家相比低延迟的玩家对冷却时间短的技能有更低的触发率, 从而处于劣势, Fortnite通过使其武器使用无需冷却GameplayEffect
的自定义Bookkeeping而避免该现象.
Epic希望在未来的GAS迭代版本中实现真正的冷却预测(玩家可以激活一个在客户端冷却完成但服务端仍处于冷却过程的GameplayAbility
).
11 修改已激活 GameplayEffect 的持续时间
为了修改 Cooldown GE
或其他任何 (Duration)GE
的剩余时间, 我们需要修改 GameplayEffectSpec
的持续时间, 更新它的 StartServerWorldTime
, CachedStartServerWorldTime
, StartWorldTime
, 并且使用 CheckDuration()
重新检查持续时间. 在服务端上完成这些操作并将 FActiveGameplayEffect
标记为 dirty, 其会将这些修改复制到客户端.
Note: 该操作包含一个 const_cast
, 这可能不是 Epic
希望的修改持续时间的方法, 但是迄今为止它看起来运行得很好.
1 | bool UPAAbilitySystemComponent::SetGameplayEffectDurationHandle(FActiveGameplayEffectHandle Handle, float NewDuration) |
12 运行时创建动态 GE
在运行时创建动态 GE
是一个高阶技术, 你不必经常使用它.
只有 (Instant)GE
可以在运行时由 C++创建, (Duration)GE
和 (Infinite)GE
不能在运行时动态创建, 因为它们在复制时会寻找并不存在的 GE
类定义. 为了实现该功能, 你应该创建一个原型 GE
类, 就像平时在编辑器中做的那样, 之后根据运行时所需来定制化 GE Spec
.
运行时创建的 (Instant)GE
也可以在客户端预测的 GA
中调用. 然而, 目前还不明确动态创建是否有副作用.
本项目在角色 MageAttributeSet
中的值受到攻击时运行时生成 Debuff 临时GE。
1 | void UMageAttributeSet::Debuff(const FEffectProperty& Property) |
13 GameplayEffect Containers
Epic的Action RPG样例项目实现了一个名为FGameplayEffectContainer
的结构体, 它不属于原生GAS, 但是对于包含GameplayEffect
和TargetData极其好用, 它会使一些过程自动化, 比如从GameplayEffect
中创建GameplayEffectSpec
并在其GameplayEffectContext
中设置默认值. 在GameplayAbility
中创建GameplayEffectContainer
并将其传递给已生成的投掷物是非常简单和显而易见的, 然而我没有选择在样例项目中实现GameplayEffectContainer
, 因为我想向你展示的是没有它的原生GAS, 但是我高度建议你研究一下它并将其纳入到你的项目中.
为了访问GameplayEffectContainer
中的GESpec
以求做一些诸如添加SetByCaller
的操作, 请使用FGameplayEffectContainer
结构体中的GESpec
数组索引访问GESpec
引用, 这要求你需要提前知道想要访问的GESpec
的索引.
GameplayEffectContainer
还包含一个可选的用于定位(Target)的高效方法.
14 存在的问题
GAS 中的现存问题:
GameplayEffect
延迟调节 (Latency Reconciliation). (不能预测能力冷却时间, 导致高延迟玩家相比低延迟玩家, 对于短冷却时间的能力有更低的激活速率.)- 不能预测性地移除
GameplayEffect
. 然而我们可以反向预测性地添加GameplayEffect
, 从而高效的移除它. 但是这不总是合适或者可行的, 因此这仍然是个问题.
6 Gameplay Ability
01 定义
GameplayAbility
是一个蓝图对象,负责执行技能的所有事件,包括播放动画,触发效果,从所有者获取属性,以及显示视觉效果。
- 提供一种方法来赋予/分配可使用的Ability
- 提供对实例化Ability的管理
- 提供 Replication 功能
- Ability state 必须始终复制到 UGameplayAbility 本身,但 UAbilitySystemComponent 对于实际激活的 GameplayAbility 提供了 RPC 复制功能
GameplayAbility
示例:
- 跳跃
- 奔跑
- 射击
- 每X秒被动地阻挡一次攻击
- 使用药剂
- 开门
- 收集资源
- 建造
不应该使用GameplayAbility
的场景:
- 基础移动输入
- 射线检测
- 一些与UI的交互 - 不要使用
GameplayAbility
从商店中购买物品这些不是规定, 只是我的建议而已, 你的设计和实现可能是多样的.
GameplayAbility
使用 AbilityTask 用于随时间推移而发生的行为(异步事件), 例如等待某个事件, 等待某个 Attribute 改变, 等待玩家选择一个目标或者使用 Root Motion Source
移动某个 Character
.
所有的 GA
都会有它们各自由你的游戏逻辑重写的 ActivateAbility()
函数, 附加的逻辑可以添加到 EndAbility()
, 其会在 GameplayAbility
完成或取消时执行.
框架图:
一个简单的 GameplayAbility
流程图:
一个更复杂 GameplayAbility
流程图:
生命周期
将 GA
授予给 ASC 后,GA
的基本执行生命周期如下:
TryActivateAbility
是执行技能的典型方式。该函数调用CanActivateAbility
来确定技能是否可以立即运行,如果可以,则继续调用CallActivateAbility
CanActivateAbility
判断是否可激活该技能。- 该函数会调用
CheckCost()
和CheckCooldown()
检查所属ASC
是否满足花费和冷却条件
- 该函数会调用
CallActivateAbility
执行技能相关的游戏代码
- 自定义逻辑需要在 C++或蓝图中重载
ActivateAbility
- 与 Actor 和组件不同,**
GA
不会使用”tick
“函数完成主要工作,而是在激活过程中启动技能任务,异步完成大部分工作**,然后通过委托(在 C++中)处理这些任务的输出,或者连接节点以输出执行引脚(在蓝图中)。 - 如果从
ActivateAbility
中调用CommitAbility
函数,它将应用执行 Cost 和 Cooldown 计算CommitAbility()
会调用CommitCost()
和CommitCooldown()
,- 如果它们不需要同时提交, 设计师可以选择分别调用
CommitCost()
或CommitCooldown()
. - 提交 Cost 和 Cooldown 会多次调用
CheckCost()
和CheckCooldown()
.
CancelAbility
提供了取消技能的机制,不过技能的CanBeCanceled
函数可以拒绝请求。与CommitAbility
不同,该函数可供技能外调用者使用。成功的取消先广播给OnGameplayAbilityCancelled
委托(通过这个委托可以添加自定义逻辑),然后通过标准代码路径结束技能。EndAbility
会在技能执行完毕后将其关闭。如果技能被取消,UGameplayAbility
类会将其作为取消流程的一部分自动处理,但其他情况下,开发者都必须调用 C++函数或在技能的蓝图图表中添加节点。如果未能正常结束技能,将导致玩法技能系统认为技能仍在运行,从而带来一些影响,例如禁止将来再使用该技能或任何被该技能阻止的技能。例如,如果游戏的”喝生命药剂”玩法技能没有正常结束,那么使用该技能的角色就无法执行任何在喝血量药剂时无法执行的操作(例如喝其他药剂、快跑、爬梯子等)。这种阻碍会一直存在,因为玩法技能系统会认为角色还在喝药剂。
02 授予 Give
[!NOTE] 规定
我们授予的实际上是GA Spec
, 而不是直接使用GA
类。可以这样理解:GA Spec
是用来描述技能,不仅包含GA
本身,还包含其他相关信息和运行时信息。
很多 ASC 内置函数参数要求传入GA Spec
,这样不仅可以直接从 GA Spec 获取 GA,还可以获取其他信息。
综上,我们所说的授予 Ability,激活 Ability 都是对GA Spec
进行处理。GA Spec
包含GA
,当单独说 Ability 这个词汇时,大多数时间是指GA Spec
- 在 Actor 可以使用某项技能之前,必须向其
ASC
授予该技能。 - 只能在服务器授予技能, 服务器会自动复制
GA Spec
到所属 (Owning)客户端 - 被授予的
GA Spec
会被添加到 ASC 的FGameplayAbilitySpecContainer ActivatableAbility
容器。
ASC 中用于授予技能的函数:
1 | //授予但不激活 |
FGameplayAbilitySpec
FGameplayAbilitySpec
定义了 GA
是什么:
- 包含 GameplayAbility Class, level 和绑定到 input ID 的相关信息。
- 保存了
GA
的运行时状态 - 激活
GA Spec
会根据实例化策略创建一个UGameplayAbility
实例 (Non-Instanced GA
除外),该实例存储在GA Spec
中
调用 GiveAbility
函数时,需要传入的参数为 GA Spec 的引用,而不是 GA 类本身。GA Spec 可直接通过调用构造函数生成,其构造函数有三个重载:
1 | /** 使用AbilityClass */ |
FGameplayAbilitySpecHandle
注意 GiveAbility
函数的返回值是返回 FGameplayAbilitySpecHandle
,**该句柄指向该已被授予的 GA Spec
**,全局唯一。后续对该技能的取消和激活操作都需要指定该句柄。
代码
本项目的玩家角色类使用 TArray<TSubclassOf<UGameplayAbility>>
保存技能,在 PossessedBy 中调用 ASC 的授予技能函数。
1 | UPROPERTY(EditAnywhere, Category = "MageCharacter|GAS") |
1 | void AMageCharacter::PossessedBy(AController* NewController) |
1 | void UMageAbilitySystemComponent::GiveCharacterAbilities(const TArray<TSubclassOf<UGameplayAbility>>& CharacterAbilities) |
03 取消 Ability
以下函数可以利用授予技能后返回的 FGameplayAbilitySpecHandle
,来 取消
对技能系统组件中该技能的访问。
ClearAbility
: 从技能系统组件中移除指定技能,其会调用EndAbility()
并设置它的WasCancelled
参数为true
.SetRemoveAbilityOnEnd
:当该技能执行完毕时,将该技能从技能系统组件中移除。如果未执行该技能,将立即移除它。如果正在执行该技能,将立即清除其输入,这样玩家就无法重新激活它或与它互动。ClearAllAbilities
:从技能系统组件中移除所有技能。此函数是唯一不需要FGameplayAbilitySpecHandle
的函数。
为了从内部取消GameplayAbility
, 可以调用CancelAbility()
,
为了从外部取消GameplayAbility
, ASC
提供了一些函数:
1 | /** Cancels the specified ability CDO. */ |
[!bug]
我发现如果存在一个(Non-Instanced)GA
时,CancelAllAbilities
似乎不能正常运行, 它似乎会命中这个(Non-Instanced)GA
并放弃继续处理。CancelAbility
可以更好地处理(Non-Instanced)GA
04 激活 Ability
只要能获取 ASC,就可以在任何地方激活 GA,比如行为树 Task 蓝图,甚至在 GA 蓝图中调用其他 GA。
如果某个 GA
被分配给了一个输入事件, 那么当输入按键按下并且它的 GameplayTag
需求满足时, 它将会自动激活, 这可能并非总是激活 GA
的期望方式。
1 | UFUNCTION(BlueprintCallable, Category = "Abilities") |
ASC
提供了多种激活 GA
的方法,可以分为主动激活(释放技能)和被动激活(挨打)两类。
主动激活
- **
ByTag
**。这会使用匹配的GameplayAbility Trigger
触发所有技能。这是触发技能的GE
的首选方法。 - **
ByClass
**:一次只能 Activate 一个 GA From GA SpecHandle
:通过蓝图或 C++代码显式激活技能。这在授予技能时由ASC
提供。GiveAbilityAndActivateOnce
:上面讲过,即授予时立即激活一次,技能必须实例化。
- **
被动激活
TriggerAbilityFromGameplayEvent
。通过 Event 激活GA
允许你传递一个该事件的数据负载Payload
.。这会使用匹配的GameplayAbility Trigger
触发所有技能。如果你需要抽象输入和决策机制,此方法非常适合,因为它提供了最大的灵活度。- 通过用户输入激活,项目中在触发 InputAction 时激活技能。
[!warning]
不要忘记应该在GA
终止时调用EndAbility()
, 除非你的GA
是像被动技能需要一直运行。
Trigger 与 Event
Trigger 可以理解为一个 Tag,当 ASC 组件收到一个 Trigger 时,就会自动调用所有拥有该 Trigger 的 GA。
Trigger 的 Tag 在 GA 的 details 面板中设置。
Trigger 的触发方式有三种,分别是:
Gameplay Event
: 当 Owner 收到一个带有 Tag 的 Gameplay Event 时激活一次 GA,此时 Owner 不会拥有对应的 Tag。Owner Tag Added
: 当 Owner 获取对应 Tag 的时候激活一次 GA。Owner Tag Present
: 当 Owner 拥有 Tag 时激活 GA,失去 Tag 时移除。
一般使用第一种方法,并配合 SendGameplayEventToActor
使用,如下图所示。
1 | void UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(AActor* Actor, FGameplayTag EventTag, FGameplayEventData Payload) |
使用 Gameplay Event
的好处是,通过 Event 激活 GA
允许你传递一个数据负载 (Payload
) ,是除了 Get Actor Info
外的另一种信息传递方法。
此时应该删除 ActiveAbility
节点,转而使用 ActivateAbilityFromEvent
事件。(不要通过在左上角重载函数的方式,右键空白处搜索才是对的)
[!bug]
当从蓝图中的 Event 激活GA
时, 你必须使用ActivateAbilityFromEvent
节点, 并且标准的ActivateAbility
节点不能出现在图表中, 如果ActivateAbility
节点存在, 它就会一直被调用而不调用ActivateAbilityFromEvent
节点.
获取激活的 Ability
初学者经常会问”我怎样才能获取激活的 Ability?”, 也许是用来设置变量或取消它. 多个 GameplayAbility
可以在同一时刻激活, 因此没有”一个激活的 Ability”, 相反, 你必须便利 ASC
的 ActivatableAbility
列表(ASC
拥有的已授予 GameplayAbility
)来获得。
UAbilitySystemComponent::GetActivatableAbilities()
会返回一个用于遍历的 TArray<FGameplayAbilitySpec>
.
ASC
还有另一个有用的函数, 它将一个 GameplayTagContainer
作为参数来协助搜索, 而无需手动遍历 GameplayAbilitySpec
列表. bOnlyAbilitiesThatSatisfyTagRequirements
参数只会返回那些 GameplayTag
满足需求且可以立刻激活的 GameplayAbilitySpecs
。 例如, 你可能有两个基本的攻击 GA
, 一个使用武器, 另一个使用拳头, 正确的激活取决于武器是否装备并设置了 GameplayTag
需求. 详见 Epic 关于函数的注释.
1 | UAbilitySystemComponent::GetActivatableGameplayAbilitySpecsByAllMatchingTags(const FGameplayTagContainer& GameplayTagContainer, TArray < struct FGameplayAbilitySpec* >& MatchingGameplayAbilities, bool bOnlyAbilitiesThatSatisfyTagRequirements = true) |
一旦你获取到了寻找的 FGameplayAbilitySpec
, 那么就可以调用它的 IsActive()
.
05 被动 Ability
为了实现自动激活和持续运行的被动 GA
, 需要重写 UGameplayAbility::OnAvatarSet()
, 该函数在授予 GA
并设置 AvatarActor
且调用 TryActivateAbility()
时自动调用.
项目可能希望在此初始化被动 Ability 或执行其他 “BeginPlay “类型的逻辑。
可以添加一个 bool
到你的自定义 UGameplayAbility
类来表明其在授予时是否应该被激活。
被动 GameplayAbility
一般有一个 Server Only
的网络执行策略。
1 | void UGDGameplayAbility::OnAvatarSet(const FGameplayAbilityActorInfo * ActorInfo, const FGameplayAbilitySpec & Spec) |
本项目使用了更简单的方法,即使用 GiveAbilityAndActivateOnce()
授予技能,这会在授予后立即激活技能。在 GA 蓝图中,我们不执行 EndAbility,就可以保持持续激活的被动技能。
06 传递数据到 Ability
GA
的一般范式是 Activate->Generate Data->Apply->End
. 有时你需要调整现有数据, GAS 提供了一些选项来获取外部数据到你的 GameplayAbility
.
使用
SendGameplayEventToActor()
激活 Ability- 使用含有数据负载 (
Payload
)的 Event 激活GameplayAbility
. - 对于客户端预测的
GameplayAbility
, Event 的Payload
是由客户端复制到服务端的. 对于那些不适合任意现有变量的数据可以使用两个Optional Object
或TargetData
变量. - 该方法的缺点是不能使用输入绑定激活 Ability. 为了通过 Event 激活
GA
, 该GA
必须设置其 Trigger, 分配一个GameplayTag
并选择一个GameplayEvent
选项. 想要发送事件, 就得使用SendGameplayEventToActor()
函数.
- 使用含有数据负载 (
使用
WaitGameplayEvent AbilityTask
- 在
GA
激活后, 使用WaitGameplayEvent AbilityTask
来告知其监听带有负载Payload
的事件.Payload
及其发送过程与通过 Event 激活GA
是一样的. - GA 在
Wait Gameplay Event
的时候会在 ASC 内部记录一个回调, 通过 Tag 来映射识别; 而当外部别的 GA 调用Send Gameplay Event
的时候, 会用 Event 的 Tag 在 ASC 的映射表中搜索, 如果搜索到就会触发对应的回调 - 该方法的缺点是 Event 不能通过
AbilityTask
复制且只能用于Local Only
和Server Only
的GameplayAbility
. 你可以编写自己的AbilityTask
以支持复制 Event 负载 (Payload).
- 在
使用
TargetData
- 自定义
TargetData
结构体是一种在客户端和服务端之间传递任意数据的好方法.
- 自定义
保存数据在
OwnerActor
或者AvatarActor
- 使用保存于
OwnerActor
,AvatarActor
或者其他任意你可以获取到引用的对象中的可复制变量. 这种方法最灵活且可以用于由输入绑定激活的GameplayAbility
, - 然而, 它不能保证在使用时数据复制, 你必须提前做好准备 - 这意味着如果你设置了一个可复制的变量, 之后立即激活该
GameplayAbility
, 那么因为存在潜在的丢包情况, 不能保证接收者上发生的顺序.
- 使用保存于
07 GA 标签
GameplayAbility
自带有内建逻辑的 GameplayTagContainer
.
GameplayTagContainer | 描述 |
---|---|
Ability Tags | 此 Ability 有以下 Tag |
Cancel Abilities with Tag | 执行此Ability时,带有这些Tag的Ability会被取消 |
Block Abilities with Tag | 此Ability激活时,带有这些Tag的Ability会被阻止 |
Activation Owned Tags | 此Ability激活时应用于激活所有者的Tag。 |
Activation Required Tags | 只有当激活的角色/组件拥有所有这些 Tag 时,才能激活此Ability。 |
Activation Blocked Tags | 如果激活的角色/组件具有以下任何一个Tag,此Ability 将被阻止。 |
Source Required Tags | 只有当源角色/组件具有所有这些Tag时才会激活Ability |
Source Blocked Tags | 源角色/组件具有以下任何一个Tag,此Ability 将被阻止。 |
Target Required Tags | 只有当目标角色/组件具有所有这些Tag时,才能激活此Abilty。 |
Target Blocked Tags | 目标角色/组件具有以下任何一个Tag,此Ability 将被阻止。 |
08 升级 Ability
有两个常见的方法用于升级Ability:
升级方法 | 描述 |
---|---|
升级时取消授予和重新授予 | 自ASC 取消授予(移除)GameplayAbility , 并在下一等级于服务端上重新授予. 如果此时GameplayAbility 是激活的, 那么就会终止它. |
增加GameplayAbilitySpec 的等级 |
在服务端上找到GameplayAbilitySpec , 增加它的等级, 并将其标记为Dirty以复制到所属(Owning)客户端. 该方法不会在GameplayAbility 激活时将其终止. |
两种方法的主要区别在于你是否想要在升级时取消激活的 GameplayAbility
. 基于你的 GameplayAbility
, 你很可能需要同时使用两种方法. 我建议在 UGameplayAbility
子类中增加一个布尔值以明确使用哪种方法.
09 实例化策略 (Instanced Policy)
激活 GA Spec
会根据实例化策略创建一个 UGameplayAbility
实例 (Non-Instanced GA
除外),该实例存储在 GA Spec
中。在某些情况下可能会非常频繁地执行技能,会出现因快速创建技能对象而对性能产生负面影响的情况。
为了解决这个问题,技能可以选择三种不同的实例化策略,以在效率和功能之间达到平衡。支持的三种实例化类型:
实例化策略 | 描述 | 何时使用的例子 |
---|---|---|
按 Actor 实例化 (Instanced Per Actor) | 每个 ASC 只能有一个在激活之间复用的 GA 实例. |
这可能是你使用最频繁的实例化策略. 你可以对任一 GA 使用并在激活之间提供持久化. 设计者可以在激活之间手动重设任意变量. |
按执行实例化 (Instanced Per Execution) | 每有一个 GA 激活, 就有一个新的 GA 实例创建. |
这些 GA 的好处是每次激活时变量都会重置, 其性能要比 Instanced Per Actor 差, 因为每次激活时都会生成新的 GA . |
非实例化 (Non-Instanced) | GA 操作其 CDO, 没有实例创建. |
它是三种方式中性能最好的, 但是使用它是最受限制的. (Non-Instanced)GA 不能存储状态, 这意味着没有动态变量和不能绑定到 AbilityTask 委托. 使用它的最佳场景就是需要频繁使用的简单 Ability, 像 MOBA 或 RTS 游戏中小兵的基础攻击. 样例项目中的跳跃 GameplayAbility 就是 非实例化(Non-Instanced) 的. |
官方文档:
- 按执行实例化:(Instanced per Execution:) 每次技能运行时,都会产生新的
GA
对象的副本。- 优点:可以自由使用蓝图图表和成员变量,并且所有内容都将在执行开始时初始化为默认值。这是最简单的实例化策略,
- 缺点:开销较大,该策略更适合不会频繁运行的技能。
- 按 Actor 实例化:(Instanced per Actor:) 在技能首次执行会生成该技能的一个实例对象,该对象会在以后的执行中重复使用。这就要求在两次技能执行之间清理成员变量,同时也使保存多个执行的信息成为可能。按 Actor 是较为理想的复制方法,因为技能具有可处理变量和 RPC 的复制对象,而不是浪费网络带宽和 CPU 时间,在每次运行时产生新对象。该策略适用于大规模的情况,因为大量使用技能的 Actor(例如在大型战斗中)只会在第一次使用技能时产生对象。
- 非实例化:(Non-Instanced:) 最高效的策略。技能不会生成任何对象,而是使用类默认对象(CDO)。
- 但是,在高效的同时也伴随着一些限制:
- 首先,该策略要求技能完全用 C ++编写,因为创建蓝图图表需要对象实例。你可以创建非实例化技能的蓝图类,但这只能更改已公开属性的默认值。
- 此外,在执行技能期间,即使在本机 C ++代码中,技能也不能更改成员变量,不能绑定代理,也不能复制变量或处理 RPC。
- 该策略仅适用于不需要内部变量存储(尽管可以针对技能用户设置属性)并且不需要复制任何数据的技能。它尤其适合频繁运行且被许多角色使用的技能,例如大型 RTS 或 MOBA 作品中部队使用的基本攻击。
- 但是,在高效的同时也伴随着一些限制:
10 绑定输入到 ASC??
增强输入系统
旧版输入系统 InputID
ASC
允许你直接绑定输入事件并当你授予 GA
时分配这些输入到 GA
, 如果 GameplayTag
合乎要求, 当按下按键时, 分配到 GA
的输入事件会自动激活各自的 GA
. 分配的输入事件要求使用响应输入的内置 AbilityTask
.
除了分配的输入事件可以激活 GA
, ASC
也接受一般的 Confirm
和 Cancel
输入, 这些特殊输入被 AbilityTask
用来确定像 Target Actor 的对象或取消它们.
为了绑定输入到 ASC
, 你必须首先创建一个枚举来将输入事件名称转换为 byte, 枚举名必须准确匹配项目设置中用于输入事件的名称, DisplayName
就无所谓了.
样例项目中:
1 | UENUM(BlueprintType) |
如果你的 ASC
位于 Character
, 那么就在 SetupPlayerInputComponent()
中包含用于绑定到 ASC
的函数.
1 | // Bind to AbilitySystemComponent |
如果你的 ASC
位于 PlayerState
, SetupPlayerInputComponent()
中有一个潜在的竞争情况就是 PlayerState
还没有复制到客户端, 因此, 我建议尝试在 SetupPlayerInputComponent()
和 OnRep_PlayerState()
中绑定输入, 只有 OnRep_PlayerState()
自身是不充分的, 因为可能有种情况是当 PlayerState
在 PlayerController
告知客户端调用用于创建 InputComponent
的 ClientRestart()
前复制时, Actor 的 InputComponent
可能为 NULL. 样例项目演示了尝试使用一个布尔值控制流程从而在两个位置绑定, 这样实际上只绑定了一次.
[!NOTE]
样例项目枚举中的Confirm
和Cancel
没有匹配项目设置中的输入事件名称 (ConfirmTarget
和CancelTarget
), 但是我们在BindAbilityActivationToInputComponent()
中提供了它们之间的映射, 这是特殊的, 因为我们提供了映射并且它们无需匹配, 但是它们是可以匹配的. 枚举中的其他输入都必须匹配项目设置中的输入事件名称.
对于只能用一次输入激活的 GameplayAbility
(它们总是像 MOBA 游戏一样存在于相同的”槽”中), 我倾向在 UGameplayAbility
子类中添加一个变量, 这样我就可以定义他们的输入, 之后在授予 Ability 的时候可以从 ClassDefaultObject
中读取这个值.
绑定输入时不激活 Ability
如果你不想你的 GameplayAbility
在按键按下时自动激活, 但是仍想将它们绑定到输入以与 AbilityTask
一起使用, 你可以在 UGameplayAbility
子类中添加一个新的布尔变量, bActivateOnInput
, 其默认值为 true
并重写 UAbilitySystemComponent::AbilityLocalInputPressed()
.
1 | void UGSAbilitySystemComponent::AbilityLocalInputPressed(int32 InputID) |
11 网络
网络执行策略 (Net Execution Policy)
网络执行策略决定了 GameplayAbility
在网络上如何执行
GameplayAbility
运行在所属 (Owning)客户端还是服务端取决于网络执行策略(Net Execution Policy) 而不是 Simulated Proxy.网络执行策略(Net Execution Policy)
决定某个 GameplayAbility
是否是客户端可预测的, 其对于可选的 Cost
和 Cooldown GameplayEffect
包含有默认行为.Simulated Client
不会运行 GameplayAbility
, 而是当服务端执行 Ability
时, 任何需要在 Simulated Proxy 上展现的视觉效果 (像动画蒙太奇)将会被复制 (Replicate)或者通过 AbilityTask
进行 RPC 或者对于像声音和粒子这样的装饰效果使用 GameplayCue.
网络执行策略 (Net Execution Policy) | 描述 |
---|---|
Local Only | GameplayAbility 只运行在所属 (Owning)客户端. 这对那些只需做本地视觉变化的 Ability 很有用. 单人游戏应该使用 Server Only . |
Local Predicted | Local Predicted GameplayAbility 首先在所属 (Owning)客户端激活, 之后在服务端激活. 服务端版本会纠正客户端预测的所有不正确的地方. 参见 Prediction. |
Server Only | GameplayAbility 只运行在服务端. 被动 GameplayAbility 一般是 Server Only . 单人游戏应该使用该项. |
Server Initiated | Server Initiated GameplayAbility 首先在服务端激活, 之后在所属 (Owning)客户端激活. 我个人使用的不多 (如果有的话). |
网络安全策略 (Net Security Policy)
网络安全策略决定了 Ability 应该在网络的何处执行. 它为尝试执行限制 Ability 的客户端提供了保护.
网络安全策略 | 描述 |
---|---|
ClientOrServer | 没有安全需求. 客户端或服务端可以自由地触发该 Ability 的执行和终止. |
ServerOnlyExecution | 客户端对该 Ability 请求的执行会被服务端忽略, 但客户端仍可以请求服务端取消或结束该 Ability. |
ServerOnlyTermination | 客户端对该 Ability 请求的取消或结束会被服务端忽略, 但客户端仍可以请求执行该 Ability. |
ServerOnly | 服务端控制该 Ability 的执行和终止, 客户端的任何请求都会被忽略. |
客户端预测 GA
对于客户端预测GameplayAbility
的激活序列:
- 所属 (Owning)客户端调用
TryActivateAbility()
- 调用
InternalTryActivateAbility()
- 调用
CanActivateAbility()
并返回是否满足GameplayTag
需求,ASC
是否满足技能花费,GameplayAbility
是否不在冷却期和当前是否没有其他实例被激活 - 调用
CallServerTryActivateAbility()
并传入其生成的Prediction Key
- 调用
CallActivateAbility()
- 调用
PreActivate()
, Epic 称之为”boilerplate init stuff” - 调用
ActivateAbility()
最终激活 Ability
服务端接收到 CallServerTryActivateAbility()
- 调用
ServerTryActivateAbility()
- 调用
InternalServerTryActivateAbility()
- 调用
InternalTryActivateAbility()
- 调用
CanActivateAbility()
并返回是否满足GameplayTag
需求,ASC
是否满足技能花费,GameplayAbility
是否不在冷却期和当前是否没有其他实例被激活 - 如果成功则调用
ClientActivateAbilitySucceed()
告知客户端更新它的ActivationInfo
(即该激活已由服务端确认)并广播OnConfirmDelegate
代理. 这和输入确认 (Input Confirmation)不一样. - 调用
CallActivateAbility()
- 调用
PreActivate()
, Epic 称之为”boilerplate init stuff” - 调用
ActivateAbility()
最终激活 Ability
如果服务端在任意时刻激活失败, 就会调用 ClientActivateAbilityFailed()
, 立即终止客户端的 GameplayAbility
并撤销所有预测的修改.
❌Replication Policy
不要使用该选项. 这个名字会误导你并且你并不需要它. **GameplayAbilitySpec 默认会从服务端向所属 (Owning)客户端复制**, 上文提到过, GameplayAbility
不会运行在 Simulated Proxy 上, 其使用 AbilityTask
和 GameplayCue
来复制或者 RPC 视觉变化到 Simulated Proxy. Epic 的 Dave Ratti 已经表明要在未来移除该选项的意愿.
❌Server Respects Remote Ability Cancellation
这个选项往往会引起麻烦. 它的意思是如果客户端的 GameplayAbility
由于玩家取消或者自然完成时, 就会强制它的服务端版本结束而不管其是否完成. 最重要的是之后的问题, 特别是对于高延迟玩家所使用的客户端预测的 GameplayAbility
. 一般情况下禁用该选项.
❌Replicate Input Directly
设置该选项就会一直向服务端复制输入的按下 (Press)和抬起 (Release)事件. Epic 不建议使用该选项而是依靠创建在已有输入相关的 AbilityTask 中的 Generic Replicated Event
(如果你的输入绑定在ASC).
Epic 的注释:
1 | /** Direct Input state replication. These will be called if bReplicateInputDirectly is true on the ability and is generally not a good thing to use. (Instead, prefer to use Generic Replicated Events). */ |
Ability 批处理
一般 GameplayAbility
的生命周期最少涉及 2 到 3 个自客户端到服务端的 RPC.
- CallServerTryActivateAbility ()
- ServerSetReplicatedTargetData () (可选)
- ServerEndAbility ()
如果 GameplayAbility
在一帧同一原子 (Atomic)组中执行这些操作, 我们就可以优化该工作流, 将所有 2 个或 3 个 RPC 批处理 (整合)为 1 个 RPC. GAS
称这种 RPC 优化为 Ability批处理
. 常见的使用 Ability批处理
时机的例子是枪支命中判断. 枪支命中判断时, 会在一帧同一原子 (Atomic)组中做射线检测, 发送 TargetData 到服务端并结束 Ability. GASShooter 样例项目对其枪支命中判断使用了该技术.
半自动枪支是最好的案例, 其将 CallServerTryActivateAbility()
, ServerSetReplicatedTargetData()
(子弹命中结果)和 ServerEndAbility()
批处理成一个而不是三个 RPC
.
全自动/爆炸开火枪支会对第一发子弹将 CallServerTryActivateAbility()
和 ServerSetReplicatedTargetData()
批处理成一个而不是两个 RPC, 接下来的每一发子弹都是它自己的 ServerSetReplicatedTargetData()
RPC, 最后, 当停止射击时, ServerEndAbility()
会作为单独的 RPC 发送. 这是最坏的情况, 我们只保存了第一发子弹的一个 RPC 而不是两个. 这种情况也可以通过 GameplayEvent 激活 Ability 来实现, 该 GameplayEvent
会自客户端向服务端发送带有 EventPayload
的子弹 TargetData
. 后者的缺点是 TargetData
必须在 Ability 外部生成, 尽管批处理方法在 Ability 内部生成 TargetData
.
Ability 批处理在 ASC 中是默认禁止的. 为了启用 Ability 批处理, 需要重写 ShouldDoServerAbilityRPCBatch()
以返回 true:
1 | virtual bool ShouldDoServerAbilityRPCBatch() const override { return true; } |
现在 Ability 批处理已经启用, 在激活你想使用批处理的 Ability 之前, 必须提前创建一个 FScopedServerAbilityRPCBatcher
结构体, 这个特殊的结构体会在其域内尝试批处理所有跟随它的 Ability, 一旦出了 FScopedServerAbilityRPCBatcher
域, 所有已激活的 Ability 将不再尝试批处理. FScopedServerAbilityRPCBatcher
通过在每个可以批处理的函数中设置特殊代码来运行, 其会拦截 RPC 的发送并将消息打包进一个批处理结构体, 当出了 FScopedServerAbilityRPCBatcher
后, 它会在 UAbilitySystemComponent::EndServerAbilityRPCBatch()
中自动将该批处理结构体 RPC 到服务端, 服务端在 UAbilitySystemComponent::ServerAbilityRPCBatch_Internal(FServerAbilityRPCBatch& BatchInfo)
中接收该批处理结构体, BatchInfo
参数会包含几个 flag, 其包括该 Ability 是否应该结束, 激活时输入是否按下和是否包含 TargetData
. 这是个可以设置断点以确认批处理是否正确运行的好函数. 作为选择, 可以使用 AbilitySystem.ServerRPCBatching.Log 1
来启用特别的 Ability 批处理日志.
这个方法只能在 C++中完成, 并且只能通过 FGameplayAbilitySpecHandle
来激活 Ability.
1 | bool UGSAbilitySystemComponent::BatchRPCTryActivateAbility(FGameplayAbilitySpecHandle InAbilityHandle, bool EndAbilityImmediately) |
GASShooter 对半自动和全自动枪支使用了相同的批处理 GameplayAbility
, 并没有直接调用 EndAbility()
(它通过一个只能由客户端调用的 Ability 在该 Ability 外处理, 该只能由客户端调用的 Ability 用于管理玩家输入和基于当前开火模式对批处理 Ability 的调用). 因为所有的 RPC 必须在 FScopedServerAbilityRPCBatcher
域中, 所以我提供了 EndAbilityImmediately
参数以使仅客户端的控制/管理可以明确该 Ability 是否可以批处理 EndAbility()
(半自动)或不批处理 EndAbility()
(全自动)以及之后 EndAbility()
是否可以在其自己的 RPC 中调用.
GASShooter 暴露了一个蓝图节点以允许上文提到的仅客户端调用的 Ability 所使用的批处理 Ability 来触发批处理 Ability. (译者注: 此处相当拗口, 但原文翻译确实如此, 配合项目浏览也许会更容易明白些.)
7 Ability Async 和 Ability Task
Ability Async
和 Abiilty Task
用于执行异步逻辑。它们通常遵循 “启动某项操作并等待完成或中断 “的模式。
Ability Async
[!NOTE]
在任何蓝图执行
AbilityAsync 是特定 Ability BlueprintAsyncActions 的基类。在基类的基础上增加了对 ASC
的引用。(如果要实现蓝图异步节点监听 ASC 可以直接继承此类,或继承基类然后添加对 ASC 的引用。区别不大)
- 类似于 Ability Task,但它们可以像 Actor 一样从任何蓝图中执行,并且不与特定 Ability 的生命周期绑定。
- 默认情况下,这些 Action 仅由生成它们的蓝图保持存活,如果蓝图实例被销毁或生成替代品,这些动作最终将被销毁。
EndAction
应在一次 Action 成功或失败时调用,但对于具有多个触发器的较长寿命的 Action ,可从蓝图中调用。
Ability Task
[!NOTE]
仅 GA 执行
AbilityTask 是可以在执行 Ability 的同时进行的小型、自包含操作(只能由 GA 调用)。
GAS 自带很多 AbilityTask
:
熟悉 AbilityTasks 的最佳方法是查看现有任务,如 UAbilityTask_WaitOverlap(非常简单)和 UAbilityTask_WaitTargetData(复杂得多)。
这些是使用 AbilityTask 的基本要求:
- 在能力任务中定义动态多播、BlueprintAssignable 委托。这些是 Task 的输出。当这些委托触发时,调用的蓝图将重新开始执行。
- 输入由 static 工厂函数定义,该函数将实例化 Task 的一个实例。该函数的参数定义了 Task 的输入。工厂函数要做的就是实例化 Task ,并可能设置起始参数。它不应调用任何回调委托!
- 重载
Activate()
函数。该函数应实际 start/execute 任务逻辑。在此调用回调委托。
- 检查列表:
- 重载
OnDestroy()
并取消 Task 注册的任何回调。同时调用Super::EndTask
- 实现真正激活 Task 的
Activate
函数。不要在静态工厂函数中激活 Task!应该在此绑定委托回调
[!warning]
UAbilityTask
的构造函数中强制硬编码允许最多 1000 个同时运行的AbilityTask
, 当设计那些同时拥有数百个 Character 的游戏 (像 RTS)的GameplayAbility
时要注意这一点.
Spawn Actor 的 AbilityTask
我们为希望 spawn actor 的 AbilityTask 提供了额外支持。虽然这可以在 Activate ()
函数中实现,但无法传递动态的 “ExposeOnSpawn
“actor 属性。这是蓝图的一个强大功能,为了支持这一功能,需要实现不同的步骤 3:
- 不通过
Activate ()
函数生成,而是使用BeginSpawningActor ()
和FinishSpawningActor ()
函数。- 区别于
Activate()
,它们能够使所生成的 Actor 可以通过meta = (ExposeOnSpawn = true)
向蓝图节点暴露参数。AbilityTask_WaitTargetData
中的 StartLocation、Reticle 参数就是这样来的,它们都是 TargetActor 通过这种方式暴露出来的参数。
- 区别于
BeginSpawningActor ()
必须接收名为 “Class “的TSubclassOf<YourActorClassToSpawn>
参数。它还必须有一个名为SpawnedActor
的YourActorClassToSpawn*&
类型的外部引用参数。该函数可以决定是否要生成角色(如果希望根据网络授权来决定是否生成角色,则该函数非常有用)。
BeginSpawningActor ()
可以使用SpawnActorDeferred
来实例化一个角色。这一点很重要,否则 UCS 将在 SpawnedActor 参数设置之前运行。BeginSpawningActor ()
还应将 SpawnedActor 参数设置为所生成的角色。- 接下来,生成的字节码将把 “SpawnedActor 参数 “设置为用户设置的任何参数。
- 如果你生成了某个 Actor,
FinishSpawningActor()
就会被调用,并传入刚刚生成的 Actor。你必须在这个 Actor上调用ExecuteConstruction
+PostActorConstruction
!title:AbilityTask_WaitTargetData 1
2
3
4
5
6
7
UFUNCTION(BlueprintCallable, meta = (HidePin = "OwningAbility", DefaultToSelf = "OwningAbility", BlueprintInternalUseOnly = "true"), Category = "Abilities")
bool BeginSpawningActor(UGameplayAbility* OwningAbility, TSubclassOf<AGameplayAbilityTargetActor> Class, AGameplayAbilityTargetActor*& SpawnedActor);
UFUNCTION(BlueprintCallable, meta = (HidePin = "OwningAbility", DefaultToSelf = "OwningAbility", BlueprintInternalUseOnly = "true"), Category = "Abilities")
void FinishSpawningActor(UGameplayAbility* OwningAbility, AGameplayAbilityTargetActor* SpawnedActor);
值得注意的是 BeginSpawningActor
和 FinishSpawningActor
在蓝图中会自动调用,UK2Node_LatentGameplayTaskCall
类保证了这一点。在 C++中则需要手动调用:
![[GAS AbilityTask 整理#小部分 Tasks 的自动绑定机制]]
可以在 AbilityTask_SpawnActor()
查看示例。
自定义
[[异步节点]]
使用AbilityTask
在C++中创建并激活AbilityTask
(GDGA_FireGun.cpp):
1 |
|
在蓝图中, 我们只需使用为 AbilityTask
创建的蓝图节点, 不必调用 ReadyForActivate()
, 其由 {cpp}K2Node_LatentGameplayTaskCall.cpp
自动调用.K2Node_LatentGameplayTaskCall
也会自动调用 BeginSpawningActor()
和 FinishSpawningActor()
(如果它们存在于你的 AbilityTask
类中, 查看 AbilityTask_WaitTargetData
), 再强调一遍, K2Node_LatentGameplayTaskCall
只会对蓝图做这些自动操作, 在 C++中, 我们必须手动调用 ReadyForActivation()
, BeginSpawningActor()
和 FinishSpawningActor()
.
如果想要手动取消 AbilityTask
, 只需在蓝图(Async Task Proxy)或 C++中对 AbilityTask
对象调用 EndTask()
.
8 Gameplay Cue
01 定义
GameplayCue
是负责运行视觉和声音效果的 Actor 和 UObject,执行非游戏逻辑相关的功能, 像音效, 粒子效果, 镜头抖动等等。
GameplayCue
一般是可复制和可预测的(除非设置 Executed
, Added
或 Removed
是本地的)。是在多人游戏中复制美化效果 (如音效、特效等,称为 Cosmetic)的首选方法。
**GA
和 GE
可以触发 Cue
**。它们通过四个可在本地或蓝图代码中覆盖的主函数来产生作用:On Active
、While Active
、Removed
及 Executed
(仅由 GE 使用)。
GameplayCue
使用不可靠的复制,因此有可能一些客户端没有接收到提示或显示其反馈。如果你将 Gameplay 代码绑定到这些 Cue,这可能造成不复制。因此,GameplayCue
应该仅用于美化反馈。对于需要复制到所有客户端的 Gameplay 相关反馈,你应该转而依赖 Ability Tasks 来处理复制。播放蒙太奇(Play Montage) 技能任务就是很好的例子。
02 调用 GC
**一般通过 GE 配置,也可以在 GA 里调用 execute/add 触发
通过 GE 调用
GameplayEffect
可以 Add/Remove/Execute GameplayCue
:
(Instant)GE
可以调用 Execute(Duration)
或(Infinite)
可以调用 Add 和 Remove.
选择 GC 对应的 Tag 即可,可以同时选择多个 Tag,触发多个 GC。
默认在成功应用 (未被 Tag 或 Immunity 阻塞)的 GE
中填写所有应该触发的 GameplayCue
的 GameplayTag
.
Require Modifier Success to Trigger Cues
:需要 GE 成功修改 Attribute 后才调用 GC,而不仅仅是 Apply 该 GE。Suppress Stacking Cues
:如果为 true,GameplayCues 将只在 Stacking GE 的第一个实例中被触发。否则触发多次 GC(如果使用了 Stack,对应的一定是GameplayCueNotify_Actor
)。- Min、Max Level 和 Magnitude Attribute 则与传入参数有关
- Raw Magnitude 即为 Magnitude Attribute 的值,而 Normalized Magnitude 的计算方式如下:
$Normalized = (Raw - Min) / (Max - Min)$
如上图所示,当 Min=0 且 Max=100 时,Normalized = Raw / 100,即百分比。
手动调用
UGameplayCueFunctionLibrary
提供了全局的 C++函数和蓝图节点来 Execute
, Add
或 Remove
GameplayCue.
1 | /** GameplayCues can also come on their own. These take an optional effect context to pass through hit result, etc */ |
03 GameplayCueNotify
GC 的触发分为两种情况 (两个 GameplayCueNotify
类): Static
和 Actor
。
Static Cue
直接在CDO
上调用, 这意味着不要在 GC 中存储一些状态信息, 因为它不会生成新的对象;Actor Cue
触发Spawn
生成新的实例, 这些生效的 GC 最终也是在 ASC 里引用保存
它们各自响应不同的事件, 并且不同的GE
类型可以触发它们. 根据你的逻辑重写相关的事件.
GameplayCue类 | 事件 | GE类型 | 描述 |
---|---|---|---|
GameplayCueNotify_Static |
Execute | Instant或Periodic | 直接操作CDO (意味着没有实例)并且对于一次性效果(像击打伤害)是极好的. |
GameplayCueNotify_Actor |
Add或Remove | Duration或Infinite | 会在Added 时生成一个新的实例, 因为其是实例化的, 所以可以随时间推移执行操作直到被移除(Removed). 这对循环的声音和粒子效果是很好的, 其会在(Duration)GE 或(Infinite)GE 被移除或手动调用移除时移除. 其也自带选项来管理允许同时添加(Added)多少个, 因此多个相同效果的应用只启用一次声音或粒子效果. |
GameplayCueNotify
技术上可以响应任何事件, 但是这是我们一般使用它的方式.
[!warning]
当使用GameplayCueNotify_Actor
时, 要勾选Auto Destroy on Remove
, 否则之后对GameplayCueTag
的添加(Add)
调用将无法进行.
当使用除 Full
之外的 ASC
复制模式时, Add
和 Remove
事件会在服务端玩家中触发两次(Listen Server) 一次是应用 GE
, 再一次是从”Minimal”NetMultiCast
到客户端. 然而, WhileActive
事件仍会触发一次. 所有的事件在客户端中只触发一次.
04 GameplayCue 标签
通常通过发送一个强制带有 GameplayCue.XX
父层级的 GameplayTag
和 UGameplayCueManager
中定义的 Execute, Add 或 Remove 来触发 GameplayCue
.
[!NOTE]
再次强调,GameplayCue
的GameplayTag
需要以GameplayCue
为开头, 举例来说, 一个有效的GameplayCue
的GameplayTag
可能是GameplayCue.A.B.C
.
创建GameplayCue
时,你会运行要在事件图表中播放的效果的逻辑。GameplayCue
可以与一系列GameplayTag
关联,并且匹配这些标签的GE
将自动应用 Apply 它们。
例如,如果你将标签 [Ability. Magic. Fire. Weak] 添加到 GameplayCue
,拥有 [Ability. Magic. Fire. Weak] 的 GE
将自动生成该 GameplayCue
并运行它。这样就可以快速轻松创建视觉效果的通用库,而不必手动从代码触发它们。或者,你也可以触发没有 GE 关联的 Cue 。有关此实现的例子,你可以查看 Lyra 示例游戏的武器发射反馈。
05 FGameplayCueParameters
本项目的 GA_E_MeeAttack 使用 GameplayCue 播放音效和特效
GameplayCue
接受一个包含额外 GameplayCue
信息的 FGameplayCueParameters
结构体作为参数.
- 如果你在
GA
或ASC
中使用函数手动触发GameplayCue
, 那么就必须手动填充传递给GameplayCue
的GameplayCueParameters
结构体. - 如果
GameplayCue
由GE
触发, 那么下列的变量会自动填充到FGameplayCueParameters
结构体中:- AggregatedSourceTags
- AggregatedTargetTags
- GameplayEffectLevel
- AbilityLevel
- EffectContext
- Magnitude(如果
GE
具有在GameplayCue
标签容器(TagContainer)上方的下拉列表中选择的Magnitude Attribute
和影响该Attribute
的相应Modifier
)
当手动触发GameplayCue
时, GameplayCueParameters
中的SourceObject
变量是一个传递任意数据到GameplayCue
的好地方.
[!NOTE]
参数结构体中的某些变量, 像Instigator
, 可能已经存在于EffectContext
中.EffectContext
也可以包含FHitResult
用于存储GameplayCue
在世界中生成的位置. 子类化EffectContext
似乎是一个传递更多数据到GameplayCue
的好方法, 特别是对于那些由GameplayEffect
触发的GameplayCue
.
查看 UAbilitySystemGlobals 中用于填充 GameplayCueParameters
结构体的三个函数以获得更多信息. 它们是虚函数, 因此你可以重写它们以自动填充更多信息.
1 | /** Initialize GameplayCue Parameters */ |
06 UGameplayCueManager
默认情况下, 游戏开始时 GameplayCueManager
会扫描游戏的全部目录以寻找 GameplayCueNotify
并将其加载进内存。可以见到以下警告
1 | LogAbilitySystem: Warning: No GameplayCueNotifyPaths were specified in DefaultGame.ini under [/Script/GameplayAbilities.AbilitySystemGlobals]. Falling back to using all of /Game/. This may be slow on large projects. Consider specifying which paths are to be searched. |
我们可以设置 DefaultGame.ini
来修改 GameplayCueManager
的扫描路径.
1 | [/Script/GameplayAbilities.AbilitySystemGlobals] |
我们确实想要 GameplayCueManager
扫描并找到所有的 GameplayCueNotify
, 然而, 我们不想要它异步加载每一个, 因为这会将每个 GameplayCueNotify
和它们所引用的音效和粒子特效放入内存而不管它们是否在关卡中使用. 在像 Paragon 这样的大型游戏中, 内存中会放入数百兆的无用资源并造成卡顿和启动时无响应.
在启动时异步加载每个 GameplayCue
的一种可选方法是只异步加载那些会在游戏中触发的 GameplayCue
, 这会在异步加载每个 GameplayCue
时减少不必要的内存占用和潜在的游戏无响应几率, 从而避免特定 GameplayCue
在游戏中第一次触发时可能出现的延迟效果.
SSD 不存在这种潜在的延迟, 我还没有在 HDD 上测试过, 如果在 UE 编辑器中使用该选项并且编辑器需要编译粒子系统的话, 就可能会在 GameplayCue 首次加载时有轻微的卡顿或无响应, 这在构建版本中不是问题, 因为粒子系统肯定是编译好的。
首先我们必须继承 UGameplayCueManager
并告知 AbilitySystemGlobals
类在 DefaultGame.ini
中使用我们的 UGameplayCueManager
子类.
1 | [/Script/GameplayAbilities.AbilitySystemGlobals] |
在我们的 UGameplayCueManager
子类中, 重写 ShouldAsyncLoadRuntimeObjectLibraries()
.
1 | virtual bool ShouldAsyncLoadRuntimeObjectLibraries() const override |
07 阻止 GameplayCue 响应
有时我们不想响应 GameplayCue
, 例如我们阻止了一次攻击, 可能就不想播放附加在伤害 GameplayEffect
上的击打效果或者自定义的效果. 我们可以在 ExecCalc 中调用 OutExecutionOutput.MarkGameplayCuesHandledManually()
, 之后手动发送我们的 GameplayCue
事件到 Target
或 Source
的 ASC
中.
如果你想某个特别指定ASC
中的GameplayCue
永不触发, 可以设置AbilitySystemComponent->bSuppressGameplayCues = true;
.
08 网络
GameplayCue 批处理
每次GameplayCue
触发都是一次不可靠的多播(NetMulticast)RPC. 在同一时刻触发多个GameplayCue
的情况下, 有一些优化方法来将它们压缩成一个RPC或者通过发送更少的数据来节省带宽.
手动RPC
假设你有一个可以发射8枚弹丸的霰弹枪, 就会有8个轨迹和碰撞GameplayCue
. GASShooter采用将它们联合成一个RPC的延迟(Lazy)方法, 其将所有的轨迹信息保存到EffectContext作为TargetData. 尽管其将RPC数量从8降为1, 然而还是在这一个RPC中通过网络发送大量数据(~500 bytes). 一个进一步优化的方法是使用一个自定义结构体发送RPC, 在该自定义RPC中你需要高效编码命中位置(Hit Location)或者给一个随机种子以在接收端重现/近似计算碰撞位置, 客户端之后需要解包该自定义结构体并重现客户端执行的GameplayCue
.
运行机制:
- 声明一个
FScopedGameplayCueSendContext
. 其会阻塞UGameplayCueManager::FlushPendingCues()
直到其出域, 意味着所有GameplayCue
都将会排队等候直到该FScopedGameplayCueSendContext
出域. - 重写
UGameplayCueManager::FlushPendingCues()
以将那些可以基于一些自定义GameplayTag
批处理的GameplayCue
合并进自定义的结构体并将其RPC到客户端. - 客户端接收自定义结构体并将其解包进客户端执行的
GameplayCue
.
该方法也可以在你的GameplayCue
需要特别指定的参数时使用, 这些需要特别指定的参数不能由GameplayCueParameter
提供, 并且你不想将它们添加到EffectContext
, 像伤害数值, 暴击标识, 破盾标识, 处决标识等等.
GE 中的多个 GameplayCue
一个 GE
中的所有 GameplayCue
已经由一个 RPC 发送了. 默认情况下, UGameplayCueManager::InvokeGameplayCueAddedAndWhileActive_FromSpec()
会在不可靠的 (NetMulticast)RPC 中发送整个 GE Spec
(除了转换为 FGameplayEffectSpecForRPC
)而不管 ASC
的复制模式, 取决于 GE Spec
的内容, 这可能会使用大量带宽, 我们可以通过设置 AbilitySystem.AlwaysConvertGESpecToGCParams 1
来将其优化, 这会将 GE Spec
转换为 FGameplayCueParameter
结构体并且 RPC 它而不是整个 FGameplayEffectSpecForRPC
, 这会节省带宽但是只有较少的信息, 取决于 GESpec
如何转换为 GameplayCueParameters
和你的 GameplayCue
需要知道什么.
客户端 GameplayCue
从 GA
和 ASC
中暴露的用于触发 GC
的函数默认是可复制的. 每个 GameplayCue
事件都是一个多播 (Multicast) RPC. 这会导致大量 RPC.
GAS 也强制在每次网络更新中最多能有两个相同的 GameplayCue
RPC. 我们可以通过使用客户端 GameplayCue
来避免这个问题. 客户端 GameplayCue
只能在单独的客户端上 Execute
, Add
或 Remove
.
可以使用客户端 GameplayCue
的场景:
- 抛射物伤害
- 近战碰撞伤害
- 动画蒙太奇触发的
GameplayCue
你应该添加到 ASC
子类中的客户端 GameplayCue
函数:
1 | UFUNCTION(BlueprintCallable, Category = "GameplayCue", Meta = (AutoCreateRefTerm = "GameplayCueParameters", GameplayTagFilter = "GameplayCue")) |
1 | void UPAAbilitySystemComponent::ExecuteGameplayCueLocal(const FGameplayTag GameplayCueTag, const FGameplayCueParameters & GameplayCueParameters) |
如果某个 GameplayCue
是客户端添加的, 那么它也应该自客户端移除. 如果它是通过复制添加的, 那么它也应该通过复制移除.
9 Ability System Globals
AbilitySystemGlobals
类保存有关 GAS 的全局信息. 大多数变量可以在 DefaultGame.ini
中设置. 一般你不需要和该类互动, 但是应该知道它的存在. 如果你需要继承像 GameplayCueManager
或 GameplayEffectContext
这样的对象, 就必须通过 AbilitySystemGlobals
来做.
想要继承AbilitySystemGlobals
, 需要在DefaultGame.ini
中设置类名:
1 | [/Script/GameplayAbilities.AbilitySystemGlobals] |
InitGlobalData()
从 UE 4.24开始, 必须调用 UAbilitySystemGlobals::InitGlobalData()
来使 TargetData
, 否则你会遇到关于 ScriptStructCache
的错误, 并且客户端会从服务端断开连接, 该函数只需要在项目中调用一次. Fortnite 从 AssetManager 类的起始加载函数中调用该函数, Paragon 是从 UEngine::Init()中调用的. 我发现将其放到 UEngineSubsystem::Initialize()
是个好位置, 这也是样例项目中使用的. 我觉得你应该复制这段模板代码到你自己的项目中以避免出现 TargetData
的使用问题.
如果你在使用AbilitySystemGlobals GlobalAttributeSetDefaultsTableNames
时发生崩溃, 可能之后需要像Fortnite一样在AssetManager
或GameInstance
中调用UAbilitySystemGlobals::InitGlobalData()
而不是在UEngineSubsystem::Initialize()
中. 该崩溃可能是由于Subsystem
的加载顺序引发的, GlobalAttributeDefaultsTables
需要加载EditorSubsystem
来绑定UAbilitySystemGlobals::InitGlobalData()
中的委托.
10 Targeting
Targeting:目标选择
用于解决游戏的目标选择时信息的获取和外观表现
TargetActor
是执行选择目标逻辑的 ActorTargetData
是选择目标逻辑的计算结果WorldReticle
是选择目标过程中进行表现的 Actor
它们三者配合的步骤一般如下:
- 激活技能,使用 AbilityTask 生成 TargetActor
- 触发 TargetActor 的选择目标逻辑。
- 具体触发的时机要根据玩法决定:
- 例如,某些跟踪子弹,会随着我们的准星移动锁定 / 解锁目标,在此过程中要不断地更新目标选择,所以可以在 Tick 或 Timer 中反复触发。
- 例如,一般的直线弹道子弹,“扣动扳机” 时,生成 TargetActor,在其确认时一次性触发计算即可。
- 具体触发的时机要根据玩法决定:
- 确认目标,数据填到 TargetData 结构体实例中,Broadcast 出去(也可能发生取消目标)
- 技能接收到 TargetData,提取信息,执行技能逻辑,例如生成子弹等
具体实现可以参考 GASShooter 项目的 AGSGATA_LineTrace 和 AGSGATA_SphereTrace 类。
Target Data
FGameplayAbilityTargetData
一个通用的 TargetData 结构体。我们希望通用函数来【生成】这些 TargetData,其他通用函数来【消耗】这些 TargetData。
[!NOTE]
一些 TargetData 【生成】案例:
- Overlap/Hit 碰撞事件生成关于谁在近战攻击中被击中的 TargetData
- 鼠标输入导致 hit trace,十字准星前面的 actor 被转换为 TargetData
- 鼠标输入导致 TargetData 从所有者的准星视图 origin/direction 生成
- 一个 AOE 技能,所有在发起者周围半径内的 actor 被添加到 TargetData
- Panzer Dragoon 风格的 ‘painting’ targeting 模式(就是按住瞄准,扫描到的敌人身上都生成准星 UI)
- MMORPG 风格的地面 AOE targeting 模式(可能是地面上的位置和被定位的 actor)
一些 TargetData 【消耗】案例:
- 将 GE 应用到 TargetData 中的所有 actor
- 从 TargetData 中的所有 actor 中找到最近的 actor
- 在 TargetData 中的所有 actor 上调用某些函数
- 过滤 (Filter)或合并 (merge) TargetData
- 在 TargetData 位置 Spawn 一个新的 actor*
或许最好区分与 Actor 相关的 Targeting 和与定位相关的 Targeting data?
- AOE 类型的 Targeting data 模糊了界限
Targetdata
保存了特定的 actors/object 引用,FHitResult,也能保存通用的 location/direction/origin 信息。可以继承它以增添想要的任何数据, 其可以简单理解为在客户端和服务端的 GA
中传递数据.
创建TargetData
TargetData
一般由 Target Actor
或者手动创建, 供 AbilityTask
]使用, 或者 GE
通过 GE Context
使用。因为其位于 GE Context
中, 所以 ExecCalc, MMC, GameplayCue
和 AttributeSet
的后端函数可以访问该 TargetData
.
使用TargetData
基础结构体 FGameplayAbilityTargetData
不能直接使用, 而是要继承它。 因为它只定义了数据访问方法,没有定义数据本身。
GAS 的 GameplayAbilityTargetTypes.h
中有一些开箱即用的派生 FGameplayAbilityTargetData
结构体:
FGameplayAbilityTargetData_LocationInfo
:带有源位置和目标位置的 TargetDataFGameplayAbilityTargetData_ActorArray
:带有源位置和目标 Actot 列表的 TargetData,适用于 AOE 攻击目标选择。FGameplayAbilityTargetData_SingleTargetHit
:带有单个FHitResult
信息的TargetData,适合单目标子弹。TargetData 被打包到FHitResult
自定义 TargetData
上述内置的 TargetData,基本上是够用了,如果需要创建新的 TargetData 类型,就需要视携带的数据类型 FGameplayAbilityTargetData
的虚函数:
1 | virtual TArray<TWeakObjectPtr<AActor>> GetActors() const; |
debug 相关虚函数
1 | /** Returns the serialization data, must always be overridden */ |
源代码在实现完类型后,还有附带下面这一段代码,看注释应该和网络同步序列化有关,反正依瓢画葫芦复制 + 替换类型名称即可。
1 | template<> |
FGameplayAbilityTargetDataHandle
FGameplayAbilityTargetDataHandle
包含一个 TArray<TSharedPtr<FGameplayAbilityTargetData>> Data
这个中间结构体可以:
- 避免在蓝图中复制完整的 TargetData 结构体
- 允许我们利用 TargetData 结构体中的多态性
- 允许我们实现 NetSerialize 并在客户端/服务器之间按值复制
- 避免使用 UObjects,可用于在蓝图中提供多态性和引用传递。
**我们一般不直接传递 FGameplayAbilityTargetData
而是使用 FGameplayAbilityTargetDataHandle TargetActor 对 TargetData 进行广播时,不会直接使用
FGameplayAbilityTargetData的实例,而是需要将其封装为
FGameplayAbilityTargetDataHandle` 来广播。
具体过程是 :
- 创建 TargetData,并且填充数据。
- 创建 FGameplayAbilityTargetDataHandle 对象(也可以使用带参的构造函数直接构建),并且调用
Add()
添加上面创建的 TargetData。 - 通过广播进行传递。
本项目中 AbilityTask_TargetHitUnderCursor.h
创建了 TargetDataHandle, 用于鼠标选取敌人目标:
1 | void UAbilityTask_TargetHitUnderCursor::SendCursorHitData() |
网络
多人游戏中, FGameplayAbilityTargetData
是用于通过网络传输定位数据的通用结构体.
当客户端 Ability Task 激活时,服务器在 $\varepsilon$ 秒后接收到请求。如果需要计算数据,客户端的数据需要 $\delta$ 秒传到服务器。无法确定谁先到达,如果数据到达完了就无法完成服务器端的计算。
解决这个问题的方法是使用 GAS 内置的 TargetData,通过 ServerSetReplicatedTargetData()
函数代替 RPC 将目标数据发送给服务器。
先激活的情况:服务器激活时绑定 TargetDataSet 委托,这样当目标数据到达服务器时,TargetDataSet 委托将被广播,因为服务器已经提前绑定了该委托,因此可以接收到目标数据。
后激活的情况:数据先到服务器,TargetDataSet 委托被广播。但此时服务器还没绑定,就会接收不到数据。这种情况下要调用 CallReplicatedTargetDataDelegateIfSet ()
。当服务器激活后,它将再次强制广播该委托。(注意这里的复制是从客户端到服务器,不要和网络中的 Replicated 机制混淆)
Target Actor
GA
一般使用 AbilityTask_WaitTargetData
生成 TargetActor
(WaitTargetDataUsingActor
用来监听已有的 TargetActor
)以在世界中可视化和获取定位信息收集的 TargetData
存入 TargetDataHandle
并通过委托返回。
可以把 TargetActor 理解为一个场景信息探测器,使用 LineTrace 或者 Overlap 来获取场景数据,内部实现步骤大致如下:(TODO:和上节介绍的 UAbilityTask_TargetHitUnderCursor
代码极其类似,只不过继承了不一样的基类。这里有个疑问,上面的代码用 AbilityTask 是不是不太合适?可否转换成 TargetActor?)
- 使用 LineTrace 或者 Overlap 来获取场景数据
- 将数据存入
TargetData
- 将
Targetdata
打包,返回TargetDataHandle
TargetActor
是基于 AActor
的, 因此它可以使用任意种类的可视组件来表示其在何处和如何定位的, 例如静态网格体(Static Mesh)和贴花(Decal)。
- 静态网格体(Static Mesh)可以用来可视化角色将要建造的物体,
- 贴花(Decal)可以用来表现地面上的效果区域。样例项目使用带有地面贴花的 AGameplayAbilityTargetActor_GroundTrace 表示陨石技能的伤害区域效果.
- 它也可以什么都不显示, 例如, GASShooter 中的枪支命中判断, 要对目标的射线检测显示些什么就是没有意义的.
AbilityTask_WaitTargetData
其他 AbilityTask 可见: [[GAS AbilityTask 整理#TargetData 类]]
AbilityTask_WaitTargetData
将一个AGameplayAbilityTargetActor
类作为参数, 其会在每次AbilityTask
激活时生成一个实例并且在AbilityTask
结束时销毁该TargetActor
.AbilityTask_WaitTargetDataUsingActor
接受一个已经生成的TargetActor
, 但是在该AbilityTask
结束时仍会销毁它.
- 优化建议: 这两种
AbilityTask
都是低效的, 因为它们在每次使用时都要生成或需要一个新生成的TargetActor
, 它们用于原型开发是很好的, 但是在实际发布版本中, 如果有持续产生TargetData
的场景, 像全自动开火, 你可能就要探索优化它的办法. GASShooter 有一个自定义的 AGameplayAbilityTargetActor 子类和一个完全重写的 WaitTargetDataWithReusableActor AbilityTask, 其允许你复用TargetActor
而无需将其销毁.
GASShooter 为了实现复用做了以下修改。
- 实现休眠状态
增加 StopTargeting 方法,与 StartTargeting 形成一对控制 Actor 休眠状态的函数,相当于反初始化,为的是进入休眠状态,以备下次使用。 - 避免 Actor 被销毁
- 重写 CancelTargeting——避免取消操作时被销毁
- 构造函数中,bDestroyOnConfirmation = false——避免确认目标时被销毁
ConfirmationType
AbilityTask_WaitTargetData
通过 ConfirmationType
参数决定目标何时被确认。ConfirmationType
指明了目标选择确认和取消的时机
EGameplayTargetingConfirmation::Type | 何时确认 Target |
---|---|
Instant | 该定位无需特殊逻辑即可立即进行, 或者用户输入决定何时开始. |
UserConfirmed | 当 Ability 绑定到 Confirm 输入且用户确认或调用 UAbilitySystemComponent::TargetConfirm() 时触发该 Targeting. TargetActor 也会响应绑定的 Cancel 输入或者调用 UAbilitySystemComponent::TargetCancel() 来取消 Targeting. |
Custom | GameplayTargeting Ability 负责调用 UGameplayAbility::ConfirmTaskByInstanceName() 来决定何时准备好 Targeting Data. TargetActor 也可以响应 UGameplayAbility::CancelTaskByInstanceName() 来取消 Targeting. |
CustomMulti | GameplayTargeting Ability 负责调用 UGameplayAbility::ConfirmTaskByInstanceName() 来决定何时准备好 Targeting Data. TargetActor 也可以响应 UGameplayAbility::CancelTaskByInstanceName() 来取消 Targeting. 不应在数据生成后就结束 AbilityTask, 因为其允许多次确认. |
当使用 non-Instant Type
时, TargetActor
一般就在 Tick()
中执行射线/Overlap, 并根据它的实现来更新位置信息到 FHitResult
。尽管是在 Tick()
中执行的射线/Overlap, 但是一般不用担心, 因为它是不复制的并且一般没有多个 (尽管存在多个) TargetActor
同时运行, 只是要留意它使用的是 Tick()
,
一些复杂的
TargetActor
可能会在其中做很多事情, 就像 GASShooter 中火箭筒的二技能. 当Tick()
中的检测对客户端响应非常频繁时, 如果性能影响很大的话, 你可能就要考虑降低TargetActor
的 Tick 频率. 对于(Instant)Type
,TargetActor
会立即生成, 产生TargetData
, 然后销毁, 并且从不会调用Tick()
并不是所有的 TargetActor
都支持每个 ConfirmationType
, 例如, AGameplayAbilityTargetActor_GroundTrace
就不支持 (Instant)Type
AGameplayAbilityTargetActor
AGameplayAbilityTargetActor
类不能直接使用,需要实现子类,原因是基类没有实现选择目标逻辑。
GAS 内置了四个可用的 AGameplayAbilityTargetActor
子类:
根据使用的不同 AGameplayAbilityTargetActor
子类, WaitTargetData AbilityTask
节点会暴露不同的 ExposeOnSpawn
参数, 一些常用的参数包括:
常用TargetActor 参数 |
定义 |
---|---|
Debug | 如果为真, 每当非发行版本中的TargetActor 执行射线检测时, 其会绘制debug射线/Overlap信息. 请记住, non-Instant Type TargetActor 会在Tick() 中执行射线检测, 因此这些debug绘制调用也会在Tick() 中触发. |
Filter | [可选]当射线/Overlap触发时, 用于从Target中过滤(移除)Actor的特殊结构体. 典型的使用案例是过滤玩家的Pawn , 其要求Target是特殊类. 查看Target Data Filters以获得更多高级使用案例. |
Reticle Class | [可选]TargetActor 生成的AGameplayAbilityWorldReticle 子类. |
Reticle Parameters | [可选]配置你的Reticle. 查看Reticles. |
Start Location | 用于设置射线检测应该从何处开始的特殊结构体. 一般这应该是玩家的视口(Viewport), 枪口或者Pawn的位置. |
自定义 TargetActor
选择目标逻辑的触发有以下 2 个地方推荐
AGameplayAbilityTargetActor::ConfirmTargetingAndContinue
适合一次性选择,例如常见的点射无跟踪锁定能力的子弹,在 “扣扳机” 时,执行逻辑。Tick
适合持续更新选择,例如需要预先锁定的跟踪性武器,发射之前可以锁定 / 更改目标。
确认目标时,通过委托 AGameplayAbilityTargetActor::TargetDataReadyDelegate
将 TargetData
广播出去即可。
以下是 GASShooter 项目中的步枪子弹触发选择目标的逻辑。
1 | // 步枪子弹,AbilityTask会立即调用此函数 |
以下是 GASShooter 项目中的火箭发射器触发选择目标的逻辑。
1 | // 火箭弹,每帧检测目标 |
持久化 TargetActor
使用默认的 TargetActor
类时, Actor 只有直接在射线/Overlap 中时才是有效的, 如果它离开射线/Overlap(它移动开或你的视线转向别处), 就不再有效了.
如果你想 TargetActor
记住最后有效的 Target, 就需要添加这项功能到一个自定义的 TargetActor
类. 我称之为持久化 Target, 因为其会持续存在直到 TargetActor
接收到确认或取消消息, TargetActor
会在它的射线/Overlap 中找到一个新的有效 Target, 或者 Target 不再有效(已销毁).
GASShooter 对火箭筒二技能的制导火箭定位使用了持久化 Target. (TODO:使用持久化 Target 做 MMO 的目标选择)
TargetDataFilter
同时使用 Make GameplayTargetDataFilter
和 Make Filter Handle
节点, 你可以过滤玩家的 Pawn
或者只选择某个特定类. 如果需要更多高级过滤条件, 可以继承 FGameplayTargetDataFilter
并重写 FilterPassesForActor
函数.
1 | USTRUCT(BlueprintType) |
复制
TargetActor
默认是不可复制的. 然而, 如果在你的游戏中向其他玩家展示本地玩家正在定位的目标是有意义的, 那么它也可以被设计成可复制的, WaitTargetData AbilityTask
也确实包含其通过 RPC 和服务端通信的默认功能. 如果 TargetActor
的 ShouldProduceTargetDataOnServer
属性设置为 false, 那么客户端就会在确认时通过 UAbilityTask_WaitTargetData::OnTargetDataReadyCallback()
中的 CallServerSetReplicatedTargetData()
RPC 它的 TargetData
到服务端. 如果 ShouldProduceTargetDataOnServer
为 true, 那么客户端就会发送一个通用确认事件, EAbilityGenericReplicatedEvent::GenericConfirm
, 在 UAbilityTask_WaitTargetData::OnTargetDataReadyCallback()
中 RPC 到服务端, 服务端在接收到 RPC 时就会执行射线或者 Overlap 检测以生成数据. 如果客户端取消该定位, 它会发送一个通用取消事件, EAbilityGenericReplicatedEvent::GenericCancel
, 在 UAbilityTask_WaitTargetData::OnTargetDataCancelledCallback
中 RPC 到服务端. 你可以看到, 在 TargetActor
和 WaitTargetData AbilityTask
中存在大量委托, TargetActor
响应输入来产生广播 TargetData
的准备, 确认或者取消委托, WaitTargetData
监听 TargetActor
的 TargetData
的准备, 确认和取消委托, 并将该信息转发回 GameplayAbility
和服务端. 如果是向服务端发送 TargetData
, 为了防止作弊, 可能需要在服务端做校验以保证该 TargetData
是合理的. 直接在服务端上产生 TargetData
可以完全避免这个问题, 但是可能会导致所属 (Owning)客户端的错误预测.
Reticle
当使用已确认的 non-Instant Type
TargetActor 进行目标选择时, AGameplayAbilityWorldReticle
类可以可视化正在定位的目标. 其可在 AbiltyTask_WaitTargetData
的 Reticle Class
参数中配置。
类中只有一个 FaceTowardSource
函数有业务逻辑,其它方法主要是一些面向设计师的 BlueprintImplementableEvents
(它们被设计用来在蓝图中开发):
1 | class ... AGameplayAbilityWorldReticle : public AActor |
这个类的使用应该是轻逻辑,重表现,主要逻辑应该写在 TargetActor 上。可以参考 GASShooter 的 BP_SingleTargetReticle
。
TargetActor
负责所有 Reticle
生命周期的生成和销毁. Reticle
是 AActor
, 因此其可以使用任意种类的可视组件作为表现形式.
GASShooter 中常见的一种实现方式是使用 WidgetComponent
在屏幕空间中显示 UMG Widget(永远面对玩家的摄像机). Reticle
不知道其正在定位的 Actor, 但是你可以通过继承在自定义 TargetActor
中实现该功能. TargetActor
一般在每次 Tick()
中更新 Reticle
的位置为 Target 的位置.
GASShooter对火箭筒二技能制导火箭锁定的目标使用了Reticle
. 敌人身上的红色标识就是Reticle
, 相似的白色图像是火箭筒的准星.
自定义FWorldReticleParameters
Reticle
可以选择使用 TargetActor
提供的 FWorldReticleParameters
进行初始化, 默认结构体只提供一个变量 FVector AOEScale
, 尽管你可以在技术上继承该结构体, 但是 TargetActor
只接受基类结构体, 不允许在默认 TargetActor
中子类化该结构体。
然而, 如果你创建了自己的自定义 TargetActor
, 就可以提供自定义的 Reticle
参数结构体并在生成 Reticle
时手动传递它到 AGameplayAbilityWorldReticles
子类.
持久化 Reticle
Reticle
只会显示在默认 TargetActor
的当前有效 Target 上, 例如, 如果你正在使用 AGameplayAbilityTargetActor_SingleLineTrace
对目标执行射线检测, 敌人只有直接处于射线路径上时 Reticle
才会显示, 如果角色视线看向别处, 那么该敌人就不再是一个有效 Target, 因此该 Reticle
就会消失。
如果你想 Reticle
保留在最后一个有效 Target 上, 就需要自定义 TargetActor
来记住最后一个有效 Target 并使 Reticle
保留在其上. 我称之为持久化 Target, 因为其会持续存在直到 TargetActor
接收到确认或取消消息, TargetActor
会在它的射线/Overlap 中找到一个新的有效 Target, 或者 Target 不再有效(已销毁). GASShooter 对火箭筒二技能的制导火箭定位使用了持久化 Target。
复制
Reticle
默认是不可复制的, 但是如果在你的游戏中向其他玩家展示本地玩家正在定位的目标是有意义的, 那么它也可以被设计成可复制的.
Gameplay Effect Containers Targeting
GameplayEffectContainer 提供了一个可选的产生 TargetData 的高效方法. 当 EffectContainer
在客户端和服务端上应用时, 该定位会立即进行, 它比 TargetActor 更有效率, 因为它是运行在定位对象的 CDO(Class Default Object)上的(没有 Actor 的生成和销毁), 但是它不支持用户输入, 无需确认即可立即进行, 不能取消, 并且不能从客户端向服务端发送数据(在两者上产生数据), 它对即时射线检测和碰撞 Overlap 很有用.
这些 Target 类型定义于 URPGTargetType,你可以根据获取 Target 的方式进行拓展,调用 TargetType 获取目标的逻辑位于 MakeEffectContainerSpecFromContainer:
1 | if (Container.TargetType.Get()) |
Epic的Action RPG Sample Project包含两种使用Container定位的样例 —— 定位Ability拥有者和从事件拉取TargetData
, 它还在蓝图中实现了在距玩家某个偏移处(由蓝图子类设置)做球形射线检测(Sphere Trace), 你可以在C++或蓝图中继承URPGTargetType
以实现自己的定位类型.
11 预测 (Prediction)
GAS 带有开箱即用的客户端预测功能, 然而, 它不能预测所有。 GAS 中客户端预测的意思是客户端无需等待服务端的许可而激活 GA
和应用 GE
。
它可以”预测”许可其可以这样做的服务端和其应用 GE
的目标. 服务端在客户端激活之后运行 GA
(网络延迟)并告知客户端它的预测是否正确, 如果客户端的预测出错, 那么它就会”回滚”其”错误预测”的修改以匹配服务端.
Epic 的理念是只能预测”不受伤害 (get away with)”的事情. 例如, Paragon 和 Fortnite 不能预测伤害值, 它们很可能对伤害值使用了 ExecutionCalculations, 而这无论如何是不能预测的. 这并不是说你不能试着预测像伤害值这样的事情, 无论如何, 如果你这样做了并且效果很好, 那就是极好的.
… we are also not all in on a “predict everything: seamlessly and automatically” solution. We still feel player prediction is best kept to a minimum (meaning: predict the minimum amount of stuff you can get away with).
我们也不完全赞同 “无缝自动预测一切 “的解决方案。我们仍然认为玩家预测最好保持在最低水平(意思是:预测你能做到的最低数量)
来自 Epic 的 Dave Ratti 在新的网络预测插件中的注释.
GAS 相关预测的最佳源码是插件源码中的 GameplayPrediction.h
.
什么是可预测的:
- Ability 激活
- 触发事件 Triggered Event
- GameplayEffect 应用
- Attribute Modifiers (执行 (Execution)目前无法预测)
- GameplayTag Modification
- GameplayCue 事件 (从预测的 Gameplay Ability 和它们自己的事件)
- 蒙太奇
- 移动 (内建在 UE4 UCharacterMovement 中)
什么是不可预测的:
- GameplayEffect 移除
- (Periodic)GameplayEffect 周期效果
尽管我们可以预测 GameplayEffect
的应用, 但是不能预测 GameplayEffect
的移除. 绕过这条限制的一个方法是当想要移除 GameplayEffect
时, 可以预测性地执行相反的效果, 假设我们要降低 40%移动速度, 可以通过应用增加 40%移动速度的 buff 来预测性地将其移除, 之后同时移除这两个 GameplayEffect
. 这并不是对每个场景都适用, 因此仍然需要对预测性地移除 GameplayEffect
的支持. 来自 Epic 的 Dave Ratti 已经表达过在 GAS的迭代版本中增加它的期望.
因为我们不能预测 GameplayEffect
的移除, 所以就不能完全预测 GameplayAbility
的冷却时间, 并且也没有相反的 GameplayEffect
这种变通方案可供使用. 服务端复制的 Cooldown GE
将会存于客户端上, 并且任何对其绕过的尝试 (例如使用 Minimal
复制模式)都会被服务端拒绝. 这意味着高延迟的客户端会花费较长事件来告知服务端开始冷却和接收到服务端 Cooldown GE
的移除. 这意味着高延迟的玩家会比低延迟的玩家有更低的触发率, 从而劣势于低延迟玩家. Fortnite 通过使用自定义 Bookkeeping 而不是 Cooldown GE
的方法避免了该问题.
关于预测伤害值, 我个人不建议使用, 尽管它是大多数刚接触 GAS 的人最先做的事情之一, 我特别不建议尝试预测死亡, 虽然你可以预测伤害值, 但是这样做很棘手. 如果你错误预测地应用了伤害, 那么玩家将会看到敌人的生命值会跳动, 如果你尝试预测死亡, 那么这将会是特别尴尬和令人沮丧的, 假设你错误预测了某个 Character
的死亡, 那么它就会开启布娃娃模拟, 只有当服务端纠正后才会停止布娃娃模拟并继续向你射击.
Note: 修改 Attribute
的 (Instant)GE
(像 Cost GE
)在你自身可以无缝预测, 预测修改其他 Character 的 (Instant)Attribute
会显示短暂的异常或者 Attribute
中的暂时性问题. 预测的 (Instant)GE
实际上被视为 (Infinite)GE
, 因此如果错误预测的话还可以回滚. 当服务端的 GameplayEffect
被应用时, 其实是存在两个相同的 GameplayEffect
的, 会在短时间内造成 Modifier
应用两次或者不应用, 其最终会纠正自身, 但是有时候这个异常现象对玩家来说是显著的.
GAS 的预测实现尝试解决的问题:
- “我能这样做吗?” (Can I do this?) 预测的基本协议.
- “撤销” (Undo) 当预测错误时如何消除其副作用.
- “重现” (Redo) 如何避免已经在客户端预测但还是从服务端复制的重播副作用.
- “完整” (Completeness) 如何确认我们真的预测了所有副作用.
- “依赖” (Dependencies) 如何管理依赖预测和预测事件链.
- “重写” (Override) 如何预测地重写由服务端复制/拥有的状态.
源自 GameplayPrediction.h
Prediction Key
GAS 的预测建立在 Prediction Key
的概念上, 其是一个由客户端激活 GameplayAbility
时生成的整型标识符.
PredictionKey 基于 FPredictionKey
,只是一个唯一的 ID 存储在客户端。
- 客户端执行预测时,它将向上发送 PredictionKey 到服务器并关联其客户端预测行为(Action)和该 Key 的副效果(Side Effect)。
- 服务器将收到该 Key,并接收或拒绝它。
- 然后,它将把服务器端创建的任何 Side Effect 与该 Key 相关联,并响应客户端通知它该 Key 已被接受或拒绝。
- 当服务器返回 PredictionKey 时,它将只返回到最初将其发送到服务器的客户端。其他客户端简单的接收一个无效 Key 或 ID 为 0 。这个过程在
FPredictionKey:: NetSerialize()
中完成
[!NOTE] SideEffect
就是 GameplayEffect,只在有有效的 PredictionKey 的情况下才能应用于客户端。
如果 GameplayEffect 被预测,那么其所作的以下任何事情也会被预测:
- Attribute Modifications
- Gameplay Tag Modifications
- GameplayCue
当一个 FActiveGameplayEffect 被创建,他将这个 PredictionKey 存储在服务器,自身也获得相同的 Key。复制到客户端时,客户端可以检查 Key。如果客户端上已经有一个具有相同 key 的激活的 GameplayEffect ,说明本地已经完成了,不需要再次应用 effect(OnApplied 逻辑不再执行),避免重复的应用 effect。
- 客户端激活
GameplayAbility
时生成Prediction Key
, 这是Activation Prediction Key
. - 客户端使用
CallServerTryActivateAbility()
将该Prediction Key
发送到服务端. - 客户端在
Prediction Key
有效时将其添加到应用的所有GameplayEffect
. - 客户端的
Prediction Key
出域. 之后该GameplayAbility
中的预测效果 (Effect)需要一个新的 Scoped Prediction Window. - 服务端从客户端接收
Prediction Key
. - 服务端将
Prediction Key
添加到其应用的所有GameplayEffect
. - 服务端复制该
Prediction Key
回客户端. - 客户端使用
Prediction Key
从服务端接收复制的GameplayEffect
, 该Prediction Key
用于应用GameplayEffect
. 如果任何复制的GameplayEffect
与客户端使用相同Prediction Key
应用的GameplayEffect
相匹配, 那么其就是正确预测的. 目标上暂时会有GameplayEffect
的两份拷贝直到客户端移除它预测的那一个. - 客户端从服务端上接收回
Prediction Key
, 这是复制的Prediction Key
, 该Prediction Key
现在被标记为陈旧 (Stale). - 客户端移除所有由复制的陈旧 (Stale)
Prediction Key
创建的GameplayEffect
. 由服务端复制的GameplayEffect
会持续存在. 任何客户端添加的和没有从服务端接收到一个匹配的复制版本的都被视为错误预测.
在源于 Activation Prediction Key
激活的 GameplayAbility
中的一个 instruction "window"
原子 (Atomic)组期间, Prediction Key
是保证有效的, 你可以理解为 Prediction Key
只在一帧期间是有效的. 任何潜在行为 AbilityTask
的回调函数将不再拥有一个有效的 Prediction Key
, 除非该 AbilityTask
有内建的可以生成新 Scoped Prediction Window 的复制点(Sync Point).
在 Ability 中创建新的预测窗口 (Prediction Window)
存在一个可以发生预测行为的窗口,称为**预测窗口 (Prediction Window)**,每当窗口打开,客户端可以执行可以改变游戏状态的重要行为(例如使用属性资源)而无需请求服务器的许可。
为了在 AbilityTask
的回调函数中预测更多的行为, 我们需要使用新的 Scoped Prediction Key
创建 Scoped Prediction Window
, 有时这被视为客户端和服务端间的复制点 (Sync Point). 一些 AbilityTask
, 像所有输入相关的 AbilityTask
, 带有创建新 Scoped Prediction Window
的内建功能, 意味着 AbilityTask
回调函数中的原子 (Atomic)代码有一个有效的 Scoped Prediction Key
可供使用. 像 WaitDelay
的其他 Task 没有创建新 Scoped Prediction Window
以用于回调函数的内建代码, 如果你需要在 WaitDelay
这样的 AbilityTask
后预测行为, 就必须使用 OnlyServerWait
选项的 WaitNetSync AbilityTask
手动来做, 当客户端触发 OnlyServerWait
选项的 WaitNetSync
时, 它会生成一个新的基于 GameplayAbility
的 Activation Prediction Key
的 Scoped Prediction Key
, RPC 其到服务端, 并将其添加到所有新应用的 GameplayEffect
. 当服务端触发 OnlyServerWait
选项的 WaitNetSync
时, 它会在继续前等待直到接收到客户端新的 Scoped Prediction Key
, 该 Scoped Prediction Key
会执行和 Activation Prediction Key
同样的操作 —— 应用到 GameplayEffect
并复制回客户端标记为陈旧 (Stale). Scoped Prediction Key
直到出域前都有效, 也就表示 Scoped Prediction Window
已经关闭了. 所以只有原子 (Atomic)操作, nothing latent, 可以使用新的 Scoped Prediction Key
.
你可以根据需求创建任意数量的 Scoped Prediction Window
.
如果你想添加复制点 (Sync Point)功能到自己的自定义 AbilityTask
, 请查看那些输入 AbilityTask
是如何从根本上注入 WaitNetSync AbilityTask
代码到自身的.
Note: 当使用 WaitNetSync
时, 会阻塞服务端 GameplayAbility
继续执行直到其接收到客户端的消息. 这可能会被破解游戏的恶意用户滥用以故意延迟发送新的 Scoped Prediction Key
, 尽管 Epic 很少使用 WaitNetSync
, 但如果你对此担忧的话, 其建议创建一个带有延迟的新 AbilityTask
, 它会自动继续运行而无需等待客户端消息.
样例项目在奔跑 GameplayAbility
中使用了 WaitNetSync
以在每次应用耐力花费时创建新的 Scoped Prediction Window
, 这样我们就可以进行预测. 理想上当应用花费和冷却时间时我们就想要一个有效的 Prediction Key
.
如果你有一个在所属 (Owning)客户端执行两次的预测 GameplayEffect
, 那么你的 Prediction Key
就是陈旧 (Stall)的, 并且正在经历”redo”问题. 你通常可以在应用 GameplayEffect
之前将 OnlyServerWait
选项的 WaitNetSync AbilityTask
放到正确的位置以创建新的 Scoped Prediction Key
来解决这个问题.
预测性地生成 Actor
在客户端预测性地生成 Actor 是一项高级技术. GAS 对此没有提供开箱即用的功能 (SpawnActor AbilityTask
只在服务端生成 Actor). 其关键是在客户端和服务端都生成复制的 Actor.
如果 Actor 只是用于场景装饰或者不服务于任何游戏逻辑, 简单的解决方案就是重写 Actor 的 IsNetRelevantFor()
函数以限制服务端复制到所属 (Owning)客户端, 所属 (Owning)客户端会拥有其本地生成的版本, 而服务端和其他客户端会拥有服务端复制的版本.
1 | bool APAReplicatedActorExceptOwner::IsNetRelevantFor(const AActor * RealViewer, const AActor * ViewTarget, const FVector & SrcLocation) const |
如果生成的 Actor 影响了游戏逻辑, 像投掷物就需要预测伤害值, 那么你需要本文档范围之外的高级知识, 在 Epic Games 的 Github 上查看 UnrealTournament 是如何生成投掷物的, 它有一个只在所属 (Owning)客户端生成且与服务端复制的投掷物.
GAS 中预测的未来
GameplayPrediction.h
说明了在未来可能会增加预测 GameplayEffect
移除和周期 GameplayEffect
的功能.
来自 Epic 的 Dave Ratti 已经表达过对其修复的兴趣, 包括预测冷却时间时的延迟问题和高延迟玩家对低延迟玩家的劣势.
来自 Epic 之手的新网络预测插件(Network Prediction plugin) 期望能与 GAS 充分交互, 就像在次之前的 CharacterMovementComponent
.
网络预测插件 (Network Prediction plugin)
Epic 最近发起了一项倡议, 将使用新的网络预测插件替换 CharacterMovementComponent
, 该插件仍处于起步阶段, 但是在 Unreal Engine Github 上已经可以访问了, 现在说未来哪个引擎版本将首次搭载其试验版还为时尚早.