第一节 初识GAS
GAS介绍
什么是Gameplay Ability System
虚幻引擎(Unreal Engine)中的Gameplay Ability System(GAS)是一个功能强大的技能系统框架,它主要用于管理游戏中的角色技能、属性、效果和交互。GAS系统通过一系列的类和接口,将角色的各种能力抽象为可重用的组件,从而简化了游戏逻辑的编写和维护。
Ability System Component
这是一种可以添加到角色的组件,它会处理许多重要的游戏系统中的事物,如技能学习,能力激活,激活能力时的通知处理。
Attribute Set
和Ability System Component一样重要的是Attribute Set。也就是属性集。几乎所有游戏都涉及角色属性,从生命、蓝量到其他属性。
Gameplay Ability
这是GAS的核心部件,Gameplay Ability是我们用来封装各种功能的类。比如攻击和施放法术。
Ability Task
Ability Task允许我们执行异步代码。意味着一旦我们开始一个任务,它可能会执行其工作并立即完成,也可能会跨越一段时间。根据游戏中发生的特定情况,也可能会执行不同的操作。
Gameplay Effect
这是我们用来改变属性值的。它能够执行与属性相关的多种不同功能。直接更改属性。定期增加或减少属性。计算属性变化等。
Gameplay Cue
游戏提示部件。这个部件负责处理诸如粒子系统和声音等。
Gameplay Tag
这是GAS的另一个核心部件。现在Gameplay Tag实际上并不局限于GAS中。它还能用于非游戏项目的开发。Gameplay Tag的使用无处不在,贯穿整个游戏。对于识别几乎任何你能想到的事物都很有用。它的层次结构比枚举、布尔值或字符串等简单变量更加灵活。
添加GAS
现在我们添加GAS的方式可能会有所不同。你可以直接在Pawn上添加技能组件,也可以对属性集执行相同的操作。
现在另一个选择是,选择与你的Pawn相关的类。比如玩家状态。
并将技能组件和属性集添加到该类中。这样做会灵活些。
假设我们使用第一种方法。
再假设这是一个多人游戏。当Pawn被销毁时,其附着的组件系统也跟着销毁。那些系统中的数据也随之消失。当你想重新生成这个Pawn时,其系统中的数据会从默认值开始重新创建。如果我们想保存这些数据,显然是十分麻烦的。
现在假设我们用第二种方法。
用一个Player State去绑定Pawn与两个系统。当我们的Pawn死亡被销毁时,两个系统不会随其一起被销毁,因为它们不存在于Pawn类上。它们存在于Player State上,当角色销毁时还会保留下来。
所以我们可以生成一个新的Pawn,并将其与我们的Player State关联起来,这样我们仍然拥有“相同”的Pawn。
第二种方法还有个好处,就是不让两个系统“污染”角色。
你可以将你的角色替换另一个角色,也可以不替换角色的这两个两个系统。
还有就是一些“杂鱼”并不需要Player State。
在这个游戏项目中,我们二种方法都将被采用。我们的敌人角色,他们将在角色类上直接拥有他们的能力系统组件 和属性集。
但对于我们操控的角色,我们将这两个系统设置在玩家状态上。
这给了我们可以看到两种方法的机会。
因此,要向我们的项目添加GAS,我们需要采取一些步骤。
- 创建一个Player State类
- 创建一个Ability System Component类,可以添加到Player State。
- 创建一个Attribute Set类,可以添加到Player State。
GAS准备阶段
在C++类的Player文件夹中创建PlayerState
名为AuraPlaerState
//创建构造函数,设置服务器更新客户端的频率
//AuraPlaerState.cpp
AAuraPlayerState::AAuraPlayerState()
{
//服务器更新客户端的频率
NetUpdateFrequency = 100.f;
}
在蓝图中创建BP_AuraPlayerState
在BP_AuraGameMode中将玩家状态类设置为BP_AuraPlaerState
创建一个继承自AbilitySystemComponent
的C++类 命名为AuraAbilitySystem 放在public/AbilitySystem文件夹下
创建一个继承自AttributeSet
的C++类 命名为AuraAttributeSet 放在public/AbilitySystem文件夹下
最后在Aura.build.cs
中添加依赖
//Aura.build.cs
//...
PublicDependencyModuleNames.AddRange(
new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput" });
PrivateDependencyModuleNames.AddRange(
new string[] { "GameplayAbilities","GameplayTags","GameplayTasks" });
GAS在多人游戏中的应用
到目前为止,我们已经提到了几次关于多人游戏的术语。所以是时候,讨论下多人游戏与GAS的关系了。
现在在多人游戏中存在一个服务器,在大多数情况下只会有一个服务器。这个服务器是游戏的一个实例,独立运行并独立于其他实例。那些也在运行游戏自己版本的机器,我们称之为客户端。
现在服务器和客户端是不同的,我们将讨论这些差异。
现在在多人游戏中,服务器可以有多种不同的类型,例如有专用服务器。专用服务器没有人类玩家,也不会进行屏幕渲染。这只是一台运行游戏模拟的计算机。
除了专用服务器之外,还有另一种称为监听的服务器模型。监听服务器是一个人类玩家或者至少是一台计算机运行着游戏的版本。我们说那个人类玩家正在主持着游戏。
现在主机在监听服务器模型中有优势,这个优势是主机没有延迟。在监听服务器中,主机就是服务器,服务器不需要发送数据给自身。
在虚幻引擎中,我们将服务器视为游戏的版本权威。每台机器上都在发生事件。玩家们奔跑改变了他们的位置。但由于延迟,你机器上的角色位置将会与你角色在另一玩家的机器上和服务器上的位置不同。
所以我们必须决定哪台机器的版本是正确的。
我们认为服务器是游戏的正确版本。因此,我们要把重要的事情做在服务器上。服务器始终要被视为游戏的权威版本。
现在,如果我们在服务器与客户端之间画一条虚线,我们可以做一些关于某些游戏类别的区别。游戏模式公存在于服务器上。如果你尝试在其中一个客户端上访问游戏模式,你将收到一个空指针。
这是因为游戏模式负责游戏规则等事项。生成玩家和重新开始游戏,诸如此类。只能在服务器上处理。现在每个玩家的玩家控制器存在于服务器上,但它们也存在于每个客户端。因此,服务器具有每个玩家控制器的权威服务器版本,以及每个客户端都有自己的本地版本。
但请注意,客户端0只在其机器上有玩家控制器0。没有玩家控制器1或玩家控制器2。所有其他玩家、玩家控制器在客户端0上不存在。只有服务器拥有游戏中的所有玩家控制器,并可以访问他们。
现在玩家状态是另一回事。每个玩家的状态都存在于服务器上,但它们也都存在于每个客户端上。
客户端0具有玩家状态0以及其它玩家状态。这是与玩家控制器的区别。Pawn也类似。
如果你在玩游戏,你除了能够看到你控制的Pawn在四处奔跑,也能看到其它人控制的Pawn在四处奔跑。你的机器中游戏中有其它人的Pawn的副本是合理的。
因此,游戏中的所有Pawn都存在于所有机器上,与玩家状态一样。
现在HUD和所有显示在屏幕上的相关小部件对比其它类有点不太相同。
每个客户端都有自己的HUD类,它只存在于该客户端上。所以,如果我们在谈论专用服务器,服务器上没有HUD。如果是一个监听服务器,那么唯一存在的HUD就是本地玩家的HUD。
当数据从服务器传输到客户端时,我们称之为复制。假设在Pawn类上有一个变量,它可以是浮点数、整数或其他类型。现在它存在于Pawn类中,意味着每个Pawn的版本都有这个客户端上的Pawn版本,服务器上的Pawn版本。他们都有这个变量。现在,如果将变量指定为复制变量,那么该变量在服务器上发生变化时,在下一次网络更新时,该变化将被发送下来。
为了让所有客户端都能更新该变量。
复制只能从服务器到客户端单向进行。如果一个变量被标记为复制,那就不应该在客户端上更改。
服务器将不会知道这个更改了。其他客户端也不会知道。只有更改了该变量的客户端才会知道。该客户端上的变量现在将被视为不正确。
如果复制只能单向进行,那客户端怎样将数据传输到服务器?
答案是RPCs,或远程过程调用。
事实上,这种关于对复制的高层概述就是你所需要了解多人游戏项目的全部。
但在这个项目里,大部分情况下,大多数事情都被GAS做了。
我们现在将继续添加我们的能力系统组件和属性集到我们的玩家状态。对于我们操控的角色,以及我们的敌人,我们将学习如何关联这两个系统。
为角色设置GAS
在角色基类中设置GAS
//AuraCharacterBase.h
//...
//添加前向声明
class UAttributeSet;
class UAbilitySystemComponent;
//...
protected:
//...
UPROPERTY()
TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;
UPROPERTY()
TObjectPtr<UAttributeSet> AttributeSet;
此时,所有继承自AuraCharacterBase的角色类,都会拥有AbilitySystemComponent
,AttributeSet
这两个GAS的属性
在敌人类中设置GAS
//AuraEnemy.cpp
AAuraEnemy::AAuraEnemy()
{
//...
//初始化敌人类的AbilitySystemComponent
AbilitySystemComponent = CreateDefaultSubobject<UAuraAbilitySystemComponent>("AbilitySystemComponent");
//开启服务端复制,将该组件的状态和行为在网络环境下从服务器同步到客户端
AbilitySystemComponent->SetIsReplicated(true);
//初始化敌人类的AttributeSet
AttributeSet = CreateDefaultSubobject<UAuraAttributeSet>("AttributeSet");
}
//...
AbilitySystemComponent = CreateDefaultSubobject<UAuraAbilitySystemComponent>("AbilitySystemComponent");
:- 这里通过
CreateDefaultSubobject
函数为AAuraEnemy
类创建并初始化了一个UAuraAbilitySystemComponent
组件。-
CreateDefaultSubobject
是 Unreal Engine 中用于在构造函数内创建并初始化子对象(组件)的一个模板函数。它常用于创建和附加组件(比如碰撞体、网格、相机等)到一个AActor
或UObject
派生类对象上。 -
用法:
-
CreateDefaultSubobject
在 构造函数 中被调用,用于初始化对象(如UActorComponent
或USceneComponent
)的默认****子对象,使这些组件在对象创建时就被附加到该对象上,并且能够参与引擎的管理(如复制、垃圾回收、渲染等)。
-
UAuraAbilitySystemComponent
是基于 Gameplay Ability System(GAS)的一个自定义组件,用于处理角色的各种能力(Abilities)。"AbilitySystemComponent"
是组件的名字,可以用来在引擎中进行识别和管理
- 这里通过
AbilitySystemComponent->SetIsReplicated(true);
:- 这一行代码开启了
AbilitySystemComponent
的网络复制功能。启用后,服务器上发生的状态变化(比如技能激活、属性变化等)会自动同步到客户端。 - 在多人游戏场景下,这是必不可少的,确保所有客户端看到的游戏状态是一致的,从而避免不同步的情况。
- 这一行代码开启了
AttributeSet = CreateDefaultSubobject<UAuraAttributeSet>("AttributeSet");
:- 这一行代码初始化了
UAuraAttributeSet
,这是另一个自定义的组件,通常用于存储和管理角色的各种属性(Attributes),比如生命值、魔法值、攻击力等。 - 这些属性与
AbilitySystemComponent
一起工作,提供了一个完整的系统来处理角色的各种状态和能力。 "AttributeSet"
是这个组件的名字,类似于之前的AbilitySystemComponent
,方便引擎识别和管理。
- 这一行代码初始化了
在主角的PlayerState中设置GAS
//AuraPlaerState.h
//...
class AURA_API AAuraPlayerState : public APlayerState,public IAbilitySystemInterface
{
GENERATED_BODY()
public:
AAuraPlayerState();
//获取AbilitySystem,继承IAbilitySystemInterface接口,重写函数
virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override;
//获取AttributeSet
UAttributeSet* GetAttributeSet() const {return AttributeSet;}
protected:
//GAS系统
UPROPERTY()
TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;
UPROPERTY()
TObjectPtr<UAttributeSet> AttributeSet;
};
//AuraPlaerState.cpp
AAuraPlayerState::AAuraPlayerState()
{
//服务器更新客户端的频率
NetUpdateFrequency = 100.f;
//初始化敌人类的AbilitySystemComponent
AbilitySystemComponent = CreateDefaultSubobject<UAuraAbilitySystemComponent>("AbilitySystemComponent");
//开启服务端复制,将该组件的状态和行为在网络环境下从服务器同步到客户端
AbilitySystemComponent->SetIsReplicated(true);
//初始化敌人类的AttributeSet
AttributeSet = CreateDefaultSubobject<UAuraAttributeSet>("AttributeSet");
}
UAbilitySystemComponent* AAuraPlayerState::GetAbilitySystemComponent() const
{
return AbilitySystemComponent;
}
此时,AuraCharacter
类中的AbilitySystemComponent
继承自AuraCharacterBase
但是在AuraCharacter
中没有初始化AbilitySystemComponent
,为nullptr
玩家的playerState
和character
里面存储的AbilitySystemComponent
和AttributeSet
需要相同,我们可以在初始化玩家Character的时候,从PlayerState里获取ASC和AS
接下来要设置复制模式,以便来同步客户端和服务器端数据。
GAS的复制模式
复制模式 | 使用场景 | 具体内容 |
---|---|---|
Full | 单人游戏/与服务器强关联的多人游戏(MOBA) | GamePlayEffect 会被复制到所有客户端 |
Mixed | 玩家控制的Actors | GamePlayEffect 仅被复制到拥有者的客户端,GameplayTags 和 GameplayCues 会被复制到所有客户端 |
Minimal | AIActors | GamePlayEffect 不会复制到任何客户端GameplayTags 和 GameplayCues 会被复制到所有客户端 |
//AuraEnemy.cpp
AAuraEnemy::AAuraEnemy()
{
//...
//开启服务端复制,将该组件的状态和行为在网络环境下从服务器同步到客户端
AbilitySystemComponent->SetIsReplicated(true);
//服务端复制的方式-Minimal(适合AI)
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Minimal);
//...
}
//AuraPlayerState.cpp
AAuraPlayerState::AAuraPlayerState()
{
//...
//开启服务端复制,将该组件的状态和行为在网络环境下从服务器同步到客户端
AbilitySystemComponent->SetIsReplicated(true);
//服务端复制的方式-Mixed(适合主角)
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);
}
初始化GAS的OwnerActor
和AvatarActor
现在,玩家角色AuraCharater
里ASC和AS的指针还是空的。对于玩家角色,两个系统的有效指针在PlayerState里。这里我们就要想办法他们关联上。PlayerState
要知道谁实际构建了这两个系统。
现在能力系统组件具有能力角色信息的概念。这样能力系统组件始终可以知道角色信息,比如谁拥有这个能力系统组件。但是能力系统组件也理解它可能被某些人拥有的概念角色。可能由某个Pawn
拥有,也可能由另一种实体拥有,比如玩家状态。因此,能力系统组件有两个变量,一个是所有者角色OwnerActior
,另一个是化身角色AvatarActor
。
OwnerActor
实际上是拥有能力系统组件的类。但AvatarActor
是与这个能力系统组件相关联的世界中的代表。现在对于我们的敌人角色来说,这两者是相同的,因为我们的敌人角色类是构建我们能力系统组件的类。
所以OwnerActor
是EnemyCharacter
,AvatarActor
也是EnemyCharacter
。
这就是我们在世界上看到的,这是视觉表现。
现在对于玩家控制角色来说,情况有些不同,因为我们的能力系统组件由玩家状态构建。
这就是我们希望将其视为能力系统组件拥有者的类。因此,在这种情况下,拥有者角色应该是玩家状态和Avatar Actor
,那个角色是我们在现实世界中看到的,那才应该是真正的拥有者。
因此,在这种情况下,OwnerActor
和AvatarActor
是两个不同的方法的区分。重要的是要明白,拥有者角色和AvatarActor
可能是也可能不是同一个人。
现在我们是决定这些事件的人。我们将调用函数来设置拥有者角色和Avatar角色。这是在能力系统组件上的一个函数,称为InitAbilityActorInfo
。
UAbilitySystemComponent::InitAbilityActorInfo(AActor* InOwnerActor, AActor* InAvatarActor);
因此,我们需要自己使用这个来初始化两种角色。
现在我们要在哪里和何时调用这个函数?
这要看不同的情况。
我们需要知道的第一件事是,调用这个函数必须在Pawn
设置完控制器之后。对于一个由玩家控制的角色,其中包含能力系统组件必须存在于Pawn
中。
为什么调用InitAbilityActorInfo必须在Pawn
设置完控制器之后
InitAbilityActorInfo
是 GAS 中用来初始化和配置 Ability System Component (ASC) 的重要函数。它的主要功能是:
- 将
Avatar Actor
(通常是玩家控制的Pawn
)与能力系统绑定,建立角色和能力系统的联系。 - 为能力系统设置与控制器、
PlayerState
等对象的关联,便于处理与玩家相关的输入和网络同步。
具体来说,InitAbilityActorInfo
的两个参数:
- OwnerActor: 通常是
PlayerState
或PlayerController
,用于表示游戏中具有能力的所有者。 - AvatarActor: 通常是
Pawn
,即玩家控制的实际角色实体,负责实际执行能力。
- Pawn 和 Controller 的关系
- Pawn 是游戏中的可控角色,而 Controller(如
PlayerController
或AIController
)是负责接收输入并控制 Pawn 的对象。 - 当 Pawn 被玩家控制时,控制器会负责向 Pawn 发送输入信息(如移动、攻击等),并通过控制器和 PlayerState 维护与角色的绑定关系。
- Pawn 是游戏中的可控角色,而 Controller(如
- Controller 的重要性
- Controller(通常是 PlayerController)是角色输入的源头,负责接收玩家的指令并与 Ability System 交互。初始化 Ability System 需要确保 Pawn 已经和对应的 Controller 建立联系。
- 如果
InitAbilityActorInfo
在 Pawn 没有被控制器控制之前调用,能力系统将无法正确初始化,因为它找不到与角色相关的控制信息,特别是输入的来源。 - PlayerState 的关联
- 对于多人游戏中的玩家,
PlayerState
中保存了玩家的重要信息,包括他们的分数、队伍以及在跨关卡中的持久化状态。 - PlayerState 通常通过 Controller 进行管理。因此,在调用
InitAbilityActorInfo
时,确保 Pawn 已经和 PlayerState 关联是至关重要的,否则能力系统无法访问玩家的状态信息。
- 对于多人游戏中的玩家,
在Pawn
的PossessedBy
函数中调用InitAbilityActorInfo
是个好方法。
PossessedBy
函数
在Pawn
被控制器控制时会回调这个函数**(仅在服务器上调用)**
这确保了能力系统组件与拥有者角色一起初始化。但这方法只适用于服务器。
在客户端上,我们必须使用不同的方法。AcknowledgePossession
可以用来实现这点。
在这个函数中,我们已经知道谁是OwnerActor
了。
所以在这种情况下,如果我们将我们的能力系统组件放在角色身上,这两个函数就是我们需要调用InitAbilityActorInfo
的地方。
现在,对于由玩家控制的角色,能力系统组件存在于玩家状态上这样的案例。我们在服务端会使用PossessedBy
。但在客户端,我们不会使用AcknowledgePossession
。而会使用OnRep_PlayerState
。
因为我们的ASC在玩家状态上。因此我们不仅需要确保我拉的控制器已设置,还需要确保此时玩家状态是有效的。
在OnRep_PlayerState
上有一个叫做RepNotify
的函数。RepNotify
是作为某些内容被复制后调用的函数。在这种情况下,玩家状态将在服务器上设置,并且玩家状态是复制的实体。这意味着一旦玩家状态已被Pawn设置,那将会复制下来,触发这个复制通知,这将作为对复制的响应而被调用。这时,我们知道我们的玩家状态已经在服务器上设置好了。在客户端那里,已经复制了一个有效的指针。所以这就是我们将要为我们的能力系统组件InitAbilityActorInfo
。在这种情况下,我们将设置我们的拥有者角色对应于玩家状态,我们的AvatarActor
角色对应于角色本身。
这就是我们将为玩家控制的角色所需要做的事情。
现在对于由AI控制的角色来说,情况就简单多了。对于我们的由AI控制的角色,我们将在Pawn
上拥有我们的能力系统组件。我们只需要在BeginPlay
里调用InitAbilityActorInfo
。
总的来说,什么时候调用,在哪里调用InitAbilityActorInfo
,看下图就知道了。
//AuraEnemy.cpp
//....
void AAuraEnemy::BeginPlay()
{
Super::BeginPlay();
//初始化AuraEnemy的ActorInfo
AbilitySystemComponent->InitAbilityActorInfo(this,this);
}
AuraEnemy
中,只需要在游戏开始时,设置OwnerActor
和 AvatarActor
为自身就可以了
//AuraCharacter.h
//...
public:
AAuraCharacter();
//当一个 Pawn 被一个新的 Controller 接管时的回调函数
virtual void PossessedBy(AController* NewController) override;
//当 PlayerState 在客户端上通过网络复制发生变化时,OnRep_PlayerState 函数会被自动调用,它主要用于处理 PlayerState 更新的回调
// PlayerState 初始化时也会调用该函数
virtual void OnRep_PlayerState() override;
private:
void InitAbilityActorInfo();
//AuraCharacter.cpp
//...
void AAuraCharacter::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
//为服务器初始化 AbilityActorInfo
InitAbilityActorInfo();
}
void AAuraCharacter::OnRep_PlayerState()
{
Super::OnRep_PlayerState();
//为客户端初始化 AbilityActorInfo
InitAbilityActorInfo();
}
void AAuraCharacter::InitAbilityActorInfo()
{
AAuraPlayerState* AuraPlayerState = GetPlayerState<AAuraPlayerState>();
//初始化PlayerState中ASC的信息
AuraPlayerState->GetAbilitySystemComponent()->InitAbilityActorInfo(AuraPlayerState,this);
//将PlayerState中的ASC和AS赋给Character,因为在Character构造函数中没有为ASC和AS赋值
AbilitySystemComponent = AuraPlayerState->GetAbilitySystemComponent();
AttributeSet = AuraPlayerState->GetAttributeSet();
}
在 AuraCharacter
中,需要分别在PossessedBy
和 OnRep_PlayerState
中为服务器端和客户端设置AbilityActorInfo
对于混合复制模式:OwnerActor 的
Owner
必须是 Controller,对于**Pawns的子类,**这会在 PossessedBy() 中自动设置。PlayerState的子类 的Owner
会自动设置为 Controller。因此,如果你的 OwnerActor 不继承于 **PlayerState或Pawns ,**并且你使用了混合复制模式,你必须在 OwnerActor 上调用 SetOwner(),将其拥有者设置为 Controller。
第二节 Attributes
属性详解
当我们在能力系统组件(ASC)内构建属性集时,拥有者角色的构造函数,会自动注册到能力系统组件。能力系统组件可以访问它和任何其它已注册的属性集。如果需要的话,我们可以拥有多个属性集。
假设你想要在几个不同的属性集之间在类别上分配你的属性。
但重要的是要知道每一个都必须是分开的类。你不能拥有同一类型的多个属性集。否则,尝试从能力系统组件中检索它会导致歧义。
另一方面,将所有相同属性的属性集包含在其中是完全可以接受的。
这可以让事情变得更简单,特别是如果属性集在进行计算需要知道其他属性的值的相关信息。属性占用的内存极小,因此可以共享一个属性集。
在这个游戏项目中,我们的属性集将包含所有要使用的属性。所以我们只会有一个属性集。
那么属性究竟是什么?
属性是与游戏中特定实体相关联的数值数量。所有属性都是浮点数。它们存在于一个名为FameplayAttributeData的结构体内。
我们可以知道属性何时发生变化,并以我们喜欢的任何功能做出响应。现在,属性值可以直接在代码中设置,但更推荐的方式是通过Gameplay Effect改变它们。
Gameplay Effect可以以多种不同的方式更改属性值。除了Gameplay Effect的内置功能之外,使用它们的另一个原因是Gameplay Effect可以让我们预测属性的变化。
预测是客户端无需等待服务器的许可去改变一个数值。数值可以立即在客户端更改,服务器会被通知这个变化。服务器可以撤销任何被视为无效的更改。
预测会让多人游戏体验更顺畅。
这是客户端变更在没有预测的情况下会发生的情形。
在客户端上,假设你有一个需要更改的属性。服务器应负责重要的游戏玩法更改,否则客户端就可以作弊。向服务器发送请求,告知属性的值需要更改。
所以服务器收到请求,根据开发人员设定的任意数量的标准来决定该请求是否有效。
如果更改被视为有效,服务器将把确认发送回去。
客户端现在可以更改属性的值。由于数据需要时间传输到达网络,这导致客户端需要更改值的时间延迟会明显增加。直到它收到服务器的许可才能实际更改。延迟时间达到100毫秒或更长并不罕见。在游戏中执行一个动作,然后在能看到和听到之前有明显的延迟。这会导致非常糟糕的游戏体验。
这是客户端变更在有预测的情况下会发生的情形。在GAS中进行预测,Gameplay Effect修改一个属性,客户端会立即感知到这种变化。没有时间延迟。
然而这种变化仍然会发送到服务器上。
如果服务器决定这是一个有效的更改,它会通知其它客户端这个更改。然而,如果服务器认为该更改无效,比如客户端开挂,服务器可以拒绝更改。将更改回滚,将客户端的值设置回正确的值。
因此服务器仍然具有权威性,但我们的客户端不必延迟。预测是复杂的,GAS将其作为内置功能是一个巨大的好处。这让我们可以专注于创建游戏机制,而不必担心实现延迟补偿。
因此,属性是FGameplayAttributeData的对象,并存储在属性集上。
属性实际上由两个值组成,一个基础值和一个当前值。
基础值是属性的永久值。当前值是基础值加上由游戏效果引起的任何临时修改的值。
考虑这一点时要考虑增益效果和减益效果。
一旦时间到期,修改将被撤销,属性将恢复到基本值。现在,你可能会忍不住得出结论,基准值是该属性的最大值。例如,计算生命值百分比时,你会将当前数值除以基础值?
这是错误的。最大值应该它应该自己单独作一个属性。
对于每一个属性Attribute,其都有基准值属性BaseValue
当前属性 CurrentValue
。但是因为属性的最大值可以被某个增益/减益所修改,比如穿上了某一个装备,最大生命值+100,比如有一个被动技能,最大生命值减少100,移速增加10%
所以,对于许多想要添加到角色上的属性,都需要有2个Attribute,比如Health和MaxHealth;初始时,Health的BaseValue和CurrentValue都是100,MaxHealth的BaseValue和CurrentValue为100;
当受击时,Health的CurrentValue减少;当穿上装备时,MaxHealth的CurrentValue增加
当回血时,Health的CurrentValue增加,但是不能大于MaxHealth的CurrentValue
为角色添加属性
//AuraAttributeSet.h
UCLASS()
class AURA_API UAuraAttributeSet : public UAttributeSet
{
GENERATED_BODY()
public:
UAuraAttributeSet();
//指定哪些属性需要在服务器和客户端之间同步,并定义每个属性的复制条件(Replication Condition)
virtual void GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const override;
//生命值
//ReplicatedUsing为当服务端修改Health数值时的回调,拥有Replicated属性时,其值会从服务器自动复制到客户端
UPROPERTY(BlueprintReadOnly,ReplicatedUsing = OnRep_Health,Category="Vital Attributes")
FGameplayAttributeData Health;
UPROPERTY(BlueprintReadOnly,ReplicatedUsing = OnRep_MaxHealth,Category="Vital Attributes")
FGameplayAttributeData MaxHealth;
UFUNCTION()
void OnRep_Health(const FGameplayAttributeData& OldHealth) const;
UFUNCTION()
void OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth) const;
};
UFUNCTION
是 Unreal Engine 中用于标记函数的一种宏,它让 Unreal Engine 的 反射系统(Reflection System) 能够识别和处理这个函数。通过使用UFUNCTION
宏,开发者可以让函数具有特定的功能,例如暴露给蓝图、支持网络调用(如 RPC)、绑定到特定的事件等。
//AuraAttributeSet.cpp
void UAuraAttributeSet::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
//设置属性Health在服务器上可以被Replicate,条件为None,Always Replicate
//对于最后一个属性,其默认值为REPNOTIFY_OnChanged,也就是当该属性更改时才Replicate
DOREPLIFETIME_CONDITION_NOTIFY(UAuraAttributeSet,Health,COND_None,REPNOTIFY_Always);
}
void UAuraAttributeSet::OnRep_Health(const FGameplayAttributeData& OldHealth )const
{
//通知能力系统我们正在Replicate一个属性,传入OldHealth为了后续回滚
GAMEPLAYATTRIBUTE_REPNOTIFY(UAuraAttributeSet,Health,OldHealth);
}
void UAuraAttributeSet::OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth) const
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UAuraAttributeSet,Health,OldMaxHealth);
}
ReplicatedUsing
是用于标记某个属性的 复制(Replication) 行为的宏修饰符,它允许开发者在属性被复制到客户端后触发一个回调函数。这样,你可以在属性的值从服务器复制到客户端时做一些额外的处理,比如更新 UI、执行动画或播放音效。
- 注册属性用于复制:
GetLifetimeReplicatedProps
函数通过调用DOREPLIFETIME
宏,将类中的属性标记为可复制属性。这样,Unreal Engine 在运行时知道哪些属性需要在网络中进行同步。 - 控制属性的复制条件:你还可以在这个函数中指定条件(比如:只在玩家靠近时复制),通过使用
COND_XXX
枚举来优化网络性能。
工作流程
- 属性标记:通过
DOREPLIFETIME
或DOREPLIFETIME_CONDITION
宏,将需要复制的属性注册到OutLifetimeProps
中。 - 引擎处理:在游戏运行时,UE的网络引擎会检查这些属性,如果属性在服务器端发生变化,就会自动同步到客户端。
- 客户端接收:客户端接收到服务器的属性更新后,会更新自身的属性值,从而保持客户端和服务器的同步。
在虚幻引擎中复制Actor属性 | 虚幻引擎 5.4 文档 | Epic Developer Community
为角色添加属性的步骤※
- 头文件中设置
FGameplayAttributeData XXX
属性值 - 设置一个
void OnRep_XXX(const FGameplayAttributeData& OldXXX)
函数,并给这个函数附上UFUNCTION()
宏 - 给XXX属性添加
UPROPERTY(
BlueprintReadOnly
,
ReplicatedUsing = OnRep_XXX
)
- 若头文件中没有定义
GetLifetimeReplicatedProps
的重载,定义它 - 在cpp中,
GetLifetimeReplicatedProps
函数里添加宏DOREPLIFETIME_CONDITION_NOTIFY(属性类,属性名,Replicate的条件,Replicate通知的时机)
DOREPLIFETIME_CONDITION_NOTIFY(UAuraAttributeSet,Health,COND_None,REPNOTIFY_Always);
- 在cpp中,为
OnRep_XXX()
函数体内添加``GAMEPLAYATTRIBUTE_REPNOTIFY(属性类,属性,属性的旧值);
void UAuraAttributeSet::OnRep_Health(const FGameplayAttributeData& OldHealth )const
{
//通知能力系统我们正在Replicate一个属性,传入OldHealth为了后续回滚
GAMEPLAYATTRIBUTE_REPNOTIFY(UAuraAttributeSet,Health,OldHealth);
}
属性访问器
现在我们有了一些属性,这时我们需要一些方法来初始化、获取、修改这些属性的值
这种方法被称为属性访问器(ATTRIBUTE_ACCESSORS
)
属性访问器有4种: 获取属性(GAMEPLAYATTRIBUTE_PROPERTY_GETTER
)、 获取属性值(GAMEPLAYATTRIBUTE_PROPERTY_VALUE_GETTER
)、 初始化值(GAMEPLAYATTRIBUTE_PROPERTY_VALUE_INITTER
)、 修改属性值(GAMEPLAYATTRIBUTE_PROPERTY_VALUE_SETTER
)
为什么要有初始化值?不是有SETTER吗?
在构造函数中,调用SETTER太早,
SETTER间接调用了 UAttributeSet::GetOwningActor()
或 UAttributeSet::GetOwningAbilitySystemComponent()
,而这些方法在 UAttributeSet
中依赖于 AbilitySystemComponent
和 OwningActor
的有效性。如果在 UAuraAttributeSet
构造函数中,AbilitySystemComponent
或 OwningActor
尚未初始化,就可能导致类型转换失败的错误。
InitHealth()
是一个纯粹的值初始化函数,它仅仅设置一个内部变量(如Health
),不会与AbilitySystemComponent
或OwningActor
交互,也不依赖其他外部系统。因此它不需要依赖AbilitySystemComponent
或OwningActor
的存在,所以不会触发报错。SetHealth()
不仅仅是一个简单的赋值函数。它可能会触发其他系统的逻辑(例如:通知AbilitySystemComponent
、更新 UI、同步网络数据等),并且在执行过程中需要访问OwningActor
或AbilitySystemComponent
。由于在构造函数阶段,这些组件可能尚未初始化,导致类型转换失败。
然后这些访问器都是由宏来设置的 我们可以点进去查看宏定义
然后在上述注释中,可以找到这些宏的简写:ATTRIBUTE_ACCESSORS
//To use this in your game you can define something like this,
//and then add game-specific functions as necessary:
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)
ATTRIBUTE_ACCESSORS(UMyHealthSet, Health)
使用该宏,就可以为我们所有属性设置属性访问器了
//#include "AttributeSet.h"
//...
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)
//...public:
//...
//生命值
//ReplicatedUsing为当服务端修改Health数值时的回调,拥有Replicated属性时,其值会从服务器自动复制到客户端
UPROPERTY(BlueprintReadOnly,ReplicatedUsing = OnRep_Health,Category="Vital Attributes")
FGameplayAttributeData Health;
//生命值的属性访问器
ATTRIBUTE_ACCESSORS(UAuraAttributeSet,Health);
在游戏debug控制台中查看属性信息
我们在AuraAttributeSet
的构造函数中,试着初始化一下Health
UAuraAttributeSet::UAuraAttributeSet()
{
InitHealth(100.f);
}
然后我们在UE中开启游戏,我们摁~
键打开控制台,输入 showdebug abilitysystem
就可以看到角色的属性了,我们摁PAGEUP
键可以循环各个角色
简易Effect(生命药水)
我们想要做一个简易的生命药水,使得主角在移动到生命药水上时,可以增加自己的生命值
先创建一个C++类,AuraEffectActor 放在public/Actor目录下
要想做一个生命药水 ,首先要设置一个Mesh
(骨骼网格体)用来放置生命药水的模型,和一个Sphere
(球形碰撞体积)用来检测碰撞(重叠)
//AuraEffectActor.h
class USphereComponent;
UCLASS()
class AURA_API AAuraEffectActor : public AActor
{
GENERATED_BODY()
public:
AAuraEffectActor();
protected:
virtual void BeginPlay() override;
private:
UPROPERTY(VisibleAnywhere)
TObjectPtr<USphereComponent> Sphere;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Mesh;
};
在构造函数中创建Mesh和Sphere的DefaultSubobject,并将Mesh设为根组件,将Sphere挂载到该组件下 这一步为了能够在蓝图中访问Mesh和Sphere,然后设置模型资产与碰撞体积
//AuraEffectActor.cpp
AAuraEffectActor::AAuraEffectActor()
{
PrimaryActorTick.bCanEverTick = true;
Mesh = CreateDefaultSubobject<UStaticMeshComponent>("Mesh");
SetRootComponent(Mesh);
Sphere = CreateDefaultSubobject<USphereComponent>("Sphere");
Sphere->SetupAttachment(GetRootComponent());
}
随后,我们应该来设置碰撞(重叠),为什么是重叠(Overlap)而不是碰撞(Collision),因为我们移动到生命药水时,不想让它有碰撞体积,所以我们设置Overlap即可
//AuraEffectActor.h
//这两个函数是作为在碰撞后的回调函数来使用的
UFUNCTION()
virtual void OnOverlap(UPrimitiveComponent* OverLappedComponent,AActor* OtherActor,
UPrimitiveComponent* OtherComp,int32 OtherBodyIndex,bool bFromSweep,const FHitResult& SweepResult);
UFUNCTION()
virtual void EndOverlap(UPrimitiveComponent* OverLappedComponent,AActor* OtherActor,
UPrimitiveComponent* OtherComp,int32 OtherBodyIndex);
//AuraEffectActor.cpp
void AAuraEffectActor::OnOverlap(UPrimitiveComponent* OverLappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep,const FHitResult& SweepResult)
{
//TODO Change this to apply Gameplay Effect. But now,using const_cast as a hack
//获取与自身重叠的Actor的ASC,如果该Actor没有实现IAbilitySystemInterface接口,则ASCInterface为nullptr
if(IAbilitySystemInterface* ASCInterface = Cast<IAbilitySystemInterface>(OtherActor))
{
//获取主角的AttributeSet
const UAuraAttributeSet* AuraAttributeSet =
Cast<UAuraAttributeSet>(ASCInterface->GetAbilitySystemComponent()->GetAttributeSet(UAuraAttributeSet::StaticClass()));
UAuraAttributeSet* MutableAuraAttributeSet = const_cast<UAuraAttributeSet*>(AuraAttributeSet);
MutableAuraAttributeSet->SetHealth(AuraAttributeSet->GetHealth() + 25.f);
}
}
void AAuraEffectActor::EndOverlap(UPrimitiveComponent* OverLappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
}
void AAuraEffectActor::BeginPlay()
{
Super::BeginPlay();
//设置重叠时的回调函数
Sphere->OnComponentBeginOverlap.AddDynamic(this,&AAuraEffectActor::OnOverlap);
Sphere->OnComponentEndOverlap.AddDynamic(this,&AAuraEffectActor::EndOverlap);
}
UPrimitiveComponent
是所有涉及碰撞和渲染的组件的父类,它直接或间接派生自UActorComponent
。它定义了对象如何在场景中呈现、与物理系统交互,以及如何处理碰撞和重叠检测。
然后我们新建一个蓝图BP_HealthPotion
设置Mesh的资产,设置Sphere的大小位置
将其拖到场景中,查看碰撞前后,角色属性的变化
碰撞生命药水之后,生命值增加了25
在修改AttributeSet时,我们使用了const_cast,其实本不应该这样做的,后续我们会有修改建议