游戏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` 长期不归零,通常是某个全屏界面没走关闭路径。
浙公网安备 33010602011771号