前言
三流程序写UI,但即便是UI这种一般程序员比较排斥去做的事情,想把它写好写的优雅,也是需要点考究的。
我虽然不知道什么是好代码,但我知道什么是菜代码。浏览过多数商城的UI系统,查阅过网上无数的UMG“最佳实践”,很可惜,大多数都没有采用一种优雅的方法进行实现,没有统一的UI管理,做法也是千奇百怪,有在PlayerController里通过Enum来切换不同的Widget的,也有我想显示个UI就立即创建的,也有GetAllWidgetOfClass关掉所有UI然后再开启自己想要的。甚至还有看起来花里胡哨的“One widget rules all!”(然后各种WidgetSwitcher切换,利用接口夸UI调用。)
以上全菜。
如果你了解栈的概念,和状态机的概念,请继续往下看(栈和状态机是个做游戏的都知道,不适合浪费太多文字解释)
思路
栈状态机才是解决该类问题的杀手锏(但不是解决问题的唯一思路),各位看官回忆一下自己玩过的游戏。
- 打开菜单:按Tab就弹出暂停菜单(层1)进入视口,暂停菜单里有继续游戏,游戏设置,退出游戏几个按钮,当你点击游戏设置的时候,又会弹出一个新的游戏设置菜单(层2)进入视口,盖住了暂停菜单,游戏设置菜单下又有音效设置,图形设置等,当你点击图形设置,又会弹出一个图形设置菜单(层3)。
- 关闭菜单:每一个菜单右上角有一个X可以关闭UI,当你关闭图形设置菜单(层3)后,图形设置菜单(层3)从视口移除,游戏设置菜单(层2)重回视口,当你关闭层2,暂停菜单(层1)重回视口。
- 一次关闭所有菜单:比如玩家按Esc,就直接回到了游戏界面,这一操作本质上就是自动关闭了层321。
好,各位发现没。
上述的打开关闭菜单的操作是否就像一个栈:一个菜单进入视口(入栈Push,进入栈顶);一个菜单退出视口(出栈Pop,从最顶层离开);顶层菜单被新打开的菜单覆盖从而不可操作(在栈中往下走了一层,Push,离开最顶层);因为顶层菜单退出视口,下面被覆盖的菜单又重新回到最顶层(在栈中往上走了一层,Pop,重新进入栈顶)。按Esc关闭所有菜单,其实就是把菜单从栈顶一个个弹出。
同时它还像一个状态机:栈中的每一个菜单都是一个状态机中的状态,一个状态机同时只有一个激活状态,就是栈顶的那一个状态(CurrentState),菜单进入/退出栈顶就是状态的Enter和Exit。
实现通用状态机
既然UI操作是有规律可言的,且可以用栈状态机进行模拟,那咱们首先就在虚幻里实现一个通用的栈状态机,然后再基于栈状态机实现一个UI栈状态机(因为栈状态机又不是只能拿来做UI管理的。)
首先每个状态都可以进入,退出,和更新。即便是状态机也是如此。而处于栈中的状态还可以被Push和Pop。以下是具体实现。
类型定义

新建一个枚举和会用到的委托声明,放在任意头文件里,比如StateMachineTypes.h 截图按代码顺序来的。
栈状态接口

再新建一个虚幻接口叫StackStateInterface.h,该接口定义了状态的共有行为。 截图按代码顺序来的。
栈状态

然后新建一个栈状态StackState.h实现StackStateInterface。 截图按代码顺序来的。

StackState.cpp
栈状态机定义

现在有了状态了,再实现一个栈状态机,为了复用,做成组件。 StackStateMachineComponent.h 截图按代码顺序来的。
栈状态机也实现了StackStateInterface,同时可以PushState和PopState,以及Pop指定State和Pop所有State

这一部分主要是提供给蓝图的回调,确保灵活。

GetterSetter,供外部访问。
栈状态机实现

状态入栈实现

状态弹栈实现

按指定数量弹栈或清空栈。

确保不断更新栈中的当前状态

同时,状态机本身也是个状态哦

我们需要知道状态的Push和Pop,比如打个log让我们知道栈中都发生了什么。
实现UI栈状态机
UI栈状态
现在咱们实现了一个通用栈状态机,已经可以完成很多事情,甚至可以拓展,所以接下来我要拓展栈状态机,进行UI管理,UE4的UI都是UserWidget,所以咱们实现一个UIState继承自UserWidget,其功能跟上面的UStackState没什么区别,除了继承的父类不一样。但两者都实现了IStackStateInterface。这样,我们游戏里创建的UserWidget都是一个可以被栈状态机管理的栈状态了。

UIState.h,和StackState.h相似,只是供蓝图使用的接口现在有Native实现。

UIState.cpp,跟StackState.cpp相似

当一个UI状态以Push/Pop形式进入/退出顶层

Update暂无实现,供蓝图实现。
UI栈状态机

UIManagerComponent.h,关于GetCurrentUIState,用到了DeterminesOutputType = StateClass和DynamicOutputParam = OutState 这两个meta
UI栈状态机用法
现在有了通用的栈状态和栈状态机,我们也添加了UI栈状态和UI栈状态机(前面的UUiMnanagerComponent)。那么这个Manager一般放在PlayerController下。所有游戏中的需要作为“界面”使用的UMG都应该继承自UIState。
1.在PlayerController中。

2.暂停菜单 UMG_PauseState


3.设置菜单 UMG_SettingState


最佳实践/设计理念/答疑
1.为什么代码要截图而不是粘贴?
因为你懒。你敲了才是你的,你不敲,还是我的。
2.前面不是说要用到这个UI的时候才创建不好吗,为什么你的截图中还是要用的时候才创建?
是不好,尤其是在那个UI依赖的东西多的情况下,我这里只是出于演示目的,实际上我得做法是在UIManager里维护一个可配置的TMap<FName,TSubclassof<UUIState>> States,然后在UIManager的Beginplay里一次创建好所有UI并存入TMap<FName,UUIState*> StateInstances里,然后写一个Helper函数如 PushNamedState(FName UIName);
3.假如每个界面的开启关闭我需要动画过渡呢?
你可以继承一个AnimatedUIState,写一堆过渡效果(透明度lerp,color等)提供一些接口函数如 GetTransitionTarget(让蓝图可以重载告知过渡效果的应用目标),然后在状态Enter/Exit执行相关过渡效果。如果要用Umg里的动画,很遗憾UMG动画不能继承,但只是过渡的话代码实现即可。
4.假如我的UI不是一层一层的呢,比如说一个界面上有多个Tab。
这时候你可以用WidgetSwitcher确保这个UI都在这一层。只是说这一层复杂点而已。
5.游戏的InGameUI怎么实现呢,比如玩家的血条那些的。
一般我的UIManager在游戏过程中,都会加一个默认状态叫UMG_PlayingState,玩家游戏过程中的UI都显示在上面,而且还能通过UICount进行一些逻辑判断,比如我只有UICount=1(也就是只有一个Playing状态的时候),我按Tab才能弹出暂停菜单。而且我这个PlayingState还可以做很多比如玩家捡到东西的屏幕提示等(ShowInfo(FText Text))。
6.由于UIManager一般放在PlayerController下,但PlayerController会存在于服务端(特指DedicatedServer,)而服务端没有UMG。这种多人联机情况下怎么办(2020.6.1日新增)?
对UIManager操作的时候,先GetPlayerController,调用上面的IsLocalPlayerController。
确保UI的操作都发生在本地玩家上。
7.还有别的疑问,欢迎评论区提出。