UI及其架构的重要性
游戏的UI系统拥有以下这些特性:- 规模庞大且极为复杂
当然也有例外,但大多数情况下,游戏都拥有相对复杂的养成系统、任务系统、属性系统、留存系统等等。根据我的经验,“UI团队”不仅负责将UI呈现给用户,还需要负责开发底层系统,追踪所有通常保存在账户系统和云数据存储中的数据以及游戏玩法系统中的留存点,以便收集数据。
- 不确定性
在开发过程中,随着团队调整游戏的开发方向,这些系统会不断扩充并且很容易发生改变。
- 微妙性
如果收集到的各类意见和结果来自于拥有不同技术水平和游戏背景的用户,那么在处理包含这些信息的反馈时,会对风格、感观、布局和演示效果进行大量调整和更改。
考虑到这些情况,我强烈建议开发者多花一点时间提前搭建一个强大的架构,并且还应注意以下几点:
- 将业务逻辑和UI的视觉效果分离
- 允许对布局和视觉效果进行快速迭代
- 高效的业务逻辑调试过程
- 性能
我将通过下面这个实际示例演示可实现这些目标的一个高效模式。
示例
假设你正在开发一款游戏。让我们随便找一款游戏——就拿《堡垒之夜》作为例子吧。;)我们希望物品商店类似于这种效果:
我们将通过讲解三点内容深入讨论这一示例:
- 类架构
- C++代码
- 蓝图
大多数时候,我会直接介绍实现方法,并且通过文字解释和代码注释来说明原因。
类架构
我推荐的架构通常大致遵循以下这种模式:UMyData是一个继承自UObject的C++类。
我的想法是创建一个数据类,将UI与用户之间的所有通信信息封装起来。由于它继承自UObjects,所以我们能够控制哪些类成员是公有/私有的,使用获取方法/设置方法,并添加有用的接口。
这种方法十分有用,它能将数据的生命周期与UI剥离,因为商店商品、库存物品、玩家状态以及所有其它各类数据源很少与UI对象共享同一种生命周期。此外,你可以让多个控件获取同一个数据对象的数据。这些数据类可能已经存在了,因为会有负责其它内容(例如游戏玩法或商城后端)的工程师创建它们,然而即便如此,很多时候UI还是需要属于它自己的数据类来封装游戏玩法数据以及更多与UI相关的特定数据。
UMyWidget是一个继承自UUserWidget的C++类。
这些C++类旨在定义控件相关的接口,以便在蓝图以及可以蓝图化的事件中使用,从而就蓝图如何与底层系统正确交互明确蓝图须遵循的约束条件。MyBlueprint是一个继承自UMyWidget的控件蓝图。
在控件蓝图中,你可以创建所有需要用到的可见UI并为其进行布局、制定风格,然后利用UMyData和UMyWidget提供的接口以及必要数据生成所有UI基本元素(文本框、图片等)。你还可通过监听UMyWidget提供的事件了解何时更新对应的UI效果。在交互式案例中,你可以响应按钮点击事件,并调用UMyData或UMyWidget提供的一些接口。让我们编写代码
我们的数据类
首先,让我们为商城中的商品编写数据类。UOfferInfo继承自UObject,因为我们希望将它公开给蓝图,并且希望UFUNCTIONS能访问我们的数据。在这个示例中,我们会尽可能简化它,但你还仍能加入价格、图片以及其它更多信息。
UENUM(BlueprintType)
enum class EOfferType : uint8
{
Normal,
Featured
};
UCLASS(BlueprintType)
class UOfferInfo : public UObject
{
GENERATED_UCLASS_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
FText GetName() const;
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
FText GetDescription() const;
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
EOfferType GetOfferType() const;
private:
// 你所有包含UProperties的数据!
};
我们的商城控件类
接着,让我们来看看商城控件类,它负责为显示所有商品的屏幕界面提供接口和行为。单个商品具体信息的展示功能将交由继承自UUserWidget的UOfferWidgetBase实现,我们稍后会介绍它。// 添加“Abstract”关键字是因为我们希望用一个蓝图类来继承这个基类
// 但不希望用户直接实例化这个基类
UCLASS(Abstract, Blueprintable, BlueprintType, ClassGroup = UI)
class UOfferShopWidgetBase : public UUserWidget
{
GENERATED_UCLASS_BODY()
public:
// 这个函数告诉用户我们已开始读取商品
// 蓝图很有可能会通过动态浏览图示和文本内容来说明我们正在下载数据
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void OnStartReadingOffers();
// 我们告诉蓝图我们希望为某个特定商品生成UI
// 蓝图很有可能会创建一个商品控件并将其UOfferInfo*作为参数传递给它,让它接着处理
// 我建议使用这类接口,以便让蓝图完整控制整个布局
// 也许画面设计师一开始就想要一个垂直框,过了一个礼拜后又想要水平滚动框,
// 或者是网格布局,或者是某种组合效果......你懂的 ;)
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void GenerateOffer(UOfferInfo* OfferData);
// 这个函数告诉用户商品已经生成完毕
// Blueprint很有可能会隐藏动态浏览图示并完成屏幕的所有设置
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void OnOfferGenerationCompleted();
// 让蓝图获取使用数据的工具函数
UFUNCTION(BlueprintCallable, Category = "OfferShopWidget")
FDateTime GetStoreRefreshDate() const;
protected:
// 至少为你的所有内部函数添加protected关键字
private:
// 其它内部函数
// 你的所有包含UProperties的数据!
UPROPERTY(transient)
TArray<UOfferInfo*>CurrentOffers;
};
我们的商品控件
最后,让我们来看看用于单独显示商品的控件的C++基类。为了实现UI效果,我们要针对两种不同的效果派生出两个使用不同布局的蓝图类。
// 添加“Abstract”关键字是因为我们希望用一个蓝图类来继承这个基类
// 但不希望用户直接实例化这个基类
UCLASS(Abstract, Blueprintable, BlueprintType, ClassGroup = UI)
class UOfferWidgetBase : public UUserWidget
{
GENERATED_UCLASS_BODY()
public:
// 你在为其中一个控件创建新的实例后须调用这个函数// 在这种模式中,如果你想显示不同的内容,可再次调用SetupOffer
// 而不必再新建一个控件,这点对于性能至关重要,尤其是当你在
// 开发背包这类效果时,你经常需要把控件添加到缓存池中以重复使用控件,从而确保UI足够流畅
UFUNCTION(BlueprintCallable, Category = "OfferWidget")
void SetupOffer(UOfferInfo* InOfferData)
{
// 我想在这里写一点注释,向大家解释下为什么我不喜欢用BluprintNativeEvents
// 主要原因是它可能会产生用户错误,在错误的位置调用父函数
// 或者根本没有调用。
// 围绕着用户体验实现的BlueprintImplementableEvent也更加简单。
OfferData = InOfferData;
OnOfferSet();
}
// 我们告诉蓝图要显示数据
// 蓝图很有可能会调用GetOfferInfo(),然后调用GetName、GetDescription
// 以及其它方法来生成文本框、图片和其它UI元素
UFUNCTION(BlueprintImplementableEvent, Category = "OfferWidget")
void OnOfferSet();
// 让蓝图获取使用数据的工具函数
UFUNCTION(BlueprintCallable, Category = "OfferWidget")
UOfferInfo* GetOfferInfo() const;
private:
UPROPERTY(transient)UOfferInfo* OfferData;
};
让我们编写蓝图
应创建继承自UOfferShopWidgetBase的UMG控件并构建UI布局,以便在运行时用数据生成UI。- FeaturedOffers_HorBox
- 占据屏幕左侧,用来显示特殊商品。
- NormalOffers_WrapBox
- 占据屏幕左侧,用来显示普通商品。
- RefreshTime_TextBlock
- 一个用来显示商城刷新时间的文本框。
以下是节点图表:
通过使用蓝图处理如何创建控件以及将它们添加到合适的容器内等问题,我们能够
:
- 轻松调整布局
- 根据不同情况切换不同控件
- 修改插槽属性,该属性会在调用“Add Child to…”这类方法后返回
现在让我们创建商品控件。我们会基于我们编写的C++类UOfferWidgetBase,派生出两个不同的UMG控件类。将它们分别命名为OfferTileSmallWidget和OfferTileLargeWidget。如上图所述,OfferShop蓝图控件类中的GenerateOffer事件会根据保存在商品数据中的商品类型创建出两种不同类型的控件。
模仿顶部效果图设置完大控件和小控件后,我们可以接着在事件图表中添加内容,图表在最大程度简化后应类似于下面这张图:
我们创建了OfferTileSmallWidget和OfferTileLargeWidget来填充商城界面,不过我们的架构有助于进一步扩充内容。想象一下,在游戏的其它界面中,我希望显示一个热门商品!
要实现这个功能需两步:
1. 在C++中,我们要为热门商品创建一个函数,也许就像这样:
UOfferInfo* MyLibrary::GetTheHotOffer()
UOfferInfo* MyLibrary::GetTheHotOffer()
它只会返回OfferInfo数据类的实例。
2. 将现有的两种控件中的一种添加到界面中,然后调用SetupOffer() ,并且将GetTheHotOffer() 的返回值作为参数。
或者我们可以新建一个拥有火焰特效的蓝图“HotOfferWidget”,将它放入并调用SetupOffer()。
或者我们可以新建一个拥有火焰特效的蓝图“HotOfferWidget”,将它放入并调用SetupOffer()。
总结
这种方法将业务逻辑保留在C++代码中,使得维护、调试和调整变得更加容易。它将基于事件的接口公开给蓝图,允许创意人员在设计他们所需的用户体验和视觉效果时拥有更高的灵活度。需要避免的一些情况包括在蓝图中使用Tick事件、属性绑定,其次,还要避免出现过多动画。这种方法本质上就是提供一组可将系统的关键层面及其状态公开的事件。这样就会很自然地避免用户使用蓝图Tick事件和属性绑定,因为它们的作用会因事件而降低。
动画的开销相对昂贵,毫无疑问动画很有用,但你仍应谨慎使用,尤其是当游戏流畅性十分重要时,例如当HUD中的UI性能预算始终捉襟见肘时。你只要开发过UI就能明白我的意思!