Fivee

导航

AIGC标识 游戏UI架构(四)-大世界咋办

大世界的 HUD 不是「一个界面」,是「一直在的一组东西」。

它在屏幕上的时间比任何界面都长,但没有打开和关闭这两个动作,只有换。这篇不写它内部怎么组织——槽位怎么划、情境怎么映射到配置、换装怎么不闪,那些属于 HUD 自己的模块。这篇只写一件事:**它既然一直在,UI 系统要跟着改哪些地方。**

---

## 一、它为什么不能当普通界面走一遍

定义:普通界面的存在方式是「被打开 → 存在一会儿 → 被关闭」;HUD 是「一直在 → 内容变 → 一直在」。两者在三个地方对不上:

| 系统假定 | HUD 的实际 |
|---|---|
| 有 Open / Close | 没有,只有换内容 |
| 进栈,能被 Back 退掉 | 不能,Back 不该关掉血条 |
| 用完就销毁 | 全天候存在 |

所以不把它塞进 `UUIPanelBase` 那套 Open / Close 流程,而是给它单独一层、单独一套契约,再把需要和系统咬合的地方接上。这样的地方一共就八处:层级、栈、输入、准入、通知、压制、缓存、生命周期,后面一节一个。

---

## 二、它和系统的接触面

定义:HUD 内部怎么组织是它自己的事。系统只需要知道三件事——现在是什么情境、情境有没有叠加项、要不要给它让位;再加一个域切走时的通知。其余一概不管。

伪码:

```text
CLASS UHudSubsystem : UGameInstanceSubsystem

    状态:
        // 下面三个是 HUD 自己的内部件,系统不直接碰
        Selector   : 情境裁决        // 玩法请求情境,它决定现在是什么
        SlotMgr    : 槽位装配        // 每个位置放什么
        Cache      : 部件复用        // 换下来的不销毁

    // ===== 系统只看这三个,也只调这三个 =====
    FUNCTION CurrentContext() -> EHudContext          // 基础层,同一时刻只有一个
    FUNCTION Overlays()       -> TArray<EHudContext>  // 叠加层,可与基础层共存
    PROCEDURE SetSuppress(Mode : EHudSuppress)        // 被界面压制

    // ===== 外加一个生命周期通知 =====
    PROCEDURE OnDomainSwitched()                      // 域要走,HUD 自己回收部件
```

后面所有「系统怎么办」,都是建立在这几个调用上的。系统不读槽位表,也不关心哪个部件在哪个位置。

情境本身仍然分两层,因为它决定准入判断的方向:

```text
枚举 EHudContext : uint8
    // 基础层:同一时刻只有一个
    Explore        // 探索,默认态
    Vehicle        // 骑乘 / 驾驶
    Build          // 建造
    Dialogue       // 对话 / 商店
    Cutscene       // 过场
    Dead           // 死亡

    // 叠加层:可与任意基础层共存
    Combat         // 战斗,骑乘战斗、建造被打断都靠它
```

---

## 三、层级:给它单独一层

定义:HUD 需要一块固定的落点,而且它的位置不能用「谁先打开」来排。所以层级枚举里单独列一层。

伪码:

```text
枚举 EUILayer : uint8
    Background = 0     // 全屏底图
    Scene      = 10    // 关卡内的固定 UI
    Hud        = 15    // 大世界 HUD:一直在,随情境换
    Main       = 20    // 主菜单、背包、设置
    Popup      = 30    // 弹窗家族
    Guide      = 40    // 新手引导
    Loading    = 50    // 加载、转场
    Debug      = 90    // GM 面板

    // 数值同时作为 UCanvasPanelSlot 的 ZOrder
    // 视觉遮挡与输入优先级因此是同一个顺序,不需要第二套排序
```

放在 `Scene` 之上、`Main` 之下的理由是语义顺序:HUD 属于「世界上的信息」,被菜单和弹窗压住是对的;而它又要压在场景之上的固定 UI 上。

这一层不进 `LayerStacks`。栈管的是「同层谁压着谁、能不能退」,HUD 不参与这两个问题。

---

## 四、栈与返回:不参与

定义:Back 只处理「玩家主动打开的界面」。HUD 不在这个集合里,所以它不进 `BackStack`,也不进 `ModalStack`。

伪码:

```text
// BackStack 里只放「玩家主动打开的界面」
// HUD 不在其中,所以退栈天然跳过它,不需要额外写一句「别关 HUD」
FUNCTION Back() -> bool:
    RETURN UUIFacade::Back()      // 先退引导还是先退弹窗,由 Facade 定,与 HUD 无关

// 判据:这个界面是玩家点出来的吗
// 是 → 进 BackStack;不是(HUD、常驻挂件、系统横幅)→ 不进
```

`ModalStack` 同理。模态的含义是「在它关掉之前,底下的东西不能被操作」,HUD 既不被关闭,也不阻断别人,所以它不是模态。

---

## 五、输入:默认不吃

定义:HUD 大部分区域只显示不响应,只有技能按钮、交互提示这类才是可点的。区分方式不是靠层级,而是靠控件自身的可见性模式。

伪码:

```text
// 纯显示件:看得见,但点击穿透到下层
Part->SetVisibility(ESlateVisibility::SelfHitTestInvisible)

// 可交互件:技能按钮、拾取提示
Btn->SetVisibility(ESlateVisibility::Visible)

// 隐藏但保留布局
Part->SetVisibility(ESlateVisibility::Hidden)
```

弹窗打开时,输入被更上层截走,HUD 的显示不受影响——因为遮住的只有输入,不是可见性。

输入优先级不需要单独定义,它和 ZOrder 是同一个顺序(见第三节)。要判「这一下点给谁」,只看向上第一个 `Visible` 的控件。

---

## 六、准入:情境是 Gate 的一个条件

定义:一个界面能不能开,取决于流程、域、情境三者同时允许。少了情境这一环,就会出现「探索时打开战斗专属面板」「战斗中弹出邮件提醒」这两类错配。

这一节是这篇最实的部分:HUD 只提供情境,判断由系统的准入层做。

### 6.1 注册表加四个字段

```text
STRUCT FUIRegistryEntry
    ...
    AllowedContexts : TArray<EHudContext>     // 空 = 不限情境
    DeniedContexts  : TArray<EHudContext>     // 优先于 Allowed
    OnContextLost   : EContextLostPolicy
    HudSuppress     : EHudSuppress

枚举 EContextLostPolicy : uint8
    Keep       // 情境变了,界面留着
    Close      // 情境变了,立即关掉
    Defer      // 情境变了先关,回到允许时再开

枚举 EHudSuppress : uint8
    None       // HUD 照常
    Dim        // HUD 变暗,不接收输入
    Hide       // HUD 整层隐去
```

`DeniedContexts` 必须单独存在,不能只靠 `AllowedContexts` 反推。邮件提醒这类界面是「几乎哪都能弹,但战斗和过场绝对不行」,用白名单要枚举十几种情境,用黑名单写三个就够了。

### 6.2 Gate 增加一个条件

```text
枚举 EUIGate
    ...
    ContextAllowed        // 由 情境允许(Entry) 判定

FUNCTION 情境允许(Entry) -> bool:
    Cur      = Hud->CurrentContext()        // 基础层
    Overlays = Hud->Overlays()

    // 黑名单:基础层和任一叠加层命中即拒
    IF Entry.DeniedContexts 含 Cur: RETURN FALSE
    FOR EACH O IN Overlays:
        IF Entry.DeniedContexts 含 O: RETURN FALSE

    IF Entry.AllowedContexts 为空: RETURN TRUE

    // 白名单:基础层或任一叠加层命中即放行
    IF Entry.AllowedContexts 含 Cur: RETURN TRUE
    FOR EACH O IN Overlays:
        IF Entry.AllowedContexts 含 O: RETURN TRUE

    RETURN FALSE
```

叠加层要一起判,否则「骑乘战斗中打开技能升级」会漏掉:基础层是 `Vehicle`,白名单只写了 `Combat`,只判基础层就会被拒。

### 6.3 情境变化时,已开界面怎么处理

```text
PROCEDURE OnContextChanged(NewBase, NewOverlays):
    FOR EACH Panel IN 当前已开界面 的副本:
        Entry = UUIRegistry::Get()->Find(Panel.Key)
        IF 情境允许(Entry): CONTINUE

        SWITCH Entry.OnContextLost:
            CASE Close: UUIFacade::Close(Panel.Key)
            CASE Defer: UUIFacade::Close(Panel.Key)
                        Deferred.Add(Panel.Key)
            CASE Keep:  CONTINUE

    // 回到允许的,把 Defer 的补开
    FOR EACH Key IN Deferred 的副本:
        IF 情境允许(UUIRegistry::Get()->Find(Key)):
            Deferred.Remove(Key)
            UUIFacade::Open(Key)
```

`Defer` 与 `Close` 的区别在于要不要记账:背包在战斗中进不去,但战斗结束后玩家多半还想打开它,所以记一笔;战斗结算面板过期就没意义了,直接 `Close`。

---

## 七、通知:投递前就要拦

定义:邮件到货这类系统通知,问题不在「能不能弹」,而在「该不该在这个时刻投递」。拦截点要放在投递之前,不是等界面打开时才拒。

```text
STRUCT FNotice
    Key      : FName          // 注册表 Key
    Data     : payload
    入桶时间 : double

CLASS UUINoticeRouter

    状态:
        Bucket  : TArray<FNotice>       // 已收到、尚未投递
        MaxHold : int32 = 20
        TTL     : float = 600           // 秒

    PROCEDURE Post(Notice):
        IF CanDeliver(Notice):
            UUIFacade::Open(Notice.Key, Notice.Data)
            RETURN

        IF Bucket.Num() >= MaxHold:
            丢弃最旧的一条
        Bucket.Add({Notice, 入桶时间 = 现在})

    FUNCTION CanDeliver(Notice) -> bool:
        Entry = UUIRegistry::Get()->Find(Notice.Key)
        RETURN 情境允许(Entry)

    PROCEDURE OnContextChanged():
        FOR EACH N IN Bucket 的副本:
            IF CanDeliver(N):
                Bucket.Remove(N)
                UUIFacade::Open(N.Key, N.Data)

    PROCEDURE Prune():
        FOR EACH N IN Bucket 的副本:
            IF 现在 - N.入桶时间 > TTL:
                Bucket.Remove(N)
```

投递层与展示层分开,处置才能不同:投递层可以攒着、可以过期丢弃;展示层(`UUIFacade` 的排队表)只处理「已经决定要弹,但时机不对」的界面。两者合成一个队列,就分不清「还没决定」和「决定了但等时机」。

---

## 八、反向:界面压制 HUD

定义:打开全屏界面时,HUD 让位。压制按当前叠开的所有界面里最严格的一档生效,关掉一个不能直接恢复。

```text
CLASS UHudSuppressor

    状态:
        Stack : TArray<EHudSuppress>

    PROCEDURE Push(Mode):
        Stack.Add(Mode)
        Apply()

    PROCEDURE Pop(Mode):
        Stack.Remove(Mode)
        Apply()

    内部 PROCEDURE Apply():
        // Hide > Dim > None
        M = Stack 为空 ? None : Stack 里最严格的一档
        Hud->SetSuppress(M)
```

背包上叠一个设置,两个都声明 `Hide`,关掉设置时不能直接恢复 HUD,要等背包也关掉。所以是计数式的栈,不是布尔开关。

---

## 九、缓存:要为「一直在的东西」破例

定义:缓存要先拆开,因为三种东西的生命周期和回收方式完全不同。

| 层 | 缓存什么 | 回收依据 | 归谁管 |
|---|---|---|---|
| 实例 | UUserWidget 对象 | 策略等级与水位 | 缓存模块 |
| 资产 | 贴图、字体、蓝图类 | 引用计数 | ResourceTracker |
| 池对象 | 高频短命控件 | 借出与归还 | WidgetPool |

三者正交。把它们塞进一个 `bCache` 开关里,是后面所有缓存问题的主要来源。

### 9.1 普通界面:四档

| 等级 | 名称 | 典型对象 | 淘汰时机 |
|---|---|---|---|
| L0 | Permanent 常驻 | 通用弹窗外框、常驻小图 | 从不淘汰 |
| L1 | Session 会话内 | 大厅主界面、背包、设置 | 切换域时 |
| L2 | LRU 最近使用 | 常用弹窗,保留 N 个 | 超出容量时淘汰最久未用 |
| L3 | Transient 一次性 | 结算、活动、公告 | 关闭即销毁 |

淘汰只由四个事件触发:切域、切流程、水位超限、容量超限。不用定时轮询扫缓存表。

```text
枚举 ECacheTier : uint8
    Permanent, Session, LRU, Transient

STRUCT FCacheEntry
    Key      : FName
    Tier     : ECacheTier
    Widget   : UUserWidget*
    LastUsed : double
    CostMs   : float        // 上次重建耗时
    Nodes    : int32        // 控件树节点数,估算占用

CLASS UUICache

    状态:
        Entries    : TMap<FName, FCacheEntry>
        LRUCap     : int32 = 8
        NodeBudget : int32 = 3000        // 控件节点总数上限

    FUNCTION Acquire(Class, Tier) -> UUserWidget*   // 命中则复用,未命中共建
    PROCEDURE Evict(Reason)
    PROCEDURE EnforceBudget()                       // 节点数超预算 → Evict(MemoryHigh)

// Evict 的四种原因,各清不同的档
PROCEDURE UUICache::Evict(Reason):
    SWITCH Reason:
        CASE DomainSwitch:      // 切域:只留常驻
            KeepTier(Permanent)
        CASE FlowSwitch:        // 切流程:留常驻与会话
            KeepTier(Permanent, Session)
        CASE MemoryHigh:        // 水位超限:LRU 先走,再动会话
            DropLRU()
            IF 仍超限: DropSession()
        CASE CapacityFull:      // 容量满:只清 LRU 尾部
            DropLRU()

// 重建贵的往上提,占得多的往下压
PROCEDURE UUICache::Reclassify():
    FOR EACH E IN Entries:
        IF E.CostMs > 8.0 AND E.Tier == LRU:      E.Tier = Session
        IF E.Nodes  > 200 AND E.Tier == Session:  E.Tier = LRU
```

### 9.2 HUD 部件:不进 LRU

HUD 部件的集合是有限的,而且切换极频繁。用 LRU 会让它在「刚淘汰」和「马上要用」之间反复重建,命中率反而低。所以它不参与上面这套分级,自己归成一整组:域还在就一直留着,域走了整批放。

```text
// HUD 部件的生命周期跟着域走,不跟着 HUD 开关走
// 只要域还在,部件就一直留着;域摘掉时整批释放
PROCEDURE 域切走时():
    UUICache::Evict(DomainSwitch)     // 普通界面回落到只留常驻
    Hud->OnDomainSwitched()           // HUD 收到通知,自己整批放掉部件
```

实现篇的 `Meta.bCache` 只回答「关掉之后留不留」这一位;留多久、什么时候丢,由这里的等级决定。两者是上下层,不是两套机制。

---

## 十、生命周期:不做逐个关闭

定义:HUD 挂在 `Hud` 层,随所属的域一起活。它没有「关闭」这个动作,所以也不需要收尾流程。

```text
PROCEDURE Activate(Next : EUIDomain):
    // 旧域整树摘掉,HUD 跟着走,不走逐界面 Close
    FOR EACH D IN 副本(Active):
        IF IsSystemDomain(D.Domain): CONTINUE
        D.RootWidget->RemoveFromParent()
        Active.Remove(D.Domain)

    Active.Add(Next, BuildRuntime(Next))

// 域内界面开开关关,不影响 HUD
// HUD 只在两种时刻回收:域切换、游戏退出
```

这也是它和普通界面最大的区别:普通界面的资源在关闭时归还,HUD 的资源在域消失时归还。

---

## 十一、配置示例

把前面几节的字段落到具体界面上,就是一张表:

| 界面 | 允许情境 | 禁止情境 | 情境丢失时 | HUD 压制 |
|---|---|---|---|---|
| 技能升级面板 | Combat | — | Close | Dim |
| 战斗结算 | Combat | — | Close | Hide |
| 背包 | Explore, Vehicle | Combat, Cutscene | Defer | Hide |
| 世界地图 | Explore | Combat, Dialogue | Close | Hide |
| 载具改装 | Vehicle | Combat | Defer | Hide |
| 邮件详情 | 不限 | Combat, Cutscene, Dead | Close | Hide |
| 邮件到货提醒 | 不限 | Combat, Cutscene, Dead, Dialogue | Close | None |

前两行对应「普通时不能打开战斗专属界面」,最后一行对应「战斗中不能弹出邮件提醒」,用的是同一套机制,只是填的字段不同。

`Defer` 只给「玩家还想继续」的界面(背包、改装)。提醒和详情这类,`Close` 就够了:它们要么已经看过,要么下次会重新来。

---

## 十二、系统侧看哪几个数

定义:HUD 出问题时,系统侧只关心它和准入、压制、缓存三处的咬合是否正常。

```text
STRUCT FHudSystemStats
    BaseContext    : EHudContext            // 基础层,准入判断的主要依据
    Overlays       : TArray<EHudContext>    // 叠加层,与基础层一起判
    SuppressLevel  : EHudSuppress          // 当前压制档位
    SuppressDepth  : int32                 // 压制栈深度,深度不归零说明有界面没关
    WidgetNodes    : int32                 // HUD 部件总节点数,计入缓存预算
    RebuildPerHour : int32                 // 一小时内的重建次数,异常高说明等级划错了
```

「战斗结束了技能栏还在」这类问题,看 `BaseContext` 与 `SuppressLevel` 就知道是哪一层没收到通知;`SuppressDepth` 长期不归零,通常是某个全屏界面没走关闭路径。

posted on 2026-11-04 17:00  Fivee  阅读(2)  评论(0)    收藏  举报