游戏引擎应用-Unreal Engine的UnLua插件与UMG&CommonUI
0. 前言其实刚做完毕设的时候还有一篇分析Shadow的,可惜突然看了好久社会学的书,后面就入职了,就莫得时间做了(一定会补全的),现在先把当下的事情处理好。UnLua上班要用,安装很简单,里面有少许教程,将Plugins的文件移到项目下面就可以。
项目地址如下,如果写的不清楚可以看看代码,还是写了挺多注释的TAT,比较重要的是第一章与第五章,特别是5.2.4,如果是Unreal大手子请忽视。
注:文中可能有还没有完全理解的地方,能够提出修改意见更好
1. UMG UI设计器快速入门
这个部分主要参考Unreal官方文档中的教程,将蓝图部分尽可能转化为UnLua的方式去做,提供一个UnLua的使用参考,要看本文需要同时浏览文档。
1.1 必要的项目设置
1.1.1 修改BP_FirstPersonCharacter蓝图
前面的步骤都很简单,到修改BP_FirstPersonCharacter蓝图的第七步是一个需要我们进行蓝图操作。首先,按照UnLua官方给的方法,我们需要为这个蓝图创建一个Lua脚本,具体可以参考UnLua给的一个很详细的教程。简单点说就是在蓝图界面点UnLua进行绑定,绑定的时候会自动创建一个Get Module Name的接口,我们需要在Return Value的位置输入我们的脚本名,aaa.bbb表示Lua脚本将创建为Content/Script/aaa/bbb.lua,在UnLua界面点击创建Lua模板文件就配置好了。(任意一步UnLua按钮没有期望的按键时,可以考虑点击蓝图的编译)
接下来我们用VSCode打开项目,并且安装Lua Booster方便使用,配置完毕之后,可以着手看这个第七步了,第七步首先是添加Event Begin Play节点,在新创建的Lua文件中,我们看到他模板就是有一个YourLuaFileName:ReceiveBeginPlay()的函数,至于为什么是会多一个Receive,UnLua官方也有解释,你也可以在Visual Studio中自己搜索一下查看。
然后我们看后面的操作,添加一个Create Widget节点,实例化一个HUD Class,然后拉一个Set节点,用作变量保留,再用Add To Viewport输送到视口,这一系列的步骤,在UnLua文档中示例11提供了一个解决方案,同时如果要用中间变量,设置为全局就可以方便访问了。当然,AddToViewport()原本是有一个ZOrder的参数,应该是UUwidget排序的方法。
特别注意!!!!!这种写法存在问题
特别注意:这个方法虽然可以在较多情况下正常运行,但是仍然推荐你查看5.2.4手柄路由调试部分,因为这个部分涉及到这个做法为什么有问题以及解决方案!!!!!!!!
1.1.2 调整BP_Rifle蓝图
接下来是调整BP_Rifle蓝图,由于我是创建的C++工程,所以和教程存在少许差异,但是不影响我们用Lua实现他的功能,由于我本身是Unreal Engine小白,下面的部分可能会稍微记录一下Unreal Engine具体内部是怎么响应的以及我的探索过程。
第一步官方文档需要我们找到BP_Rifle的蓝图,然后第二步找到On Component Begin Overlap 节点开始修改,大致上的意思即使没学过Unreal应该也能看懂,这里要实现的就是在人物与枪械发生碰撞时,认为人装备枪械,并且给UI加上子弹的显示。
那么我们用C++工程中BP_Rifle是什么样子的呢?如下所示,这个时我已经绑定过UnLua的了,所以和原生的文件在Interfaces部分多了一个Get Module Name ,可以发现我们无法找到蓝图中说明的On Component Begin Overlap 节点,但是通过上面的逻辑可以判断出,这个On Pick Up节点肯定与之有关
在这里可以解释一下,左上角展现了文件结构,工程原生已经将TP_PickUpComponent.h定义的组件绑定到了BP_Rifle上面,所以我们可以直接调用到On Pick Up事件;如果不绑定的话我们就无法获得一个OnPickUp事件的能力,因为BP_Rifle类中没有自定义事件的定义。
在C++文件中查找OnPickUp方法,发现在TP_PickUpComponent.h文件中存在这个变量,首先这个成员变量的类型是FOnPickUp,在上面可以看见这个类型是一个委托(Delegate)类型,至于什么是委托类型,我也是这几天才在Unreal Document上面看见了,如果不了解可以查阅一下,目前粗浅的理解这个是一个类似事件触发器的玩意,当然这个理解肯定不全面,但至少可以对Delegate有个基本的认识。还可以看见UTP_PickUpComponent是继承于UShpereComponent,并且内部有BeginPlay和OnSphereBeginOverlap两个函数,十分类似教程中事件触发器部件的名字。
TP_PickUpComponent.h
再进入TP_PickUpComponent.cpp寻找触发逻辑,发现BeginPlay的时候,将函数OnSphereBeginOverlap与委托OnComponentBeginOverlap绑定起来,并且在下面的函数中,调用了OnPickUp进行广播,说明这里是触发PickUp给全局的地方,也就是执行完毕这个函数,BP_Rifle中与On Pick Up关联的一系列事件开始执行。
TP_PickUpComponent.cpp
其实现在还有一个问题是,OnComponentBeginOverlap从哪里来的?肯定是其父类,在VS中搜索OnComponentBeginOverlap可以找到PrimitiveComponent.h展示两部分代码和注释就能理解整体的逻辑了,这个类是很多图元组件类的基类,示例项目中,检测碰撞是一个球,其逻辑就是UTP_PickUpComponent->USphereComponent->UShapeComponent->UPrimitiveComponent
至此,我们能够理解C++部分的委托逻辑,那么在Lua端我们就很容易操作了,我们给BP_Rifle蓝图绑定一个Lua文件,并且在BeginPlay的时候绑定OnPickUp委托,并且测试了是不是这个类原生就有OnClicked委托,结果显示如下,至于怎么实现教程需求,和1.1.1一摸一样。同样我们还可以将蓝图中的链接枪械与人物的逻辑拉过来,这个可选项就给大火自己实现吧(提示,在蓝图中,存在Target is ***的组件大多都可以右键然后点击Goto Definition来实现原代码查阅)
BP_Rifle绑定的Lua
输出结果,第一行到第三行是Lua中ReceiveBeginPlay输出,发现委托类型是可以查出来的,在蓝图触发和Lua触发后面都加了一个输出,发现蓝图要快一些,可能是因为蓝图绑定就是在C++中的,整体逻辑没有问题
1.1.3 调整BP_FirstPersonCharacter蓝图中的角色变量
这个部分其实不是很难,对按键的监听是很容易做到的,并且这一部分的监听在C++文件中也是存在的,但是唯一让我百思不得其解的是,LearningProjectCharacter.cpp中,明明将Action “Jump”绑定到了ACharacter::Jump,查询这个函数的行为,发现就只有简单的两句赋值,那系统原生的ACharacter类是怎么做到能够判断Jump在空中不能按Jump呢?这个问题看似很奇怪,但是对我们接下来要做的事非常重要,我们要对跳跃进行监听,而我们并不能监听已经起跳时按下的跳跃键,所以我们需要监听到Jump成功判断之后的事件。
UE_5.0\Engine\Source\Runtime\Engine\Private\Character.cpp
经过很长时间的源码查阅,我检测不到与Jump有关的任何后续的功能联系,其中bPressedJump应该是一个突破口,并且在下面就有一个与跳跃有关的判断函数ACharacter::CheckJumpInput,其中bPressedJump 成为了其中一个判断条件,那么DoJump应该就是真正的Jump执行,可以跳转到DoJump的声明。
DoJump声明,其中表示了检测机制,同时表达了,监听Character::Jump()是官方推荐的
其实在ACharacter中可以看见很多与Jump有关的变量,比如说跳跃长按、跳跃次数等等可以Custom跳跃的方法,而我们目前即使知道了如何利用原生的ACharacter,仍然没有解决我们的问题,CheckJumpInput(...)的使用,还是很难查找。写了很久的脚本语言之后,甚至都忘了C++可以加断点看堆栈!不过我是在启动器里面下载的Unreal Engine 5,所以需要去启动器界面额外下载一个输入调试用符号。
作为Check函数,我对CheckJumpInput打了断点(上上上图),查看调用栈,果然这个Check函数被注册成为了一个Tick触发函数,所以Action “Jump”的真正作用是给系统激活一个脉冲信号,而系统会每个Tick来检查这个脉冲信号在不在,能不能用?如果弄过电子类的东西(时序分频器或者键盘鼠标信号处理),这种Buffer的方法应该是很常见的,Buffer的设计理念更加适合于系统,虽然会慢一点,优势在于各部分很容易解耦!
调用栈可以看见Tick触发
所以了解了这个机制之后,应该怎么调用到真正的Jump呢?厚礼蟹,经过哥们将蓝图复现之后再输出进行测试,原来这个B教程也没管这个问题,这个Jump相应就是注册按下空格就可以减0.25。。。刚才我们得到的知识不会骗我们,我们可以在Lua中重新绑定函数,Action “Jump”的响应函数,重新绑定之后我们把状态设置好(注意,绑定Action在原生中默认是非组播委托,所以绑定只能生效一个,然而Lua的执行会比C++要慢,所以Lua这边的注册可以覆盖掉C++部分,最好还是只留一边的委托,防止意外情况发生,具体可以通过LearningProjectCharacter.cpp中的BindAction查看,点击会跳转到InputComponent.h),并且用Jump的数量进行Check,这样在不极限的情况下(同一个Tick内多次点击)可以满足我们的需要,同理这个章节后面的操作就简单多了。
1.1.4 调整BP_Rifle蓝图中的角色变量
上面的尝试渐渐地可以看出在架构设计的时候,Lua和C++的逻辑应该是什么样子的,到这一步我们把逻辑应用上去就可以了。很简单的逻辑,开火的输入是Action “PrimaryAction”(定义同之前Jump一样,具体可以查阅VS中的代码),开火的逻辑响应在TP_WeaponComponent定义的Fire()中,所以我们需要查找的就是,这两者是怎么绑定起来的,知道基本逻辑之后,对Unreal的熟悉程度就特别重要了。(FOnUseItem)
其实这个部分直接写在C++里面要合理一点,因为Check这件事情分离就属实有点异味了;但是如果硬要将这个部分写到Lua上去,我们光知道这个启动Fire()的逻辑是不够的,因为即使我们在Lua端Check了Ammo,这个部分怎么也不会影响到C++端,所以我们是不是可以做个Lua方的额外Check约束C++这部分。
按照UnLua示例C++端是可以直接调用Lua函数的,注意这里的调用更像是调用类的静态函数一样,是没有实例化也就是没有self的,通过定义函数用.还是用:可以看出来(坑了我好久),然后C++端可以按照教程示例的C++代码写,不过唯一有点问题的就是,为了适配热重载,似乎所有的功能都是实时需要调用FLuaEnv,这点在我自己尝试在BeginPlay中提前提取出来FunctionTable的时候感受到了。
在BeginPlay处尝试的时候遇见了一些问题,首先在TP_WeaponComponent.h中加入BeginPlay()的声明,可以参考PickUp怎么写的,然后在cpp文件中加入定义,特别注意,很多函数是需要先执行父类函数的,比如说BeginPlay()就需要调用Super::BeginPlay();其次,好像没法保存UnLua::FLuaTable的临时变量,就算保存下来了,还是会在后面使用的时候失效,虽然心在滴血,可还是把这么一坨臃肿的代码放到了Fire中。
C++调用Lua函数
定义的Lua函数
其实做到这里会发现架构里面还是有点问题,Ammo应该是Weapon的属性,而不是Character的属性,枪为了拿到子弹数据还需要去Character身上拿,实在不合理。而且这个部分要修改应该就要去C++中修改,因为Rifle的蓝图包括了碰撞盒等与枪械无关的属性,并且人物可以拿到枪械信息,而不是枪械拿到人物信息,示例程序为什么会有这样的逻辑Bug。最后重构了一下还是觉得,厚礼蟹,直接写在C++里面,Lua不是办这种事的角色。特别注意,如果要C++部分的属性能够在Lua或者蓝图中获取到,需要加上UPROPERTY宏定义。
1.2 显示生命值、能量和弹药
1.2.1&1.2.2 视觉效果:生命值、能量和弹药
这个两部分,如果做过图形界面的同学应该很熟悉(本人之前少许接触过一些Android的图形界面开发),感觉教程文档还是写的挺不错的,可以参考教程文档上面的布置。
1.2.3 脚本:生命值、能量和弹药
这个部分发现之前的命名有问题,重写了一下代码命名,其中
HUD.InGameHUD改为BP_FirstPersonCharacter
HUD.InGameHUD改为BP_Rifle
新增HUD.MyHUD绑定到MyHUD蓝图,HUD.MyHUD_AmmoCount绑定到MyHUD_AmmoCount蓝图
这下就合理多了。
按照教程和之前的解读,这个部分的代码已经简单到异常了,这里唯一比较困难的是如何找到ProgressBar的绑定,点击到ProgressBar的蓝图,可以在右上角进入蓝图类,搜索percent应该就很好理解了。然而,我自信满满的把绑定了Lua的Return函数,结果一点反应也没有。
首先发现了一个很特殊的点就是,这次找不到函数报错和以前不一样,这次会爆一个与__index有关的错误,找了快一个周末也没有查明到底为什么绑定不上,后面用断点调试了一下堆栈,发现UProgressBar下的SProgressBar会每个Tick调用OnPaint函数,在OnPaint的时候会查询需要绘制的变量(这里就是Percent)是否存在Bind,如果有Bind从Bind处获取值(具体调用栈可以在UFloatBinding::GetValue()处设置断点查看),也就是说,这个托管逻辑是Tick触发的,这不是很费么。。。所以虽然没查清楚原因(感觉就是因为UProgressBar的PercentDelegate不是蓝图可视的),我还是把这个逻辑改了。下面给的参考代码是HUD.MyHUD的。
我之前将Weapon链接到了Character上,但是调用了Character拿不到Weapon的数据?实在是搞了我好久,一直以为是Variable Visibility的问题,最后发现这个问题是和载入顺序有关系的!!!!(同样与蓝图可视性也有关)下面是对应的Lua代码和Log输出
可以看见PushObjectCore可以反映出Component在UnLua中加载的时间
我们代表武器Component的类是UTP_WeaponComponent,可以从UnLua的Log中看出,我们TP_Weapon(隶属于BP_Rifle蓝图)的这个模块是在Construct之后加载的,所以我们并不能在Construct的时候获得他的地址(这点和教程中把Ammo也放在Character下的模式就是纯纯的不同),并且这个时候也需要将Character里面定义的Weapon指针给用宏定义。
到这里入门的UMG就完成了。
2. UMG UI 设计器 - 操作指南
这部分讲的都比较浅显,比第一章更加入门。
2.1 创建和显示UI
这个部分里面有个RemoveFromParent()方法需要学一下,绑定了小键盘1实现MyHUD的开关。
2.2 创建控件模板
这个部分可以学习有个悬停提示,感觉还挺好用的。
2.3 UMG Slots
这个和Android的控件也有点相似,至于有没有更深层次的用法还需要注意,更像一个Layout,面向上层框架的Layout,如果合理利用可能可以实现很多基于锚点的自定义功能。
2.4 控件交互组件
这个看起来比较神奇,做起来应该不是很难,实际用处应该在VR中多一点。
3. UMG布局和视觉设计
3.1 锚点
看起来锚点和Android的Constraint Layout有点异曲同工,但是第一章布置在Vertical Box的控件是没法让内部的部件设置锚点的,这个和Android的垂直分布也很类似。
3.2 制作UMG控件动画
这个部分其实还是主要看设计动画的方式,毕竟已经固定给时间轴的动画设计了,结合了之前学习到的与动画有关的绑定,感觉还是可以在这方面设计点东西的。
3.2.1 设计动画并播放
这里就是按照教程,我自己给第一章的工程设计了一个血条震动的动画。用Lua播放动画其实很简单,如果不太清楚可以去蓝图中找到PlayAnimation节点看下Input的Parameters,具体代码可以如下写,至于动画怎么设计,就随自己喜欢了。
3.2.2 一点想法
对于一些游戏,在扣血的时候会有一个Buffer存了一段虚血,这个感觉是一个不错的尝试,但是这需要我们认为ProgressBar能够这样渲染数据,并且这个动画由于存在参数,一定是程序化的。至于怎么实现这个东西,需要去查看ProgressBar是怎么绘制的,之前在1.2.3的时候就存在过对ProgressBar逻辑调用的讨论,当时已经涉及到了OnPaint函数,这次详细看一下,从变量名就比较容易看出来,逻辑就是画一个矩形,所以我们其实可以重写这个函数,然后额外新画一个表示虚血的矩形就可以了。
其中const float ClampedFraction = FMath::Clamp(ProgressFraction.GetValue(), 0.0f, 1.0f);
我在实现的时候没有考虑这种方法,毕竟主要注重Lua的实现,自定义Widget可以之后做一做,感觉目前能用的控件还是太少了,具体我的实现可以参考仓库代码,这里贴一下主要逻辑,调用PlayAnimation可能就有性能上的损失,其实这种动画一般都可以利用Timer程序化实现。(暂时没有考虑渲染虚血框)
local Lerp = UE.UKismetMathLibrary.Lerp
local HealthBuffer = -1
local HealthVirtualBuffer = -1
local HealthVirtualTime = -1
local EnergyBuffer = -1
local AnimationDuration = 0
local AnimationPlayRate = 1.5
local MyCharacter = nil
function MyHUD:Construct()
-- 获取Character
local World = self:GetWorld()
MyCharacter = UE.UGameplayStatics.GetPlayerCharacter(World, 0)
MyCharacter = MyCharacter:Cast(UE.ALearningProjectCharacter)
AnimationDuration = (self.GetDamageAnimation:GetEndTime() - self.GetDamageAnimation:GetStartTime()) / AnimationPlayRate
self:BindToAnimationStarted(self.GetDamageAnimation, {self, self.HealthVirtualSetup})
self:BindToAnimationFinished(self.GetDamageAnimation, {self, self.HealthVirtualFinished})
end
function MyHUD:Tick(MyGeometry, InDeltaTime)
if MyCharacter == nil then
return
end
if HealthVirtualTime ~= -1 then
HealthVirtualTime = HealthVirtualTime + InDeltaTime
end
-- 自动回复
if MyCharacter.Health < 1.0 then
MyCharacter.Health = math.min(1, MyCharacter.Health + InDeltaTime / 60)
end
if MyCharacter.Energy < 1.0 then
MyCharacter.Energy = math.min(1, MyCharacter.Energy + InDeltaTime / 60)
end
-- Health逻辑
if MyCharacter.Health ~= HealthBuffer then
-- 如果生命值降低了,那么就启动伤害动画
if MyCharacter.Health < HealthBuffer then
self:PlayAnimation(self.GetDamageAnimation, 0, 1, UE.EUMGSequencePlayMode.Forward, AnimationPlayRate, false)
end
HealthBuffer = MyCharacter.Health
-- 如果仍然在掉血动画
if HealthVirtualTime < AnimationDuration and HealthVirtualTime ~= -1 then
self.HealthProgressBar:SetPercent(Lerp(HealthVirtualBuffer, HealthBuffer, HealthVirtualTime / AnimationDuration))
else
self.HealthProgressBar:SetPercent(HealthBuffer)
end
else
HealthVirtualBuffer = HealthBuffer
self.HealthProgressBar:SetPercent(HealthBuffer)
end
-- Energy逻辑
if MyCharacter.Energy ~= EnergyBuffer then
EnergyBuffer = MyCharacter.Energy
self.EnergyProgressBar:SetPercent(MyCharacter.Energy)
end
end3.3&3.4&3.5 裁剪&工作区&UMG样式
感觉UMG样式里面的圆形ProgressBar有点好玩,感觉用蒙版和极坐标可以实现圆形绘制,就是实现起来不知道是C++好还是蓝图好。
4.控件类型参考
4.1 UMG富文本块
这个主要把文件调好就行,用起来比较容易,后面扩展要用的时候再说吧。
4.2 模糊框架
很好用,很好玩。
4.3&4.4&4.5&4.6 杂项
感觉到时候用到的话再仔细探究吧,除了无效化方框看起来有点用以外,其他的有点像玩具。
4.7 动画性能测试
测试了下50个Button其中10个在Animation的情况下,基本上没什么卡顿,当然后面还需要在Android设备上测试一下,突然感觉Android性能测试好难。
5. Common UI
感觉这是一个非常复杂的组件,这里鉴于Document也讲的稀里糊涂的,所以我去Epic社区找到了一个视频,主要是依据这个视频学习。
5.1 视频部分要点
这里部分UnLua的坑,第一个是SetShowMouseCursor,Lua调用的时候是不能取到SetShowMouseCursor函数的,并且源码中这个函数也是不开放给蓝图的,不知道为什么蓝图可以调用,通过查阅源码可以发现,Lua能够直接取到controller.bShowMouseCursor ,修改这个值就可以达到同样的效果了。
之前可以看见蓝图里面经常有Cast然后Variable的操作,其实这个Variable在UnLua中正确的表现应该是self.***=,而不是在外面打一个local标记,这样别人在调用类的时候就可以获得在UnLua中定义的新变量,十分方便。
GetDesiredFocusTarget应该是给手柄打开菜单时默认的位置。
CommonActivatableWidget顶层的Activate Visibility与Deactivate Visibility属性很重要,需要正确配置。
Stack的PushWidget函数实际上是BP_AddWidget,Stack很方便,但总的来说没有上面的灵活,他必须只显示栈顶肯定是一个不好的设计,把栈遍历一遍也不是什么难事,在CommonActivatableWidgetContainer.h中绑定了一个SCommonAnimatedSwitcher,而且这个Switcher只支持提供的模式,并且变换完毕之后一定消失,这几个部件绑定在一起变得非常不灵活,但是在特定需求情况下可能异常好用。
5.2 我的实现过程
我感觉这部分还是稍许困难的,而且视频是在油管上不一定所有人都能看到,我根据自己的总结将教程中的教学结合了一下,下面是我提供的操作方法。(该部分项目基于上面的部分开发)
5.2.1 CommonUI配置
首先如果要用CommonUI需要下载官方的插件,Plugins->Common UI Plugin
并且调整项目设置,Project Settings->Gerneral Settings->Game Viewport Client Class
重启项目。
5.2.2 必要布置
我们需要构建几个蓝图类,右键Content Drawer,选择蓝图,并且选择继承于CommonActivatableWidget,这个可以让我们的Widget能够被Activate与Deactivate,CommonUI会自动根据是否被激活让控件自动转化自己的控制属性。
建立蓝图类之后,按照以下的结构创建几个蓝图Widget,其中Display是Border,设置了一个基础的背景颜色,并且改为变量。
暂停菜单
暂停菜单呼起玩法
玩法呼起玩法详情
并且设置每个顶层的Activate与Deactivate Visibility设置如下(当然你可以现在不设置,然后查看一下效果是什么,这样设置可以保证我们后面的正常响应)
同时给每个蓝图类创建Lua文件。(点击UnLua的Bind->输入GetModuleName数据->Compile->点击UnLuaCreateLuaTemplate)
5.2.3 逻辑配置
首先我们需要配置每个菜单的响应事件,为每个菜单蓝图设置四个事件,一组是ActivateDelegate、DeactivateDelegate,一组是AddToViewportDelegate、RemoveFromParentDelegate,这样保证所有对于该菜单的操作都会在菜单的Lua文件中执行。
申请四个委托,当然这是一个很简短的Demo,你也可以选择不使用Delegate,混乱地调用函数
-- 这是每一个
-- 通过观察生命周期,调用Initialize会导致程序报错,通过查看调用栈似乎是因为这个时候Delegate还没有初始化
-- 如果在Construct里面注册会出现逻辑错误,因为Construct是靠AddToViewport触发的,具体Widget的生命周期可以参考
-- https://blog.csdn.net/yekong1225/article/details/121414140
function PauseMenu:Setup(ParentMenu)
-- 教程中将所有的Menu都作为成员变量放在了Character下面,个人认为那样的设计不妥
self.ParentMenu = ParentMenu
self.DisplayDelegate:Add(self, self.ReceiveDisplayDelegate)
end
function PauseMenu:Construct()
local World = self:GetWorld()
self.Character = UE.UGameplayStatics.GetPlayerCharacter(World, 0)
-- 注册响应函数,这些应该都是组播
self.Button_Resume.OnClicked:Add(self, self.OnClickResume)
self.Button_HowToPlay.OnClicked:Add(self, self.OnClickHowToPlay)
-- 非AddToViewport函数注册
self.UndisplayDelegate:Add(self, self.ReceiveUndisplayDelegate)
self.ActivateDelegate:Add(self, self.ReceiveActivateDelegate)
self.DeactivateDelegate:Add(self, self.ReceiveDeactivateDelegate)
end
-- 注册与注销,是个好习惯
function PauseMenu:Destruct()
self.Button_Resume.OnClicked:Remove(self, self.OnClickResume)
self.Button_HowToPlay.OnClicked:Remove(self, self.OnClickHowToPlay)
self.DisplayDelegate:Remove(self, self.ReceiveDisplayDelegate)
self.UndisplayDelegate:Remove(self, self.ReceiveUndisplayDelegate)
self.ActivateDelegate:Remove(self, self.ReceiveActivateDelegate)
self.DeactivateDelegate:Remove(self, self.ReceiveDeactivateDelegate)
end
-- 执行函数,其中self.Display是UMG蓝图中的Border
-- 这个操作可以直接用self代替,这里用self.Display从MyPauseMenu的文件结构看是为了避免修改Blur的程度
function PauseMenu:FActivate()
self:ActivateWidget() -- CommonUI激活!
self.Display:SetRenderOpacity(1) -- 设置渲染透明度为不透明
end
function PauseMenu:FDeactivate()
self:DeactivateWidget() -- CommonUI灭活!
self.Display:SetRenderOpacity(0.3) -- 设置渲染透明度为稍微透明,展示区别
end
function PauseMenu:FDisplay()
self:AddToViewport() -- 加入视口
end
function PauseMenu:FUndisplay()
self:RemoveFromParent() -- 从视口移除
end
-- Delegate响应函数
function PauseMenu:ReceiveActivateDelegate()
self:FActivate()
end
function PauseMenu:ReceiveDeactivateDelegate()
self:FDeactivate()
end
function PauseMenu:ReceiveDisplayDelegate()
self:FDisplay()
end
function PauseMenu:ReceiveUndisplayDelegate()
self:FUndisplay()
end上面的基本框架应该可以作为一个基类了,这可以满足绝大部分的需求,上面我们设计的都是响应,接下来我们要设计触发逻辑。
首先,在Character中加载我们的PauseMenu,最高级Menu
-- 当然第一级别的Menu也可以在触发的时候动态绑定(最终做法),这样就可以开关多次
function BP_FirstPersonCharacter:ReceiveBeginPlay()
...
local CommonUIPauseMenuClass = UE.UClass.Load(&#34;/Game/MyContent/CommonUI/Menu/MyPauseMenu&#34;)
self.CommonUIPauseMenu = NewObject(CommonUIPauseMenuClass, self)
self.CommonUIPauseMenu:Setup(nil)
...
end同时写一个我们自定义呼出PauseMenu的方法,然后设置用M键可以触发这个逻辑
function BP_FirstPersonCharacter:DealWithPauseCustom()
self.MenuMethod = &#34;Custom&#34; -- 教程中是CommonUI Container的方法,所以定义我们自己的方法为Custom
self.CommonUIPauseMenu.DisplayDelegate:Broadcast() -- 加入视口
self.CommonUIPauseMenu.ActivateDelegate:Broadcast() -- CommonUI激活!
local World = self:GetWorld()
local Controller = UE.UGameplayStatics.GetPlayerController(World, 0)
UE.UWidgetBlueprintLibrary.SetInputMode_UIOnlyEx(Controller) -- 设置只能控制UI,现在移动鼠标就不会有视角改变
Controller.bShowMouseCursor = true -- 对应蓝图中的Set Show Mouse Cursor
end
function BP_FirstPersonCharacter:M_Pressed()
self:DealWithPauseCustom()
end
在PauseMenu中,我们点击玩法应该能够更进一步到玩法的界面,并且点击继续能够返回回去(这部分逻辑具体看仓库吧,与前面重复了)
function PauseMenu:OnClickHowToPlay() -- 函数配置在前面
if self.Character.MenuMethod == &#34;Stack&#34; then
local CommonUIHowToPlayClass = UE.UClass.Load(&#34;/Game/MyContent/CommonUI/Menu/MyHowToPlay&#34;)
self.Character.CommonUIMenuContainer.MyMenuStack:BP_AddWidget(CommonUIHowToPlayClass)
elseif self.Character.MenuMethod == &#34;Custom&#34; then
-- 动态配置下一级页面
local CommonUIHowToPlayClass = UE.UClass.Load(&#34;/Game/MyContent/CommonUI/Menu/MyHowToPlay&#34;)
self.CommonUIMyHowToPlay = NewObject(CommonUIHowToPlayClass, self)
self.CommonUIMyHowToPlay:Setup(self)
-- 开启下一级页面
self.CommonUIMyHowToPlay.DisplayDelegate:Broadcast()
self.CommonUIMyHowToPlay.ActivateDelegate:Broadcast()
-- 灭活自身
self:FDeactivate()
end
end同样,我们要给MyHowToPlay第二级菜单设置关闭自身响应与开启第三级菜单响应(开启的逻辑与上面相似)
function MyHowToPlay:Open()
... 与上面的触发一模一样
end
function MyHowToPlay:Close()
if self.Character.MenuMethod == &#34;Stack&#34; then
self:DeactivateWidget()
elseif self.Character.MenuMethod == &#34;Custom&#34; then
self:FUndisplay()
self.ParentMenu.ActivateDelegate:Broadcast()
end
end按照这个逻辑设置下去,就可以获得正确的前进与回退了,并且是完美符合我们需要的逻辑的。
5.2.4 手柄路由调试(重要)
做完上面的步骤之后,我发现手柄无论如何也没法调用到GetDesiredFocusTarget(),也就是说,根本没有任何一个组件在乎我的手柄应该Focus到哪里,那这样显然就没法进行手柄的工作,也不符合CommonUI设计的理念。 所以我新建了一个工程去专门Debug这个问题。——如果我做错了,我宁愿要法律来惩罚我,也不要看没有注释的代码。
手柄路由的核心在CommonUIActionRouterBase.h/cpp中(下简称RouterBase),我在之前的工程上已经看了很多这个部分的源代码了,现在调试可以稍微轻松一点,我尽量说明白些。
首先按照官方文档的说法,实现Route的一个关键结构就是自带的一个树结构,这个结构将Widget管理成为Active与Deactive两类,这样我们用手柄选择下一个按钮的时候,这个树结构就只会往Activate那一边搜索,这样就保证了我们需要的逻辑正确性。在RouterBase文件中,有很多与树有关的东西,其中最重要的就是搜索顶点——树根,在我之前的工程中,是没法触发SetActivateRootNode()这个函数的。(反正就是怪,很怪)
//这里给出一些关键的定义
UCLASS()
class COMMONUI_API UCommonUIActionRouterBase : public ULocalPlayerSubsystem {
...
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
...
bool Tick(float DeltaTime);
...
virtual void SetActiveRoot(FActivatableTreeRootPtr NewActiveRoot);
void SetForceResetActiveRoot(bool bInForceResetActiveRoot);
...
}
有兴趣可以直接看下这些实现,不过也不急,在正确可以触发的项目中,我试着拿了一下调用栈,其中我的断点设置在了SetActiveRoot 的第一行,也就是这个函数,我们可以设置树根,如果我们按一个按键触发了Activate,那么按照CommonUI的设计逻辑他应该要被加入进来。
可以发现,我们即将把新加入的CommonActivatableWidget设置为树根,所以这里是生效了的,然而我在之前的工程中,除了在Deinitialize函数中赋null以外,没有任何一个部件能够触发这个函数,那么跟着调用堆栈,我们可以找到CommonUI插件中最顶层的调用位置。
可以看见激发这个部分的顶层是Tick,所以查看Tick部分进入ProcessRebuildWidget的代码并且调试
正确的,左下角变量Num=1
错误的,左下角变量Num=0
估计是这个变量除了问题,查阅一下发现有这样一个函数修改他,并且查了下这个函数Get的是什么
//CommonUIActionRouterBase.cpp
void UCommonUIActionRouterBase::HandleActivatableWidgetRebuilding(UCommonActivatableWidget& RebuildingWidget)
{
if (RebuildingWidget.GetOwningLocalPlayer() == GetLocalPlayerChecked())
{
RebuiltWidgetsPendingNodeAssignment.Add(&RebuildingWidget);
}
}
//UUserWidget.cpp
ULocalPlayer* UUserWidget::GetOwningLocalPlayer() const
{
if (PlayerContext.IsValid())
{
return PlayerContext.GetLocalPlayer();
}
return nullptr;
}
果然,Holy这里没有绑定PlayerContext
正确的,PlayerContext正确绑定
错误的,PlayerContext没东西
PlayerContext已经是UserWidget类的东西了,为什么会在这里出错,再看一下这个是哪里改的,我找了很久,发现应该是UUserWidget::Initialize()最早存在这个调用,结果发现进来这个函数的时候,PlayerContext已经配置好了,并且正确的是配置对的,错误的依旧是什么都没有。查找了下调用栈!!!
右下角调用栈可以看见有execCreate与Create,过去看看就知道了
然后发现了UE.UWidgetBlueprintLibrary.Create,这个函数是Blueprint用来Create Widget的方法,我原本天真的以为教程中的NewObject和这个是对应的,原来当时的想法是完全错误的,不过幸好错误了,我能够梳理整个创建的流程,芜湖。其实UnLua教程中有这部分,但是都在示例工程里面,以后还是要多加仔细。把所有与Widget有关的NewObject都改成如下形式就可以了。
self.CommonUILeft = UE.UWidgetBlueprintLibrary.Create(self, UE.UClass.Load(&#34;/Game/MyContent/CommonUI/Left&#34;))并且特别注意,我们要override的是BP_GetDesiredFocusTarget,这个查查源码一会就知道了。
5.2.5 其他
其次,教程中是使用了CommonUI自带的Container,这个Container分为队列型的和栈型的,他只能显示一个Widget,等于说上面的三级菜单,如果我要显示HotToPlay,那么PauseMenu就会强制用动画进行隐退,这样对于多级要显示的菜单很不方便,但是期间的逻辑更加简单,调用self:DeactivateWidget()就会自动退栈,调用Container的BP_AddWidget(WidgetClass)就会自动生成该类的一个实例化并且入栈,对于新手来说非常友好。 详细的代码以及Container的做法可能更多查阅仓库吧,有点懒得写了,比起这种方法不灵活但简单很多。
6. 杂项 Miscellaneous
6.1 遇到过的问题
Timer结束后,使用Animation会让前面的Timer没有表现
需要注意Render Opacity带来的世界线变动,Animation中美术为了方便查阅,将Render Opacity设置成为了0,这样多个部件放进来就可以表现正常,但是放了所有部件进来合成动画,部分不使用的部件的渲染成为了透明,所以就表现出来不显示,Holy美术点击到Graph界面,左下角就是Animation,感觉还是挺方便的,整体动画配置也是标准的那一套方案,之前摸过一点Pr,有点熟悉。
这个部分需要查阅BindToAnimation部分的代码,一般是Started和Finished,绑定的时候可以看UnLua官方文档的实现,其实在给委托的时候,我们需要按照这种方式给出{self, DelegateFunction},注意这里Lua函数调用分为&#39;.&#39;和&#39;:&#39;。
self:BindToAnimationStarted(self.YourAnimation, {self, DelegateFunction})6.2 心得:
①UI的处理应该分为点击视觉响应以及点击逻辑响应两种方式,因为点击视觉响应只有在点击这个UI的时候生效,但是逻辑响应可以作为委托给不同的函数用,如果逻辑响应和视觉响应绑定到了一个函数,就没办法很容易区分这两者的关系了。同时,即使是视觉相应,也需要留出状态句柄供逻辑相应调度,所以视觉相应按理来说是与逻辑分离的,我们任意时候调节代码应该关注逻辑。
②写法与技巧(千万注意&#39;.&#39;和&#39;:&#39;,&#39;:&#39;会将self作为第一个变量传进去)
获取类名:UE.UKismetSystemLibrary.GetClassDisplayName(self:GetClass())
UMG动画时长:self.GetDamageAnimation:GetEndTime()-self.GetDamageAnimation:GetStartTime()
新建Widget:UE.UWidgetBlueprintLibrary.Create(self, YourClass)
③设计的时候多是将Widget作为成员变量给了Character,然后访问的时候用GetPlayerCharacter获取,这样做是不是很垃圾,感觉应该有更好的索引整理方法,应该要看下好的项目是怎么写的。当然,等到每个需要修改的操作的时候,理应来说应该设置托管,所有操作都应该在自己的类中执行,虽然写起来会代码庞大一点,但是整体逻辑非常清晰,通常难Debug的是瞎几把飞天调用而不是长而有体系的代码。
④感觉如果思路好点,可以根据项目需要,组一个CommonUI的子类,应该还挺容易的。(Sig发了一个《Compact Poisson Filters for Fast Fluid Simulation》手好痒啊,可惜没时间了) [爱] 笔芯
页:
[1]