Fivee

导航

AIGC标识 游戏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 打开:一条主决策链

ui-12-01

12.2 同层策略:三种栈演化

策略 开 1 开 2 回退一次
Exclusive 栈:1 关闭 1,栈:2 栈空
Stack 栈:1 栈:1、2 关闭 2,栈:1
Single 栈:1 刷新 1,栈:1 关闭 1

ui-12-02

12.3 关闭:栈顶与非栈顶

ui-12-03

12.4 回退:逐层与到底

ui-12-04

ui-12-05

12.5 域切换:整树替换

ui-12-06

12.6 流程切换:跨阶段交接

ui-12-07

12.7 准入:四种裁决

ui-12-08

12.8 排队:条件满足后补发

ui-12-09

12.9 系统域:阻断与恢复

ui-12-10

12.10 弱提示:单实例复用

ui-12-11

12.11 缓存:二次打开

ui-12-12

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

ui-12-13

ui-12-14

12.13 输入:谁先拿到

ui-12-15

如果是大世界项目,HUD需要经常切换,那就还得再套一次

posted on 2026-11-04 16:04  Fivee  阅读(3)  评论(0)    收藏  举报