RPG GAME UI
在UE5中,UI是由一个个的Widgets
构成的,Widgets
属于类UUSerWidget
,而我们游戏的属性等数据在GAS的AttributeSet
中,Widgets
该如何去获取这些属性呢?
我们真的应该去在Widgets
中深入每一个类,然后去获取那些对象中的属性吗?
在UI中,这些可视化的部件叫做View
视图,而数据的总和叫做Model
,我们的任务就是从Model中获取所需要的数据然后传递给View
我们想要一个中间层Controller来管理视图与数据间的关系,Controller可以从Model中检索数据,并将这些数据广播到View中,这个中间层不仅能够负责获取数据,还要能够有处理数据的逻辑(比如在地图上计算点到点的距离、设计最短路线等等),我们叫它WidgetController
; 这使得View
层可以专注于呈现UI,Model
层可以只限于提供数据
以及,在我们摁下按钮时,可以对数据进行一定的改动,比如技能加点,天赋加点等等
在前端中,有一个架构叫做MVVM MVVM模式(Model-View-ViewModel)架构模式,是将View和ViewModel关联起来,通过双向数据绑定实现View和ViewModel的同步更新。View负责展示数据和用户交互,ViewModel负责处理数据和业务逻辑,Model负责存储数据。MVVM的优点是能够降低View和ViewModel之间的耦合,使得代码更加可维护和可测试。
初步构建MVVM系统
新建C++类AuraUserWidget
, AuraWidgetController
AuraUserWidget父类为UserWidget
AuraWidgetController父类为Object
现在我们先构建AuraUserWidget与WidgetController间的联系
//AuraUserWidget.h
UCLASS()
class AURA_API UAuraUserWidget : public UUserWidget
{
GENERATED_BODY()
public:
//蓝图调用方法,可以设置WidgetController
UFUNCTION(BlueprintCallable)
void SetWidgetController(UObject* InWidgetController);
//WidgetController
UPROPERTY(BlueprintReadOnly)
TObjectPtr<UObject> WidgetController;
protected:
UFUNCTION(BlueprintImplementableEvent)
void WidgetControllerSet();
};
//AuraUserWidget.cpp
void UAuraUserWidget::SetWidgetController(UObject* InWidgetController)
{
WidgetController = InWidgetController;
WidgetControllerSet();
}
然后我们想想在WidgetController中,有哪些数据需要获取?
PlayerController` ,`PlayerState`,`AbilitySystemComponent`,`AttributeSet
//WidgetController.h
class UAttributeSet;
class UAbilitySystemComponent;
/**
*
*/
UCLASS()
class AURA_API UAuraWidgetController : public UObject
{
GENERATED_BODY()
protected:
UPROPERTY(BlueprintReadOnly,Category="WidgetController")
TObjectPtr<APlayerController> PlayerController;
UPROPERTY(BlueprintReadOnly,Category="WidgetController")
TObjectPtr<APlayerState> PlayerState;
UPROPERTY(BlueprintReadOnly,Category="WidgetController")
TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;
UPROPERTY(BlueprintReadOnly,Category="WidgetController")
TObjectPtr<UAttributeSet> AttributeSet;
};
制作模板球体UI
我们有血量,蓝量等球体UI,如下图,我们想要创建一个模板的球体UI,可以让血量蓝量等球体UI可以复用
- 创建UI的层级
将SizeBox,Image_Background,ProgressBar,Image_Class全部设置为是变量
- 设置模板的默认值
将SizeBox的结构设置为所需,并设置为变量,打开宽度重载和高度重载 我们在复用时,可能会在子类中修改高度和宽度
Overlay设置水平对齐和垂直对其为填充
设置ImageBackGround
水平垂直填充
设置ProgressBar_Globe
水平垂直填充
ImageGlass
设置水平垂直填充
- 设置可被子类覆盖的变量
- BoxSize
新建变量BoxHeight,BoxWidth,设置为浮点,新建类别GlobeProperties
然后设置默认值都为250.0
- ImageBackGround
设置默认值
- ProgressBar
新增变量ProgressBarFillImage为Slate笔刷,设置默认值(这个可以不设置),必须设置Tint的颜色为纯透明
Tint为填充背景图
- GlobePadding(用于进度条的padding)
- ImageGlass
变量为Slate笔刷,默认设置背景图为MI_Empty
- GlassPadding(ImageGlass的padding)
变量为float,默认值为10.0
然后模板球体UI就做好了,接下来可以制作继承自模板球体UI的生命值和法力值UI了
制作生命值法力值UI
新建控件蓝图,继承自刚才的模板UI
然后在蓝图中,设置显示继承的变量
设置ProgressBarFillImage的值为MI_HealthGlobe就可以了
法力值同理,将ProgressBarFillImage 设置为MI_ManaGlobe
制作一个UI汇总的Overlay
我们会将所有UI组件都放置到同一个Overlay中,我们可以拖拽UI组件进去然后布局,Overlay就是我们用户界面的总布局
我们创建一个Canvas,设置大小为填充屏幕
然后我们就可以把UI组件拖进Canvas中,设置他们的锚点都为BottomCenter
然后我们进入关卡蓝图
创建新控件
然后选择WBP_Overlay,添加进视口
进入关卡就可以看到效果了
创建HUD
但是,在关卡中设置UI展现是不太好的,因为这会使得在每个关卡都要放置UI,此时,有一个叫做HUD的类可以帮助我们在GameMode上面放置UI并展示在视口
此时我们先创建一个AuraHUD的C++类,继承自HUD类
//AuraHUD.h
class AURA_API AAuraHUD : public AHUD
{
GENERATED_BODY()
public:
UPROPERTY()
TObjectPtr<UAuraUserWidget> OverlayWidget;
protected:
virtual void BeginPlay() override;
private:
//可以在蓝图中放置所需的Widget类
UPROPERTY(EditAnywhere)
TSubclassOf<UAuraUserWidget> OverlayWidgetClass;
};
//AuraHUD.cpp
void AAuraHUD::BeginPlay()
{
Super::BeginPlay();
//创建Widget,其类为蓝图上放置的Overlay类
UUserWidget* Widget = CreateWidget<UUserWidget>(GetWorld(),OverlayWidgetClass);
//添加进视口
Widget->AddToViewport();
}
然后在Blueprints/UI/HUD中创建BP_AuraHUD,设置OverlayWidgetClass
最后在AuraGameMode上设置HUD类为BP_AuraHUD
删除关卡蓝图上的Widget,启动游戏,UI依然存在
将数据与视图绑定
初始化WidgetController
创建一个C++类OverlayWidgetController
我们前面经创建了控制器层基类。接下来会基于控制器层基类创建一个UI专用的控制层类供UI使用。
在基类中,添加一个结构体FWidgetControllerParams,供我们初始化WidgetConstoller所需的数据
//AuraWidgetController.h
struct FWidgetControllerParams
{
GENERATED_BODY()
FWidgetControllerParams(){};
FWidgetControllerParams(APlayerController* PC,APlayerState* PS,
UAbilitySystemComponent* ASC,UAttributeSet* AS):
PlayerController(PC),PlayerState(PS),AbilitySystemComponent(ASC),AttributeSet(AS){};
UPROPERTY(EditAnywhere,BlueprintReadWrite)
TObjectPtr<APlayerController> PlayerController = nullptr;
UPROPERTY(EditAnywhere,BlueprintReadWrite)
TObjectPtr<APlayerState> PlayerState = nullptr;
UPROPERTY(EditAnywhere,BlueprintReadWrite)
TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent = nullptr;
UPROPERTY(EditAnywhere,BlueprintReadWrite)
TObjectPtr<UAttributeSet> AttributeSet = nullptr;
};
class AURA_API UAuraWidgetController : public UObject
{
GENERATED_BODY()
public:
//成员函数,绑定所需的数据至WidgetController
UFUNCTION(BlueprintCallable)
void SetWidgetControllerParams(const FWidgetControllerParams& WCParams);
//...
};
//AuraWidgetController.cpp
void UAuraWidgetController::SetWidgetControllerParams
(const FWidgetControllerParams& WCParams)
{
PlayerController = WCParams.PlayerController;
PlayerState = WCParams.PlayerState;
AbilitySystemComponent = WCParams.AbilitySystemComponent;
AttributeSet = WCParams.AttributeSet;
}
然后在AuraHUD中添加初始化UI视图和WidgetController
//AuraHUD.h
class AURA_API AAuraHUD : public AHUD
{
GENERATED_BODY()
public:
UPROPERTY()
TObjectPtr<UAuraUserWidget> OverlayWidget;
//获取WidgetController
UOverlayWidgetController* GetOverlayWidgetController
(const FWidgetControllerParams& WCParams);
//初始化WidgetController和Widget
void InitOverlay(APlayerController* PC,APlayerState* PS,
UAbilitySystemComponent* ASC,UAttributeSet* AS);
//在此时,我们不能在BeginPlay中初始化WidgetController,
//因为在BeginPlay时,我们不知道ASC,AS等数据是否已经初始化完毕
//我们必须等数据都初始化完毕后,再去初始化WidgetController,我们需要一个时机去调用InitOverlay
private:
//WidgetController
UPROPERTY()
TObjectPtr<UOverlayWidgetController> OverlayWidgetController;
//WidgetController所需的WidgetController类,可以在蓝图中设置
//(因为子类可能所需的WidgetController不同)
UPROPERTY(EditAnywhere)
TSubclassOf<UOverlayWidgetController> OverlayWidgetControllerClass;
//可以在蓝图中设置所需的Widget类
UPROPERTY(EditAnywhere)
TSubclassOf<UAuraUserWidget> OverlayWidgetClass;
};
//AuraHUD.cpp
UOverlayWidgetController* AAuraHUD::GetOverlayWidgetController(const FWidgetControllerParams& WCParams)
{
if(OverlayWidgetController == nullptr)
{
OverlayWidgetController = NewObject<UOverlayWidgetController>(this,OverlayWidgetClass);
OverlayWidgetController->SetWidgetControllerParams(WCParams);
}
return OverlayWidgetController;
}
void AAuraHUD::InitOverlay(APlayerController* PC,APlayerState* PS,
UAbilitySystemComponent* ASC,UAttributeSet* AS)
{
checkf(OverlayWidgetClass,TEXT("OverlayWidgetClass没有在蓝图中初始化"))
checkf(OverlayWidgetControllerClass,TEXT("OverlayWidgetControllerClass没有在蓝图中初始化"))
//创建Widget,其类为蓝图上放置的Overlay类
UUserWidget* Widget = CreateWidget<UUserWidget>(GetWorld(),OverlayWidgetClass);
OverlayWidget = Cast<UAuraUserWidget>(Widget);
//初始化WidgetController
const FWidgetControllerParams WidgetControllerParams(PC,PS,ASC,AS);
UOverlayWidgetController* WidgetController = GetOverlayWidgetController(WidgetControllerParams);
OverlayWidget->SetWidgetController(WidgetController);
//添加进视口
Widget->AddToViewport();
}
NewObject
既需要提供模板类型(如 NewObject<UMyObject>()
)又可以接受一个 UClass
参数,主要是出于灵活性和类型安全性的考虑。我们来详细解释一下这个设计背后的原因。
1. 模板类型的用途:编译时类型安全
模板参数提供了编译时类型安全的机制。通过模板参数 T
,编译器可以确保你创建的对象类型是确定的,这样在编译期就能检查类型是否匹配,从而避免不必要的运行时错误。
UMyObject* MyObjectInstance = NewObject<UMyObject>();
上面的代码中,模板 UMyObject
确定了创建的对象类型,并且函数返回值类型也是 UMyObject*
。这种方式的好处是:
- 类型安全:编译器在编译时能够确保你正在创建的对象类型是正确的。如果你尝试将返回的对象赋值给一个不兼容的类型,编译器会发出错误。
- 简洁:通过模板参数,你可以避免显式地提供
UClass
类型,使代码简洁且易于阅读。
2. UClass 参数的用途:运行时动态性
UClass*
参数则提供了运行时的灵活性。虽然模板可以在编译时提供类型安全性,但在某些情况下,你可能无法在编译期确定要创建的类型,需要在运行时动态决定。这时就需要通过 UClass
来决定对象的类型。
UClass* DynamicClass = GetSomeClass(); // 从运行时动态获取类` `UObject* DynamicObject = NewObject<UObject>(Outer, DynamicClass);
在上面的代码中,DynamicClass
可以在运行时根据游戏逻辑动态变化。这种情况下,模板类型 UObject
作为基类,允许你动态地指定派生类类型。这样你就可以在运行时决定创建哪个类的实例,而不局限于编译期的模板类型。
然后我们要思考,在哪里能够调用InitOverlay这个函数,一定是在PlayerController,PlayerState,ASC和AS都初始化完成的那个时候,也就是AuraCharacter中的InitAbilityActorInfo里
//AuraCharacter.cpp
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();
//InitOverlay,此时PlayerController,PlayerState,ASC,AS都初始化好了,可以将这些数据与WidgetController绑定
//在多人游戏的客户端中,这个PlayerController可能为空,因为每个玩家只能拥有自己的PlayerController
//只有对于自己控制的角色,PlayerController不为空,当然,也不需要在自己的屏幕上添加其他角色的HUD
if(AAuraPlayerController* PlayerController = Cast<AAuraPlayerController>(GetController()))
{
//如果HUD有效,那么我们可以将数据通过HUD与WidgetController绑定
if(AAuraHUD* AuraHUD = Cast<AAuraHUD>(PlayerController->GetHUD()))
{
AuraHUD->InitOverlay(PlayerController,AuraPlayerState,AbilitySystemComponent,AttributeSet);
}
}
}
我们在这时可以想到,在多人游戏中,很多时候我们都要判断是否为空指针,比如在AAuraPlayerController::BeginPlay
里,
//...
//check(Subsystem)不能使用,否则在多人游戏中有其他玩家,游戏会崩溃
//在多人游戏中,如果有其他的玩家,此时在本机初始化其他玩家的时候,Subsystem为空
UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetLocalPlayer());
if(Subsystem){
Subsystem->AddMappingContext(AuraContext,0);
}
//...
随后我们在BP_AuraHUD中,设置OverlayWidgetControllerClass与OverlayWidgetClass
向View广播数据值
在AuraHUD中,OverlayWidget->SetWidgetController(WidgetController);
我们给Widget设置了WidgetController,此时,WidgetController不知道有谁绑定了它,只有Widget知道它绑定了WidgetController,我们想要在Widget中获取WidgetController中的数据,我们想要一种单向数据流来做这件事情,
我们可以使用GALEGATE
委托来广播数据;委托和JS当中的Event事件相似,是UE中一种自定义的消息机制
- UE****中的事件委托
-
使用过程
-
申明一个代理(消息)类型
-
DECLARE_DELEGATE(FStandardDelegateSignature)
-
FStandardDelegateSignature就是我们自己定义代理类型,可以将FStandardDelegateSignature看成使用UE宏DECLARE_DELEGATE声明的一个类(Class)
-
定义一个代理(消息)
-
FStandardDelegateSignature MyStandardDelegate;
-
MyStandardDelegate就是我们自己定义的一个代理,可以看成FStandardDelegateSignature的一个实例Instance
-
绑定响应函数
-
MyStandardDelegate.BindUObject(this, &ADelegateListener::EnableLight)
-
绑定响应函数就是定义代理(消息)被触发时的响应函数,跟Javascript中的callback函数原理是一样的。
-
触发代理(消息)
-
MyStandardDelegate.ExeIfBound();
-
前面三步已经完成消息的注册,这一步就可以触发代理,然后执行代理定义好的响应函数,完成整个消息处理流程。
-
解除绑定响应函数
-
MyStandardDelegate.Unbind();
-
如果后续不再需要响应函数,可以及时取消代理与响应函数的绑定。
-
代理分类
-
第一种分类:
-
代理按绑定响应函数的个数分成:Standard delegate(标准代理,或者叫做单播代理)和MultiCast delegate(多播代理)。他们之间区别非常简单,MultiCast支持绑定多个响应函数,Standard delegate只能有一个响应函数,也就是说MultiCast delegate跟广播的概念是一样的,有群发的效果。
-
Multicast delegate的使用
-
DECLARE_MULTICAST_DELEGATE(FMulticastDelegateSignature)
-
FMultiDelegateSignature MyMulticastDelegate
-
MyMulticastDelegate.AddUObject(this, &ResponseFunction)
-
MyMulticastDelegate.Broadcast()
-
MyMulticastDelegate.RemoveAll()
-
第二种分类:
-
按是否可以在蓝图中调用来分类:标准(静态)代理与动态代理(Dynamic)
-
动态代理与标准代理之间区别:动态代理支持序列化,因此可以在蓝图中使用,而标准的却不能。
-
然后,我们创建一个函数BroadcastInitialValues来向视图广播一次初始值
//AuraWidgetController.h
public:
//...
//向绑定了该WidgetController的Widget广播初始值
virtual void BroadcastInitialValues();
//...
//OverlayWidgetController.h
//声明广播健康值的委托
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChangedSignature,float,NewHealth);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnMaxHealthChangedSignature,float,NewMaxHealth);
/**
*
*/
UCLASS()
class AURA_API UOverlayWidgetController : public UAuraWidgetController
{
GENERATED_BODY()
public:
virtual void BroadcastInitialValues() override;
UPROPERTY(BlueprintAssignable,Category="GAS|Attributes")
FOnHealthChangedSignature OnHealthChanged;
UPROPERTY(BlueprintAssignable,Category="GAS|Attributes")
FOnMaxHealthChangedSignature OnMaxHealthChanged;
};
//OverlayWidgetController.cpp
void UOverlayWidgetController::BroadcastInitialValues()
{
//不需要调用父类的Broadcast,我们想要有自己的广播
//转化至UAuraAttributeSet,这样就可以调用GetHealth和GetMaxHealth等获得数据了
const UAuraAttributeSet* AuraAttributeSet = CastChecked<UAuraAttributeSet>(AttributeSet);
//如果转化不成功游戏将会崩溃
//广播初始化值
OnHealthChanged.Broadcast(AuraAttributeSet->GetHealth());
OnMaxHealthChanged.Broadcast(AuraAttributeSet->GetMaxHealth());
}
然后我们选择一个广播初始化值的时机,在AuraHUD的InitOverlay函数中,在Widget设置完WidgetController后,就可以广播了
//AuraHUD.cpp
//...
UOverlayWidgetController* WidgetController = GetOverlayWidgetController(WidgetControllerParams);
OverlayWidget->SetWidgetController(WidgetController);
//让WidgetController广播初始值,因为此时Widget已经绑定好了WidgetController
WidgetController->BroadcastInitialValues();
//添加进视口
Widget->AddToViewport();
//...
然后我们在WBP_Overlay
中,将生命值UI组件(WBP_HealthGlobe
)和法力值UI组件(WBP_ManaGlobe
)绑定WidgetController
这个事件WidgetControllerSet是在AuraUserWidget类中定义的
在SetWidgetController中调用的,它是一个将WidgetController在Widget设置好中的一个回调,可以在蓝图中实现
而且,生命值UI组件和法力值UI组件的父类都是AuraUserWidget,所以,为他们设置WidgetController后,又可以在他们的蓝图中去调用回调函数,此时我们进入生命值UI的蓝图然后在初始化时将WidgetController广播的初始化生命值和最大生命值赋值
但是,OverlayWidgetController不是蓝图类,所以无法在蓝图中将WidgetController类转换为OverlayWidgetController
所以在其UCLASS上设置为蓝图类型
UCLASS(BlueprintType,Blueprintable)
class AURA_API UOverlayWidgetController : public UAuraWidgetController
创建一个蓝图类型的OverlayWidgetController,名为BP_OverlayWidgetController
在BP_AuraHUD中将OverlayWidgetControllerClass设为BP_OverlayWidgetController
然后在生命值蓝图中,分配事件(记住是分配Assign
)
然后,我们获得了Health和MaxHealth,现在可以为生命UI设置生命值进度条所占百分比了
但是,我们无法在子类上设置进度条所占百分比,这个参数是保存在了父类,所以我们要在父类上创建一个函数让子类能够调用
OnHealth处理
OnMaxHealth、Mana同理
我们初始化角色的血量为100,满血为200
我们打开游戏可以看到角色的血量为一半
监听数据的变化
AbilitySystem提供了一个监听数据变化的函数AbilitySystem.GetGameplayAttributeValueChangeDelegate(想要监听的数据).AddUObject(要绑定的 UObject 实例,&成员函数)
我们可以调用这个方法来监听数据的变化,我们把绑定监听数据的回调放到基类的虚函数中
//AuraWidgetController.h
//...
public:
//...
//设置监听数据变化的依赖绑定
virtual void BindCallbacksToDependencies();
//...
然后我们在OverlayWidgetController设置
//OverlayWidgetController.h
//...
public:
//设置监听数据变化的依赖绑定
virtual void BindCallbacksToDependencies() override;
//...
protected:
//生命值变化的回调
void HealthChanged(const FOnAttributeChangeData& Data) const;
//最大生命值变化的回调
void MaxHealthChanged(const FOnAttributeChangeData& Data) const;
//...
//OverlayWidgetController.cpp
void UOverlayWidgetController::BroadcastInitialValues()
{
//...
//在BroadcastInitialValues的最后绑定依赖
BindCallbacksToDependencies();
}
void UOverlayWidgetController::BindCallbacksToDependencies()
{
const UAuraAttributeSet* AuraAttributeSet = CastChecked<UAuraAttributeSet>(AttributeSet);
//绑定当生命值改变时的回调
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AuraAttributeSet->GetHealthAttribute())
.AddUObject(this,&UOverlayWidgetController::HealthChanged);
//绑定最大生命值改变的回调
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AuraAttributeSet->GetMaxHealthAttribute())
.AddUObject(this,&UOverlayWidgetController::MaxHealthChanged);
}
void UOverlayWidgetController::HealthChanged(const FOnAttributeChangeData& Data) const
{
OnHealthChanged.Broadcast(Data.NewValue);
}
void UOverlayWidgetController::MaxHealthChanged(const FOnAttributeChangeData& Data) const
{
OnMaxHealthChanged.Broadcast(Data.NewValue);
}
此时,我们就可以在游戏中看到,当我们捡起血瓶时,生命值增加
任务:设置Mana法力值
- 在AuraAttributeSet的构造函数中InitMana和InitMaxMana
- 在OverlayWidgetController中声明法力值改变的委托,并添加回调
- 在OverlayWidgetController的BindCallbacksToDependencies添加法力值改变后的回调
- 在蓝图中绑定委托广播事件后的操作,改变视图的Mana&MaxMana