游戏UI架构(三)
基础设施铺完了,终于可以动手搭系统。
前面那篇讲的是「一个界面怎么被管」,这篇讲「整个游戏的界面怎么被组织」。
一、从界面到系统
定义:单界面框架解决的是「打开、显示、关闭、回收」;系统框架解决的是「什么时候允许有界面、有哪几个、谁压着谁、跨阶段怎么交接」。前者是机制,后者是秩序。
UIFacade 业务唯一入口,Open / Close / Back
├── UIRegistry 界面清单,单一事实来源
├── UIGate 准入判断,能不能开
├── UIGameFlowSubsystem 游戏阶段,现在该看什么
├── UIDomainManager 互斥边界,谁跟谁能共存
├── UIManager 界面实例,层级 / 栈 / 路由
└── UISystemOverlay 系统界面,断线 / 防沉迷 / 公告
│
└── 共用底座:UUIPanelBase、EventBus、ResourceTracker、
Logger、WidgetPool、SafeZone
二、职责拆分
定义:一个入口,多个各管一件事的子系统。把「游戏现在处于什么阶段」「哪些界面能共存」「这个界面能不能开」「界面实例放哪」拆成四件事,每一件都有自己的状态和边界。
伪码:
// 层级关系:越靠上越靠业务,越靠下越靠机制
// 业务只认 Facade,不认下面任何一层
CLASS UUIFacade : UGameInstanceSubsystem
状态:
PendingQueue : TArray<FUIPending>
DomainMgr : UUIDomainManager*
UIManager : UUIManagerSubsystem* // 上一层的实例机制,原样复用
FUNCTION Open(Key, InData, Source) -> bool:
Entry = UUIRegistry::Get()->Find(Key)
IF Entry == NULL: RETURN FALSE
// 准入:前置条件链
Verdict = UUIGate::Check(*Entry, InData)
SWITCH Verdict:
CASE Rejected: RETURN FALSE
CASE Queued: PendingQueue.Add({Key, InData}); RETURN TRUE
CASE Allowed: BREAK
// 所属域必须已激活
RT = DomainMgr->Find(Entry->Domain)
IF RT == NULL: RETURN FALSE
// 机制层只认「域运行时 + 注册条目」,不认游戏处于哪个阶段
RETURN UIManager->Open(*RT, *Entry, InData)
FUNCTION Close(Key, Result)
FUNCTION Back() -> bool
FUNCTION CurrentFlow() -> EGameFlow
FUNCTION CurrentDomain() -> EUIDomain
// 排队项在条件满足时统一补发
PROCEDURE FlushQueue():
FOR EACH P IN 副本(PendingQueue):
IF UUIGate::Check(P.Key, P.Data) == Allowed:
PendingQueue.Remove(P)
Open(P.Key, P.Data)
三、域 Domain
定义:一组共享运行上下文的界面,域内部可以互相叠加、互斥、跳转;域之间整体互斥,同一时刻只有一个域活着。切域不是逐个关界面,而是销毁整棵子树。
伪码:
枚举 EUIDomain : uint8
Lobby // 大厅:主菜单、背包、商店、好友、任务
Battle // 战斗:HUD、技能栏、结算
System // 跨域常驻:断线、防沉迷、公告、强更
Debug // 调试面板
STRUCT FUIDomainRuntime
Domain : EUIDomain
RootWidget : UUserWidget* // 这个域的根,挂在 GameLayer 上
LayerPanels : TMap<EUILayer, UCanvasPanel*>
Mounts : TMap<EUILayer, UCanvasPanel*>
Instances : TMap<FName, UUIPanelBase*>
Stacks : TMap<EUILayer, TArray<FUIPanelEntry>>
ModalStack : TArray<UUIPanelBase*>
BackStack : TArray<UUIPanelBase*>
Context : FUIDomainContext // PlayerController / InputMode / 数据源
CLASS UUIDomainManager : UGameInstanceSubsystem
PROCEDURE Activate(Next : EUIDomain):
IF Active.Contains(Next): RETURN
// 业务域整体替换,System 域保留
FOR EACH D IN 副本(Active):
IF IsSystemDomain(D.Domain): CONTINUE
// 整树销毁:旧域一次性摘掉,由引擎递归销毁子控件
// 不走逐界面 Close,避免异步收尾排到新域里
D.RootWidget->RemoveFromParent()
// 资源与委托在这里统一归还
FOR EACH Panel IN D.Instances:
Panel->RemoveAllBindings()
FUIResourceTracker::ReleaseDomain(D.Domain) // 按域整体归还,不看单界面
D.Instances.Empty(); D.Stacks.Empty()
D.ModalStack.Empty(); D.BackStack.Empty()
Active.Remove(D.Domain)
Active.Add(Next, BuildRuntime(Next))
FUNCTION BuildRuntime(D : EUIDomain) -> FUIDomainRuntime:
RT.Domain = D
RT.RootWidget = CreateWidget<UUserWidget>(PC, DomainRootClass)
RT.RootWidget->AddToViewport(DomainZOrder(D))
// 域内仍然按 EUILayer 分层,复用的是同一套层级枚举
FOR EACH Layer IN 全部层级:
RT.LayerPanels[Layer] = BuildLayerRoot(RT.RootWidget, Layer)
RT.Mounts[Layer] = BuildSafeZone(RT.LayerPanels[Layer])
RETURN RT
// System 域不参与业务切换,Activate/Deactivate 都不碰它
FUNCTION IsSystemDomain(D) -> bool: RETURN D == EUIDomain::System
四、流程 Flow
定义:游戏在时间轴上的阶段。每个阶段声明自己的域、入口界面、常驻界面、允许打开的界面白名单、以及进入前要预载的资源。
伪码:
枚举 EGameFlow : uint8
Boot, Login, Lobby, Matchmaking, Loading, InGame, Settlement, Reconnect
STRUCT FFlowDefinition
Flow : EGameFlow
Domain : EUIDomain
EntryUI : FName // 进入即打开
ResidentUIs : TArray<FName> // 阶段内常驻,不参与返回栈
AllowedUI : TArray<FName> // 白名单;空 = 不限制
Preload : TArray<FSoftObjectPath> // 进入前异步预载
InputMode : EUIInputMode
CLASS UUIGameFlowSubsystem : UGameInstanceSubsystem
状态:
Current : EGameFlow
DefTable : TMap<EGameFlow, FFlowDefinition>
PROCEDURE EnterFlow(Next):
IF Next == Current: RETURN
Def = DefTable[Next]
// 1. 旧阶段常驻界面下线(走正常 Close,保留各自的收尾)
FOR EACH Key IN DefTable[Current].ResidentUIs:
UUIFacade::Close(Key)
// 2. 域切换:跨域才动,同域只换界面
IF Def.Domain != DefTable[Current].Domain:
DomainMgr->Activate(Def.Domain)
// 3. 预载:句柄在这个阶段结束时统一释放
PreloadHandle = FUIPreloader::Preload(Def.Preload)
AWAIT PreloadHandle->完成
// 4. 入口与常驻
UUIFacade::Open(Def.EntryUI, 空, EOpenSource::Flow)
FOR EACH Key IN Def.ResidentUIs:
UUIFacade::Open(Key, 空, EOpenSource::Flow)
// 5. 输入模式跟随阶段
FUIInputRouter::Apply(Def.InputMode)
Current = Next
EventBus()->Broadcast(EUIMessage::FlowChanged, Next)
UUIFacade::FlushQueue()
// 白名单校验放在 Gate 里,Flow 只提供数据
FUNCTION IsAllowed(Key) -> bool:
List = DefTable[Current].AllowedUI
RETURN List.Num() == 0 OR List.Contains(Key)
五、注册表 Registry
定义:所有界面的元数据集中在一处声明,不进代码。Framework 从这张表推导出路由、预载清单、埋点字典、检查工具。
伪码:
STRUCT FUIRegistryEntry
Key : FName // 唯一标识,埋点与路由共用
Class : TSoftClassPtr<UUIPanelBase>
Domain : EUIDomain
Layer : EUILayer
StackMode : EUIStackMode
ResPaths : TArray<FSoftObjectPath> // 界面自己占的资源
Gate : TArray<FUIGateRule>
bInBackStack: bool
bCache : bool
AnalyticsId : FName
CLASS UUIRegistry : UDeveloperSettings // 挂在 Project Settings,策划也能看
config TMap<FName, FUIRegistryEntry> Entries
FUNCTION Find(Key) -> FUIRegistryEntry*
RETURN Entries.Find(Key)
// 由注册表推导,不手写第二份
FUNCTION CollectPreload(Domain, LayerMask) -> TArray<FSoftObjectPath>
FUNCTION ValidateAll() -> TArray<FString>
// 查 Class 为空、Key 重复、埋点缺 ID、跨域层级冲突
六、准入 Gate
定义:打开一个界面前要过的条件链。条件不满足不是一个布尔值,而是一个带处置策略的结果:拒绝、排队、静默。
伪码:
枚举 EUIGate : uint8
LoggedIn
FlowAllowed // 命中当前流程白名单
DomainActive
ResReady
NoBlockingModal // 当前没有被强制遮挡
NotInTransition // 不在域切换 / 预载中
枚举 EGateAction : uint8
Reject // 直接不开
Queue // 记住,条件满足后补开
Silent // 不提示也不排队
枚举 EGateVerdict : uint8
Allowed, Rejected, Queued
STRUCT FUIGateRule
Cond : EUIGate
Action : EGateAction
FUNCTION UUIGate::Check(Entry, InData) -> EGateVerdict:
FOR EACH Rule IN Entry.Gate:
IF 满足(Rule.Cond): CONTINUE
SWITCH Rule.Action:
CASE Reject: RETURN Rejected
CASE Queue: RETURN Queued
CASE Silent: RETURN Rejected
RETURN Allowed
FUNCTION 满足(Cond) -> bool:
LoggedIn → 账号子系统已登录
FlowAllowed → FlowSubsystem->IsAllowed(Key)
DomainActive → DomainMgr 里有这个域
ResReady → Entry.ResPaths 都已加载完成
NoBlockingModal → ModalStack 里没有阻断型界面
NotInTransition → 没有正在进行的 Activate / Preload
七、输入模式
定义:UI 的打开层级决定输入去向。输入模式不是界面的属性,是阶段的属性,由 Flow 统一下发;界面只声明自己需要哪一种。
伪码:
枚举 EUIInputMode : uint8
Game // 只有玩法,HUD 类界面走这个
GameAndUI // 玩法与 UI 同时响应,鼠标不锁
UIOnly // 只 UI,焦点交给顶层界面
STRUCT FUIInputRouter
PROCEDURE Apply(Mode : EUIInputMode):
SWITCH Mode:
CASE Game:
PC->SetInputMode(FInputModeGameOnly())
PC->bShowMouseCursor = FALSE
CASE GameAndUI:
Data = FInputModeGameAndUI()
Data.SetLockMouseToViewportBehavior(DoNotLock)
Data.SetHideCursorDuringCapture(FALSE)
PC->SetInputMode(Data)
PC->bShowMouseCursor = TRUE
CASE UIOnly:
Data = FInputModeUIOnly()
Data.SetWidgetToFocus(顶层界面->TakeWidget())
PC->SetInputMode(Data)
PC->bShowMouseCursor = TRUE
// 界面打开不改变模式,只在 Close 时由 Flow 复位
// 这样多个弹窗叠开不会互相抢输入模式
八、预加载
定义:按流程编排资源加载。每个阶段的预载清单来自注册表,句柄挂在流程上,阶段结束统一释放。
伪码:
CLASS FUIPreloader
状态:
Streamable : FStreamableManager&
Handles : TMap<EGameFlow, TSharedPtr<FStreamableHandle>>
FUNCTION Preload(Flow, Paths) -> TSharedPtr<FStreamableHandle>:
IF Handles.Contains(Flow): RETURN Handles[Flow]
// 异步加载,不阻塞流程切换
Handle = Streamable.RequestAsyncLoad(Paths)
Handles.Add(Flow, Handle)
RETURN Handle
PROCEDURE Release(Flow):
IF NOT Handles.Contains(Flow): RETURN
Handles[Flow]->ReleaseHandle()
Handles.Remove(Flow)
// 预载清单由注册表按域 + 层级筛出,不手写
FUNCTION BuildPlan(Flow) -> TArray<FSoftObjectPath>:
Def = FlowSubsystem->Definition(Flow)
RETURN UUIRegistry::Get()->CollectPreload(Def.Domain, 全部层级)
+ Def.Preload
九、系统域
定义:不参与业务流程、但可能在任何时刻出现的界面。它们不进 BackStack,不受流程白名单限制,层级固定压在业务域之上。
伪码:
枚举 EOverlayKind : uint8
Blocking // 阻断型:断线重连、强更、封禁
Notice // 通知型:公告、活动推送
Toast // 弱提示:错误码、短提示
CLASS UUISystemOverlay : UGameInstanceSubsystem
状态:
BlockingStack : TArray<UUIPanelBase*>
NoticeQueue : TArray<FName>
CurrentToast : UUIPanelBase*
PROCEDURE Show(Key, Kind):
SWITCH Kind:
CASE Blocking:
// 阻断型独占:先压掉已有阻断界面
FOR EACH P IN BlockingStack: P->Hide()
W = OpenInSystemDomain(Key)
BlockingStack.Add(W)
FUIInputRouter::Apply(UIOnly)
CASE Notice:
// 通知型排队,一次只显示一条
NoticeQueue.Add(Key)
IF BlockingStack.Num() == 0: PopNextNotice()
CASE Toast:
// 弱提示复用同一个控件,走对象池
IF CurrentToast 有效: 归还到池
CurrentToast = FUIWidgetPool::Borrow(ToastClass)
设置文本与生命周期
// 系统界面关闭时由这里统一复位,不交给业务
PROCEDURE OnBlockingClosed():
BlockingStack.Pop()
IF BlockingStack.Num() == 0:
FUIInputRouter::Apply(当前流程的输入模式)
PopNextNotice()
十、可观测性
定义:UI 系统自己的调试面。能看树、看栈、看预算,也能绕过准入强行打开任意界面。
伪码:
CLASS UUIDebugPanel : UUIPanelBase
// 结构快照
PROCEDURE DumpTree():
FOR EACH D IN DomainMgr->ActiveDomains:
FOR EACH Layer IN 全部层级:
FOR EACH Panel IN D.Stacks[Layer]:
输出 缩进 + Panel->类名 + Visible/Collapsed + ZOrder
PROCEDURE DumpStacks():
FOR EACH D IN DomainMgr->ActiveDomains:
输出 D.ModalStack 自顶向下
输出 D.BackStack 自底向上
输出 PendingQueue
// 绕过准入
PROCEDURE ForceOpen(Key):
Entry = Registry->Find(Key)
RT = DomainMgr->Find(Entry->Domain)
IF RT == NULL: RETURN
UIManager->Open(*RT, *Entry, 空) // 直接走机制层,不过 Gate
// 预算
PROCEDURE Stats():
输出 每个域的控件总数
输出 每个界的打开耗时(CreateWidget → NativeConstruct 完成)
输出 当前帧 UI 的 DrawCall 与 Slate 批次
输出 池的空闲 / 使用中数量
// 注册表自检
PROCEDURE ValidateRegistry():
FOR EACH S IN Registry->ValidateAll(): 输出 S
十一、工程规范
定义:新增一个界面要走的固定路径。做完这七步,界面就能被系统接管。
1. 建类 UMyPanel : UUIPanelBase,只覆写 Native* 与 NativeRefresh
2. 加注册 在 Project Settings → UIRegistry 加一条,填 Key / Domain / Layer / StackMode
3. 声明资源 ResPaths 填这个界面独占的图与音频;公共资源走 Common,不填
4. 声明准入 Gate 填这界面需要的条件与处置策略
5. 定埋点名 AnalyticsId 与 Key 一致,不另起
6. 放目录 Content/UI/<Domain>/<Module>/WBP_<Module>_<Panel>
7. 过检查 Registry->ValidateAll() 无报错
命名:
WBP_<Domain>_<Module>_<Panel> 控件蓝图
U<Module>_<Panel>_ViewModel 数据快照(可选)
T_UI_<Module>_<Use>_<State> 贴图
十二、全场景流程图
定义:把 Facade 收到的每一种调用、每一种当前状态,摊开成确定的路径。下面这些图覆盖了 Open / Close / Back / 域切换 / 流程切换 / 准入 / 横切 / 缓存 / 竞态 的全部组合。
12.1 打开:一条主决策链

12.2 同层策略:三种栈演化
| 策略 | 开 1 | 开 2 | 回退一次 |
|---|---|---|---|
| Exclusive | 栈:1 | 关闭 1,栈:2 | 栈空 |
| Stack | 栈:1 | 栈:1、2 | 关闭 2,栈:1 |
| Single | 栈:1 | 刷新 1,栈:1 | 关闭 1 |

12.3 关闭:栈顶与非栈顶

12.4 回退:逐层与到底


12.5 域切换:整树替换

12.6 流程切换:跨阶段交接

12.7 准入:四种裁决

12.8 排队:条件满足后补发

12.9 系统域:阻断与恢复

12.10 弱提示:单实例复用

12.11 缓存:二次打开

12.12 竞态:加载过程中被打断


12.13 输入:谁先拿到

如果是大世界项目,HUD需要经常切换,那就还得再套一次
浙公网安备 33010602011771号