斯坦福 UE4 C++ ActionRoguelike游戏实例教程 07.在C++中使用UMG
斯坦福 UE4 C++ ActionRoguelike游戏实例教程 07.在C++中使用UMG
斯坦福课程 UE4 C++ ActionRoguelike游戏实例教程 0.绪论
概述
本篇文章的目标是创建一个基于C++的UMG类,并以这个类作为子类,为攻击到的敌对小兵添加一个血条UI。
最终效果如下:
目录
- 认识UMG
- 创建小兵血条
UMG
UE中的UMG(Unreal Motion Graphics)是一种用于创建用户界面(UI)和HUD(头部显示)的工具。UMG提供了一个可视化的编辑器,允许开发人员轻松创建复杂的UI和HUD,而无需编写代码。正如我们常见到的血条UI、操作提示、各种游戏内积分提示都是可以使用UMG实现的。
UMG包括一系列可自定义的预制件,如按钮、文本框、滑块、进度条等等。这些预制件可以通过拖放操作在编辑器中创建,然后进行定位、缩放和旋转等操作以满足特定的UI需求。此外,UMG还提供了脚本编写接口,使得开发人员可以通过编写脚本来实现更加复杂的UI逻辑。
UMG编辑界面就不详细展示了,这里简单提一下UMG的使用。
创建完成后,可以在任意事件蓝图里使用如下方式创建控件,并添加到视口。
今天还要介绍一种用c++创建和编辑UMG的方法,当然,最方便的还是直接使用UMG提供的编辑器来操作,我们用C++通常只是创建一个基类用来定义UMG蓝图的接口。具体怎么使用UMG,让我们边做边说。
创建小兵血条
编辑C++代码
UserWidget可以说是各种用户UI控件的基类,后续使用的蓝图类也是继承于该类。我们将他命名为SurWorldUserWidget,之后这个类还会作为其他控件的基类,大家在设计的时候可以好好考虑这个类里应该实现什么功能。
创建完成后,UE会自动在uproject文件里自动添加编译"UMG"模块,出于一致性,记得在Build.cs文件里添加"UMG"。
下面我贴出了新创建的类的所有代码。先看看头文件,里面定义了一些成员:
- 首先定义了一个SizeBox类型的成员。SizeBox允许使用者自定义其大小,并限定其内容的尺寸。注意这里使用了
meta = (BindWidget)
修饰符,和名字一样,这是控件专用的修饰符,可以将指针绑定到UMG编辑器里同名同类型的组件。如果编辑器里没有对应组件,编译甚至还会报错。 - 作为小兵的血条控件,就需要绑定一个小兵的对象。这里定义了
AActor* AttachedActor
,用意很好理解。值得一提的是,由于小兵和UMG控件是独立的两个对象,当小兵对象被销毁时,传统C++的方法是无法在控件类中得知指针是否被释放的,容易造成空悬指针的问题。好在UE引进了垃圾回收机制,只要加上UPROPERTY宏,就自动为成员变量登记垃圾回收。当指针所指的对象销毁掉后,会自动将指针置为nullptr,比C++11的share_ptr还好用( - 最后就是重载了NativeTick函数,每次Tick都会执行一次。
//SurWorldUserWidget.h
UCLASS()
class FPSPROJECT_API USurWorldUserWidget : public UUserWidget
{
GENERATED_BODY()
protected:
//该宏允许蓝图编辑器的同名同类型的组件 与 C++中的成员指针相关联
//因此要想成功绑定,蓝图里的控件必须是同名同类型的
UPROPERTY(meta = (BindWidget))
USizeBox* ParentSizeBox;
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
public:
//一个很重要的一点就是添加了UPROPERTY宏的对象都会被列入UOBJECT的垃圾回收系统管理。当这个对象在其他地方被释放(destroy)后,所有指向这个对象的指针都会被赋为NULL,有点像shared_ptr
UPROPERTY(BlueprintReadOnly, Category = "UI")
AActor* AttachedActor;
};
接下来是Cpp文件的内容。
具体来说,这段代码的作用如下:
- 首先,代码会调用Super::NativeTick(MyGeometry, InDeltaTime)来调用UserWidget类的NativeTick方法,以确保父类的功能正常工作。
- 然后,代码会检查目标Actor是否有效。正如上面所说的,垃圾回收机制解决了空悬指针的问题,但是没有解决空指针的问题。因此这里同样需要注意空指针的问题。这里使用了IsValid函数进行判断指针是否有效,该函数除了判断指针是否为空以外,还判断该对象是否被标记为删除,也就是调用了Destroy之类的函数。如果AttachedActor为空,则调用RemoveFromParent()从父级控件中移除自定义控件,并输出日志信息,以告知开发者该控件被移除。
- 接下来,代码会通过UGameplayStatics::ProjectWorldToScreen函数将AttachedActor的三维世界坐标投影到二维屏幕坐标系中,得到屏幕坐标。
- 接着,代码会使用UWidgetLayoutLibrary::GetViewportScale函数获取视口缩放比例,然后将屏幕坐标除以该比例,以确保在不同分辨率的屏幕上,控件的位置保持一致。
- 最后,如果ParentSizeBox不为空,代码会将控件的渲染位置设置为屏幕坐标,以实现控件位置的更新。
//SurWorldUserWidget.cpp
void USurWorldUserWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
//首先判断目标Actor是否有效
if(!IsValid(AttachedActor))
{
RemoveFromParent();
UE_LOG(LogTemp, Warning, TEXT("AttachedActor no longger valid, removeing Health Widget."));
return;
}
FVector2D ScreenPosition;
//该函数返回三维世界投影到二维平面上的坐标
if(UGameplayStatics::ProjectWorldToScreen(GetOwningPlayer(), AttachedActor->GetActorLocation(), ScreenPosition))
{
float Scale = UWidgetLayoutLibrary::GetViewportScale(GetWorld());
ScreenPosition /= Scale;
}
if(ParentSizeBox)
{
//设置其渲染到屏幕上的位置
ParentSizeBox->SetRenderTranslation(ScreenPosition);
}
}
编辑蓝图
虽然文章讲的是在C++中使用UMG,不得不说在代码编辑UI实在是非常低效的做法。通常蓝图和C++代码截图的正确姿势是使用C++定义类的功能和接口(demo),使用蓝图来做具体方法的实现。这里当然也不例外。
这里为刚才创建的类创建一个蓝图子类,将其命名为MinionHealth_Widget。
进入编辑器,如果点击编译会提示你需要绑定ParentSizeBox,这就刚才Meta修饰符的作用所在了。
将页面的布局修改成如图:
其中,画布画板
允许其中的控件自由排布,非常适合我们根据自己的喜好手动布局。这时我们会发现SizeBox已经可以在界面中自由拖动了。将Image材质设置为前几节课制作的HealthBar,并给Image起一个自己看的顺眼的名。这里将ParentSizeBox设置成大小到内容:
该选项会使SizeBox的大小固定为其包含的组件的大小。这里的组件只有一张图片,即这张图片多大,SizeBox就有多大。
修改根据自己的喜好修改Image控件,主要是修改图像大小,由于大小到内容
的作用,SizeBox会随着Image的大小变化而变化。
为小兵添加控件相关逻辑
代码如下。在.h文件中,添加了UMG控件的对象指针,代表着一个小兵对应一个空间。并定义了要生成空间的类型。
class FPSPROJECT_API ASurAiCharacter : public ACharacter
{
GENERATED_BODY()
......
protected:
USurWorldUserWidget* ActiveHealthBar;
//我们希望生成的UI类型
UPROPERTY(EditDefaultsOnly, Category = "UI")
TSubclassOf<UUserWidget> HealthBarWidgetClass;
......
}
在.cpp文件中,主要修改OnHealthChanged
函数,因为我们希望血条UI只在小兵被攻击时才出现。为了防止每次攻击都会创建一个控件,这里判断控件指针是否为空来避免。
void ASurAiCharacter::OnHealthChanged(AActor* InstigatorActor, USurAttributeComponent* OwningComp, float NewHealth,
float Delta)
{
...
//保证只创建一次控件
if(ActiveHealthBar == nullptr)
{
ActiveHealthBar = CreateWidget<USurWorldUserWidget>(GetWorld(), HealthBarWidgetClass);
if(ActiveHealthBar)
{
ActiveHealthBar->AttachedActor = this;
ActiveHealthBar->AddToViewport();
}
}
...
}
效果如下,当我们击中小兵后,小兵身上会出现一个血条。
当前的血条位置不太对,我们可以在编辑器里对血条的位置进行调整,图中设置了血条的对其,将其都设置为0.5,这样血条就会在目标坐标的正中心出现。
为了抬高血条的高度,在SurWorldUserWidget基类里定义了控件生成的偏移,将该偏移量添加到SurWorldUserWidget.cpp的ProjectWorldToScreen函数调用即可。
//SurWorldUserWidget.h
UPROPERTY(EditAnywhere)
FVector WorldOffset;
将WorldOffset设置为(0,0,100),血条的位置如下(后文会讲解掉血效果)
添加掉血效果
正常来说做到这步,我们已经实现了一个悬浮在小兵头上的血条,他会在我们攻击小兵的时候出现。接下来该做掉血效果,该效果表现为血条的红色部分会随着小兵的血量减少而减少。让我们进入MinionHealth_Widget的图表中,从事件构造
拉出一条线,为AttachedActor的属性组件绑定我们期望在生命值发生变化时产生的事件。
蓝图修改如下,我们获取了AttachedActor的属性组件,为其绑定了一个OnHealthChanged事件,这样,每当属性组件的血量变化时,都会通过委托机制调用这个事件函数。
在该事件函数中,我们获取了动态材质也就是M_HealthBar,就像之间编辑人物血条一样设置了M_HealthBar的ProgressAlpha变量,关于M_HealthBar是什么东西,既然都看到这里了,应该都挺熟悉前面的课程。请允许笔者偷个懒,不展开描述了。具体参考之前创建人物血条的课程Lecture 7。
最后的效果如下。我们发现当第一次攻击小兵时,他出现的血条是满血的状态,也就是出现了BUG。
检查代码逻辑发现,这是程序执行顺序导致的BUG。当我们攻击到小兵时,我们还未创建控件,还未将控件的OnhealthChanged事件绑定到属性组件中,因此本次血量变化的委托之中并没有控件的OnhealthChanged事件,所以血条就不会发生变化。
解决这个BUG的办法很简单,既然构造控件和掉血是同时发生的,那我们在创建控件后手动更新一下血条即可。具体做法就是在控件构造时主动调用一次OnhealthChanged事件。当然,你也可以自己定义一段逻辑来更新材质,道理都是一样的。
当小兵血条归零后,我们可以直接删除血条。这里也是添加了一小段逻辑,将其从父项中移除。在没有父控件的情况下,这里的父项指的就是玩家的视口。至于有父控件的情况会在后面的文章提到。
最终效果&总结
本篇文章我们在C++中创建了一个UMG组件的基类,结合蓝图创建了一个在小兵头上显示的血条。在之后的课程中,我们还将学习如何将这些一个一个的统合到一个总控件上。
参考链接
UE4 UMG的简单使用 https://zhuanlan.zhihu.com/p/461626363