该专栏用于保存对TomLooman的ActionRoguelike项目的学习笔记,学习过程中的思考与记录不一定准确。
教程参考:/tomlooman/ActionRoguelike
(资料图片)
基于的项目实现:/CarolBaggins2023/TomLooman_ActionRoguelike_Tutorial
2023_08_13
带有C++和更多框架扩展的UMG:bot的血条,更多Heads-up Display(HUD),玩家角色的Spawn,控制台命令
之前我们在蓝图中实现了角色的血条显示,现在我们尝试结合C++中实现bot受击后,在bot身边显示血条的功能。
因为这类附着在某个对象上的UI,在很多类似的地方可以复用,比如可以用在宝箱或者血包上,所以我们对这种Widget创建一个基类,继承自UserWidget。UserWidget是一个可以被我们扩展的Widget类。
我们创建的继承自UserWidger的Widget类如下,
bot受击产生UI后,这个UI应该一直跟着bot移动,所以我们需要覆盖Tick函数,在其中确定UI的位置。在Widget中,承担Tick功能的是NativeTick函数。
因为Widget的Tick函数不只确定UI的位置,还要执行其它操作,所以要先调用父类函数。
我们知道UI在世界中的位置就是bot的Location,但是不知道世界中的位置与屏幕上的位置的转换。这时就要调用UGameplayStatics::ProjectWorldToScreen函数确定UI在屏幕上的位置,其函数原型是bool UGameplayStatics::ProjectWorldToScreen(APlayerController const* Player, const FVector& WorldPosition, FVector2D& ScreenPosition, bool bPlayerViewportRelative)需要我们传入玩家的Controller和UI在世界中的位置,并用一个二维向量的变量接收结果(UI在屏幕上的位置)。这里的AttachedActor是该类的成员变量,该变量在生成widget时被赋值。WorldOffset也是该类的成员变量,用来微调UI的位置。
但是ProjectWorldToScreen并不考虑UMG的DPI,所以这样可能会让最终生成位置产生偏移。所以我们要根据显示窗口的DPI对UI显示在屏幕上的位置进行调整。通过UWidgetLayoutLibrary::GetViewportScale可以获得目前的DPI。
目前我们只确定了UI的位置,还要实际地渲染UI,所以我们调用SetRenderTranslation并传入上面获得的位置。然而我们原本并没有一个实际地Widget来进行显示,所以我们创建了一个SizeBox类型的成员变量,这个Widget之后会作为实际UI的父组件。
注意,这里的说明符meta = (BindWidget)表示这个成员变量会在Widget的设计图中被绑定,绑定的对象是设计图中与它类型和名称相同的那个组件。
创建了上面的Widget基类后,我们可以在编辑器中创建它的蓝图子类,用来表示bot的血条。
因为我们在这个基类中有一个需要在Widget设计图中绑定的SizeBox成员变量,所以刚创建完蓝图类后会出现报错,提示SizeBox成员变量未绑定,此时我们就要创建一个SizeBox,并使它的名字与那个成员变量相同。
然后和玩家角色血条类似,我们用一个image表示血条,并使用之前用过的血条材料(该材料中的ProgressAlpha参数控制血条变化)。
现在bot受击时还没有真正地生成这个Widget,所以我们要对bot的OnHealthChanged成员函数进行补充。
我们不想重复生成UI,所以在bot类中声明了一个之前定义的Widget基类的成员变量。
在OnHealthChanged中,我们先判断是否已有UI。之后的逻辑和蓝图很像,用CreateWidget创建一个Widget,创建成功后AddToViewport。
注意在AddToViewport“之前“,我们要对Widget的AttachedActor进行赋值,因为AddToViewport会调用蓝图中的EventConstruct,而我们在EventConstruct的执行流中需要访问bot的属性组件成员,所以必须要知道这个Widget到底Attach在哪个bot上。
CreateWidget第一个参数要求传入OwnerT* OwningObject,这里我们选择最简单的GetWorld()(没弄明白)。第二参数要求传入生成的Widget的类别,类似于玩家角色射出的子弹的类别,这里我们将这个类别作为bot类的成员变量,并在编辑器中进行赋值。
现在我们攻击bot后已经会正确地在bot上显示血条UI了,但是有两个问题,(1)bot死亡被销毁后崩溃,(2)血条不会变化。下面依次解决。
bot死亡被销毁后崩溃的原因是,我们现在Widget的NativeTick中执行了AttachedActor->GetActorLocation(),但并没有检查AttachedActor是否为空,所以一旦bot死亡被销毁,AttachedActor就变成了空指针,再在它上面调用函数就会导致报错。为了解决这个问题,我们在NativeTick中,先对AttachedActor进行空指针判断,如果为空,则将Widget从父组件中移除,相当于删除Widget。
血条不会变化是因为我们还没有对血条材料中的ProgressAlpha进行赋值,而我们要先思考在哪里修改ProgressAlpha。因为血条形态的改变应该在bot属性组件中的Health发生改变时,所以就离不开属性组件类中的委托。所以我们应该将血条材料中的某个函数与拥有该血条的bot的属性组件类中的委托绑定,当委托广播时执行该函数,修改ProgressAlpha。
绑定过程如下,我们通过自定义的Widget基类中的AttachActor获得拥有该血条的bot,并获得该bot的属性组件从,再接着完成自定义函数与委托的绑定。这里我们还在绑定后直接调用了委托,因为bot受伤这一事件发生在绑定之前,所以血条材料无法接受到bot的第一次委托广播,也就是说如果不在绑定后立即执行一次的话,bot第一次受击就不会引起血条变化。
与委托绑定的事件与玩家角色的血条类似,不同的是,当bot血量小于等于0,也就是死亡时,我们延迟1s删除血条UI。
我们玩家角色的血条、瞄准点等UI都是在蓝图中通过CreateWidget创建的,如果想这样一个个创建,当Widget很多时会冗杂。所以我们想要将这些Widget集成到一个Widget中。
我们创建一个Widget将玩家角色的UI都放进去。我们还新建了两个Text,一个表示玩家积分(现在还没实现),一个表示游戏时间(后面讲)。
在这些原本单独的UI放进一个大的Widget中时,我们要对它们进行一些修改,主要的修改是去除原本的CanvasPanel,可以看到现在血条UI的外面已经没有表示显示器区域的虚线方框了。另外此时UI的显示方式我们可以选择DesiredOnScreen,而不是默认的FullScreen,注意这只影响我们看到的,不影响UI实际放入大Widget后的样子。
在大Widget中,我们将血条和积分竖直排列,这里不要手动对齐,而是要用VerticalBox,也就是它们外层显示的虚线框。另外VerticalBox应设置为SizeToContent,这样它能随里面Widget的大小调整自己的大小,而里面小Widget的大小可能通过它们自己的设计图进行修改,大Widget中的引用也会相应修改。
关于每个UI在屏幕上的位置,我们还可以通过UI的锚点(Anchor)与支点(Pivot)进行对齐(表述不准确,直接看例子)。例如下面的例子,我们想把游戏时间放在右上角,所以我们将UI的锚点(辐射状的图表)改到右上角,PositionX和PositionY是UI的锚点与支点之间的偏移,图中表示支点在锚点的X轴向-50、Y轴向50,Alignment表示选择哪个点作为UI的支点,候选点就是UI周围方框上的八个小白点,左上是(0,0),右下是(1,1),上方中点是(,0)。
在创建完这样一个包含其他Widget的大Widget后,我们在玩家角色的蓝图中就不需要一个个地Create小Widget了,直接Create一个大Widgtet就可以了。
补充一下游戏时间UI的实现方法。
容易想到的是我们创建一个Text组件,然后将该Text与一个函数绑定。因为这个UI是显示游戏时间的,时刻在改变,而不像攻击伤害那样,受伤了才出现,所以可以不使用那种自定义事件的方法。
绑定的函数如下所示,之前我们也涉及过多次获取游戏时间,例如受击发光材料。我们可以通过UGameplayStatics::GetTimeSeconds获得游戏时间,但是这个游戏时间是玩家本地的。当涉及到多人游戏时,这里会发生错误,比如游戏已经开始了10分钟,但是你刚加入游戏,此时如果你用UGameplayStatics::GetTimeSeconds获取游戏时间,返回时间为0,因为游戏刚开始在玩家本地运行。但实际上我们希望获取的是游戏真正运行的时间,这时我们可以使用GetServerWorldTimeSeconds,来获取服务器上的游戏运行时间,而这个函数要从GameState中获得。
另外,我们在游戏运行过程中的任何地方都不应该使用FString,因为FString不会进行本地化,可能会出错,所以这里把FString转成了FText。
我们在最开始的时候通过把玩家角色和bot的蓝图子类拖拽到视口面板中,在世界中生成了玩家角色和bot。现在我们已经在自定义的GameMode中实现了bot的自动生成,所以视口面板中的bot可以删除。那我们的玩家角色删除后会怎么样?我们会进入自由移动的上帝视角,没有可以控制的角色。
我们可以在GameMode中设置游戏开始时玩家控制的Pawn的类型,
当我们在WorldSetting中用自定义的GameMode覆盖了默认的GameMode后,我们在游戏开始后就可以控制一个PlayerCharacter类的Pawn。
但是现在开始游戏后我们依然没有控制一个角色,因为角色不知道生成在哪里。这种情况下,我们可以在世界中放置一个PlayerStart,它将作为玩家角色的生成点。如果有多个PlayerStart,将会随机挑选一个点生成玩家角色。
在游戏中,按下波浪号键可以呼出控制台,我们可以调用控制台中已有的一些函数,也可以自定义控制台命令。我们下面自定义两个控制台命令,玩家回血和杀死所有bot。
我们在ASCharacter中声明并实现玩家回血函数,函数的实现很好理解,重点在于函数宏中的说明符Exec,这表示我们可以在控制台中调用该函数。
另外,这里我们第一次用了函数形参的默认值,UE中的函数形参默认值和C++规则相同。
我们在GameMode中实现杀死所有bot函数,同样要使用说明符Exec。这里我们在属性组件类中添加了Kill函数,其实就相当于受到满生命值的伤害。
我们可以直接在游戏中呼出控制台然后调用这些函数。
UE的CheatManager自带一些控制台函数,例如void UCheatManager::God()
能将Pawn的CanBeDamaged设为false,也就是不受伤害(UE的Character自带一些类似游戏中常见的属性)。
但是调用这个函数并不会使我们的玩家角色不受伤害,因为我们的血量来自于我们自定义的属性组件类,UE不知道。所以为了实现类似的效果,我们在属性组件类的OnHealthChanged中增加一个功能,当Pawn的CanBeDamaged为false时,不继续修改血量成员变量。
最后要注意,说明符Exec能让类的成员函数在控制台中调用,但仅限于以下的类:(1)PlayerController(2)玩家控制的Character(3)GameMode(4)CheatManager这种已经有控制台命令的类。
标签: