斯坦福 UE4 C++ ActionRoguelike游戏实例教程 07.在C++中使用UMG

发布时间 2023-04-09 22:26:32作者: 仇白

斯坦福 UE4 C++ ActionRoguelike游戏实例教程 07.在C++中使用UMG

斯坦福课程 UE4 C++ ActionRoguelike游戏实例教程 0.绪论

概述

本篇文章的目标是创建一个基于C++的UMG类,并以这个类作为子类,为攻击到的敌对小兵添加一个血条UI。

最终效果如下:

目录

  1. 认识UMG
  2. 创建小兵血条

UMG

UE中的UMG(Unreal Motion Graphics)是一种用于创建用户界面(UI)和HUD(头部显示)的工具。UMG提供了一个可视化的编辑器,允许开发人员轻松创建复杂的UI和HUD,而无需编写代码。正如我们常见到的血条UI、操作提示、各种游戏内积分提示都是可以使用UMG实现的。

image-20230321205552910

图里圈出来的UI元素都是UMG的功劳

UMG包括一系列可自定义的预制件,如按钮、文本框、滑块、进度条等等。这些预制件可以通过拖放操作在编辑器中创建,然后进行定位、缩放和旋转等操作以满足特定的UI需求。此外,UMG还提供了脚本编写接口,使得开发人员可以通过编写脚本来实现更加复杂的UI逻辑。

UMG编辑界面就不详细展示了,这里简单提一下UMG的使用。

image-20230321205227549

创建UMG的的通常方法

创建完成后,可以在任意事件蓝图里使用如下方式创建控件,并添加到视口。

image-20230321205334617

使用UMG的的通常方法

今天还要介绍一种用c++创建和编辑UMG的方法,当然,最方便的还是直接使用UMG提供的编辑器来操作,我们用C++通常只是创建一个基类用来定义UMG蓝图的接口。具体怎么使用UMG,让我们边做边说。

创建小兵血条

编辑C++代码

UserWidget可以说是各种用户UI控件的基类,后续使用的蓝图类也是继承于该类。我们将他命名为SurWorldUserWidget,之后这个类还会作为其他控件的基类,大家在设计的时候可以好好考虑这个类里应该实现什么功能。

image-20230321165323441

右键编辑器,创建一个UserWidget子类

创建完成后,UE会自动在uproject文件里自动添加编译"UMG"模块,出于一致性,记得在Build.cs文件里添加"UMG"。

下面我贴出了新创建的类的所有代码。先看看头文件,里面定义了一些成员:

  1. 首先定义了一个SizeBox类型的成员。SizeBox允许使用者自定义其大小,并限定其内容的尺寸。注意这里使用了meta = (BindWidget)修饰符,和名字一样,这是控件专用的修饰符,可以将指针绑定到UMG编辑器里同名同类型的组件。如果编辑器里没有对应组件,编译甚至还会报错。
  2. 作为小兵的血条控件,就需要绑定一个小兵的对象。这里定义了 AActor* AttachedActor,用意很好理解。值得一提的是,由于小兵和UMG控件是独立的两个对象,当小兵对象被销毁时,传统C++的方法是无法在控件类中得知指针是否被释放的,容易造成空悬指针的问题。好在UE引进了垃圾回收机制,只要加上UPROPERTY宏,就自动为成员变量登记垃圾回收。当指针所指的对象销毁掉后,会自动将指针置为nullptr,比C++11的share_ptr还好用(
  3. 最后就是重载了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文件的内容。

具体来说,这段代码的作用如下:

  1. 首先,代码会调用Super::NativeTick(MyGeometry, InDeltaTime)来调用UserWidget类的NativeTick方法,以确保父类的功能正常工作。
  2. 然后,代码会检查目标Actor是否有效。正如上面所说的,垃圾回收机制解决了空悬指针的问题,但是没有解决空指针的问题。因此这里同样需要注意空指针的问题。这里使用了IsValid函数进行判断指针是否有效,该函数除了判断指针是否为空以外,还判断该对象是否被标记为删除,也就是调用了Destroy之类的函数。如果AttachedActor为空,则调用RemoveFromParent()从父级控件中移除自定义控件,并输出日志信息,以告知开发者该控件被移除。
  3. 接下来,代码会通过UGameplayStatics::ProjectWorldToScreen函数将AttachedActor的三维世界坐标投影到二维屏幕坐标系中,得到屏幕坐标。
  4. 接着,代码会使用UWidgetLayoutLibrary::GetViewportScale函数获取视口缩放比例,然后将屏幕坐标除以该比例,以确保在不同分辨率的屏幕上,控件的位置保持一致。
  5. 最后,如果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

image-20230321173744530

创建子类

进入编辑器,如果点击编译会提示你需要绑定ParentSizeBox,这就刚才Meta修饰符的作用所在了。

image-20230321173940697

添加SizeBox(尺寸框)

将页面的布局修改成如图:

image-20230321175233464

布局

其中,画布画板允许其中的控件自由排布,非常适合我们根据自己的喜好手动布局。这时我们会发现SizeBox已经可以在界面中自由拖动了。将Image材质设置为前几节课制作的HealthBar,并给Image起一个自己看的顺眼的名。这里将ParentSizeBox设置成大小到内容:

image-20230321175403435

在ParentSizeBox中选择大小到内容

该选项会使SizeBox的大小固定为其包含的组件的大小。这里的组件只有一张图片,即这张图片多大,SizeBox就有多大。

修改根据自己的喜好修改Image控件,主要是修改图像大小,由于大小到内容的作用,SizeBox会随着Image的大小变化而变化。

image-20230321175658715

图像大小可在这里设置

为小兵添加控件相关逻辑

代码如下。在.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();
      }
   }
   
   ...
}

效果如下,当我们击中小兵后,小兵身上会出现一个血条。

image-20230321183355921

击中小兵会出现血条

当前的血条位置不太对,我们可以在编辑器里对血条的位置进行调整,图中设置了血条的对其,将其都设置为0.5,这样血条就会在目标坐标的正中心出现。

image-20230321195312002

调整对其

为了抬高血条的高度,在SurWorldUserWidget基类里定义了控件生成的偏移,将该偏移量添加到SurWorldUserWidget.cpp的ProjectWorldToScreen函数调用即可。

//SurWorldUserWidget.h
UPROPERTY(EditAnywhere)
FVector WorldOffset;

将WorldOffset设置为(0,0,100),血条的位置如下(后文会讲解掉血效果)

血条效果

添加掉血效果

正常来说做到这步,我们已经实现了一个悬浮在小兵头上的血条,他会在我们攻击小兵的时候出现。接下来该做掉血效果,该效果表现为血条的红色部分会随着小兵的血量减少而减少。让我们进入MinionHealth_Widget的图表中,从事件构造拉出一条线,为AttachedActor的属性组件绑定我们期望在生命值发生变化时产生的事件。

image-20230321195618521

绑定事件的方法

蓝图修改如下,我们获取了AttachedActor的属性组件,为其绑定了一个OnHealthChanged事件,这样,每当属性组件的血量变化时,都会通过委托机制调用这个事件函数。

在该事件函数中,我们获取了动态材质也就是M_HealthBar,就像之间编辑人物血条一样设置了M_HealthBarProgressAlpha变量,关于M_HealthBar是什么东西,既然都看到这里了,应该都挺熟悉前面的课程。请允许笔者偷个懒,不展开描述了。具体参考之前创建人物血条的课程Lecture 7。

image-20230321201554354

最后的效果如下。我们发现当第一次攻击小兵时,他出现的血条是满血的状态,也就是出现了BUG。

即使攻击到了小兵,小兵一开始血条也是满的

检查代码逻辑发现,这是程序执行顺序导致的BUG。当我们攻击到小兵时,我们还未创建控件,还未将控件的OnhealthChanged事件绑定到属性组件中,因此本次血量变化的委托之中并没有控件的OnhealthChanged事件,所以血条就不会发生变化。

解决这个BUG的办法很简单,既然构造控件和掉血是同时发生的,那我们在创建控件后手动更新一下血条即可。具体做法就是在控件构造时主动调用一次OnhealthChanged事件。当然,你也可以自己定义一段逻辑来更新材质,道理都是一样的。

image-20230321202240951

添加了一次事件调用

当小兵血条归零后,我们可以直接删除血条。这里也是添加了一小段逻辑,将其从父项中移除。在没有父控件的情况下,这里的父项指的就是玩家的视口。至于有父控件的情况会在后面的文章提到。

image-20230321202259043

血条归零时移除控件

最终效果&总结

本篇文章我们在C++中创建了一个UMG组件的基类,结合蓝图创建了一个在小兵头上显示的血条。在之后的课程中,我们还将学习如何将这些一个一个的统合到一个总控件上。

参考链接

UE4 UMG的简单使用 https://zhuanlan.zhihu.com/p/461626363