# UI 系统架构手册 > 文件位置:`Docs/Guides/05_UISystem_Architecture_Guide.md` > 版本:1.0 · 适用项目:zeling_v2 --- ## 目录 1. [架构概览](#1-架构概览) 2. [UIManager 面板栈](#2-uimanager-面板栈) 3. [GameState 驱动的 UI 显隐逻辑](#3-gamestate-驱动的-ui-显隐逻辑) 4. [主菜单流程(MainMenuController)](#4-主菜单流程mainmenucontroller) 5. [存档槽选择面板(SaveSlotController)](#5-存档槽选择面板saveslotcontroller) 6. [Loading Screen 体验设计](#6-loading-screen-体验设计) 7. [游戏内覆盖层 UI(Pause / Death / HUD)](#7-游戏内覆盖层-uipause--death--hud) 8. [输入焦点管理(IFocusable)](#8-输入焦点管理ifocusable) 9. [Inspector 配置指南](#9-inspector-配置指南) 10. [扩展指南](#10-扩展指南) 11. [商业对标评估](#11-商业对标评估) 12. [常见问题排查](#12-常见问题排查) --- ## 1. 架构概览 UI 系统采用**面板栈(Panel Stack)+ 游戏状态驱动**的混合模式,将 UI 生命周期与游戏逻辑彻底解耦: ``` ┌────────────────────────────────────────────────────────────────────┐ │ GameManager(FSM) │ │ └─ Raise EVT_GameStateChanged(GameStateId) │ └────────────────────────┬───────────────────────────────────────────┘ │ (SO 事件频道,跨程序集) ┌────────────────────────▼───────────────────────────────────────────┐ │ UIManager(IUIManager) │ │ ├─ 面板栈(Stack) │ │ │ ├─ OpenPanel(PanelId / GameObject) │ │ │ └─ CloseTopPanel() │ │ ├─ 状态驱动的根节点(HUDRoot / DeathScreenRoot) │ │ └─ 订阅各 Open/Close 事件频道(Pause / Map / Shop / Inventory …)│ └────────────────────────┬───────────────────────────────────────────┘ │ ┌───────────────┼───────────────────────────┐ ▼ ▼ ▼ 静态面板(预设) Addressable 面板(按需加载) 状态根节点 PauseMenuRoot InventoryPanel(异步) HUDRoot SettingsRoot MapPanel(异步) DeathScreenRoot ShopRoot … ``` **层级与渲染顺序(Sort Order):** | 层级 | Sort Order | 说明 | |---|---|---| | HUD Canvas | 10 | 永远在游戏世界之上 | | 菜单 / 面板 Canvas | 20 | 暂停、商店、符文等 | | Loading Screen Canvas | 99 | 覆盖所有游戏 UI | | Splash Screen Canvas | 100 | 仅在启动时显示 | | Scene Fade Overlay Canvas | 110 | 最顶层,场景切换遮罩 | --- ## 2. UIManager 面板栈 ### 核心数据结构 ```csharp private Stack _panelStack; // 当前打开的面板顺序 private HashSet _openPanelSet; // O(1) 成员检查 private Dictionary _panelRegistry; // 静态面板注册表 private Dictionary _addressableHandles; // 异步面板缓存 ``` ### 面板操作 API | 方法 | 说明 | |---|---| | `OpenPanel(PanelId id)` | 查注册表 → 若为 Addressable 则异步加载 → 激活 → 压栈 | | `OpenPanel(GameObject panel)` | 直接引用压栈(幂等:已在栈中则不重复压)| | `CloseTopPanel()` | 弹栈 → 隐藏 → 恢复下层 `IFocusable.OnFocusRestored()` | | `CloseAllPanels()` | 清空栈,隐藏所有面板 | | `RegisterPanel(PanelId, GameObject)` | 运行时动态注册(供场景级 UI 使用)| ### 面板栈行为规范 ``` 初始状态(Gameplay):栈空,HUDRoot 可见 玩家按暂停: OpenPanel(PanelId.Pause) Stack → [PauseMenuRoot] 玩家在暂停菜单打开设置: OpenPanel(PanelId.Settings) Stack → [PauseMenuRoot, SettingsRoot] 玩家按取消(Cancel 输入): CloseTopPanel() → 隐藏 SettingsRoot,恢复 PauseMenuRoot 焦点 Stack → [PauseMenuRoot] 玩家按恢复: CloseTopPanel() → 隐藏 PauseMenuRoot Stack → [](空) GameManager 响应 EVT_ResumeRequested → FSM: Paused → Gameplay ``` > **原则:** 面板栈只管 UI 层级,不管游戏状态。游戏状态转换始终由 `GameManager` 通过 FSM 负责。 > `UIManager.OpenPanel(Pause)` 和 `GameManager.TransitionTo(Paused)` 是两个独立的调用, > 它们分别由 `EVT_PauseRequested` 触发(UIManager 订阅开面板,GameManager 订阅切状态)。 --- ## 3. GameState 驱动的 UI 显隐逻辑 `UIManager` 订阅 `EVT_GameStateChanged`,根据新状态决定 HUD 和 DeathScreen 的可见性: ``` EVT_GameStateChanged(GameStateId state) │ ├─ Gameplay / BossFight → HUDRoot.SetActive(true) DeathScreenRoot.SetActive(false) ├─ Dead → HUDRoot.SetActive(false) DeathScreenRoot.SetActive(true) ├─ Paused → 不改变(保持 HUD 可见,仅叠加暂停面板) ├─ MainMenu → HUDRoot.SetActive(false) DeathScreenRoot.SetActive(false) │ CloseAllPanels() ├─ LoadingScene → HUDRoot.SetActive(false) DeathScreenRoot.SetActive(false) └─ Cutscene → HUDRoot.SetActive(false)(不显示 UI) ``` **UIManager 不处理哪些事情:** - 不决定是否进入 Paused 状态(那是 GameManager 的职责) - 不监听场景加载事件(那是 SceneService 的职责) - 不直接修改 GameStateMachine(只通过 Raise 事件频道间接驱动) --- ## 4. 主菜单流程(MainMenuController) `MainMenuController` 挂载在 `Scene_MainMenu` 的 Canvas 上,随场景一起加载/卸载。 ### 完整状态机(MainMenuController 内部) ``` [初始化] │ └─ EVT_GameStateChanged(MainMenu) 到达 │ ├─ 播放按钮组入场动画(_mainButtonsRect 下移 → 回弹) └─ RefreshContinueButton() └─ ISaveService.HasAnySave() → 「继续」按钮是否可点击 ───────────────────────────── 按钮点击逻辑: Btn_NewGame → _saveSlotController.SetMode(NewGame) _saveSlotPanel.SetActive(true) Btn_Continue → _saveSlotController.SetMode(Continue) _saveSlotPanel.SetActive(true) Btn_Settings → _settingsPanel.SetActive(true) Btn_Credits → _creditsPanel.SetActive(true) Btn_Quit → Application.Quit() ───────────────────────────── EVT_SlotConfirmed(slotIndex) 到达(由 SaveSlotController 发布) │ ├─ _saveSlotPanel.SetActive(false) ├─ _currentSlot = slotIndex └─ Raise EVT_SceneLoadRequest({ Key: _firstGameSceneKey, Type: Scene, ShowLoading: true }) └─ 游戏场景加载流程启动(参见 01_BootFlow_Setup_Guide.md 第 2 节) ``` ### Inspector 字段速查(MainMenuController) | 字段 | 类型 | 说明 | |---|---|---| | `_firstGameSceneKey` | `string` | 第一个游戏场景的 Addressable Key(**必填**)| | `_onGameStateChanged` | GameStateEventChannelSO | 监听 FSM 状态变化 | | `_onSceneLoadRequest` | SceneLoadRequestEventChannelSO | 发布场景加载请求 | | `_onSlotConfirmed` | IntEventChannelSO | 监听存档槽确认 | | `_mainButtonsGroup` | `CanvasGroup` | 入场动画目标 | | `_mainButtonsRect` | `RectTransform` | 入场位移动画目标 | | `_saveSlotPanel` | `GameObject` | 存档槽选择面板根节点 | | `_settingsPanel` | `GameObject` | 设置面板根节点 | | `_creditsPanel` | `GameObject` | 制作人员表面板根节点 | | `_saveSlotController` | `SaveSlotController` | 存档槽逻辑控制器 | | `_btnNewGame/Continue/Settings/Credits/Quit` | `Button` | 各主按钮引用 | --- ## 5. 存档槽选择面板(SaveSlotController) ### 模式与交互逻辑 ``` SaveSlotController.SetMode(mode) │ ├─ mode = Continue │ ├─ 有存档的槽:可点击 → LoadAsync(slot) → Raise EVT_SlotConfirmed(slot) │ └─ 空槽:灰显不可交互 │ └─ mode = NewGame ├─ 空槽点击 ────────────────────────────────────► NewGameModeController.Show() │ 玩家选择普通 / 钢铁之魂 │ → ISaveService.CreateSlot(slot, steelSoul) │ → Raise EVT_SlotConfirmed(slot) │ └─ 有存档的槽点击 → ConfirmDialogController.Show("是否覆盖?") │ ├─ 确认 ────────────────────► NewGameModeController.Show()(同上) └─ 取消 ────────────────────► 关闭 ConfirmDialog,回到槽列表 ``` ### SaveSlotUI 卡片渲染 每张存档卡调用 `ISaveService.GetSlotSummaryAsync(i)` 刷新,渲染规则: | 状态 | 显示内容 | |---|---| | `HasData = false` | 「空存档」占位,Continue 模式下灰显 | | `HasData = true, IsSteelSoul = false` | 时长 + 位置 + 零珠 + HP | | `HasData = true, IsSteelSoul = true` | 同上,叠加钢铁之魂特殊徽章与边框 | --- ## 6. Loading Screen 体验设计 ### 视觉层次结构(LoadingScreenManager) ``` Canvas_Loading (Sort Order 99) └── LoadingRoot(默认 SetActive=false) ├── BackgroundImage[] ← 随机显示一张艺术图 ├── ProgressBarFill ← Image.Filled, FillMethod=Horizontal └── TipText ← TextMeshProUGUI,显示随机本地化提示 ``` ### 体验节奏控制 | 参数 | 默认值 | 说明 | |---|---|---| | `_minDisplayTime` | `0.5s` | 防止快速设备上的 Loading Screen 一闪而过(视觉抖动)| | 进度更新频率 | 每帧( `Update` 中 Raise)| 进度条连续平滑 | | 进度范围 | 0 → 0.9(Addressables 下载)→ 1.0(加载完成)| 避免进度条长时间卡在 100% | ### 进度数据流 ``` SceneLoader(每帧轮询 AsyncOperationHandle) └─ Raise EVT_LoadingProgressUpdated(handle.PercentComplete * 0.9f) └─ LoadingScreenManager.SetProgress(value) └─ _progressFill.fillAmount = value 加载完成: SceneLoader.Raise(EVT_LoadingProgressUpdated, 1.0f) SceneLoader.Raise(EVT_LoadingComplete) └─ LoadingScreenManager.StartCoroutine(HideAfterMinTime()) └─ yield WaitForSeconds(remainingMinTime) └─ _loadingRoot.SetActive(false) ``` --- ## 7. 游戏内覆盖层 UI(Pause / Death / HUD) ### 暂停菜单(PauseMenuController) 挂载在 `UIRoot/PauseMenuRoot`,随 Persistent 场景常驻。 | 按钮 | 行为 | 事件 | |---|---|---| | 恢复 | Raise EVT_ResumeRequested → GameManager FSM 切回 Gameplay | VoidEventChannelSO | | 设置 | UIManager.OpenPanel(PanelId.Settings)(面板栈叠加)| — | | 返回主菜单 | Raise EVT_SceneLoadRequest({ Scene_MainMenu, Scene, NoLoading })| SceneLoadRequestEventChannelSO | | 退出游戏 | Application.Quit() | — | ### 死亡画面(DeathScreenController) ``` EVT_GameStateChanged(Dead) 到达 └─ UIManager 激活 DeathScreenRoot └─ DeathScreenController.OnEnable() └─ StartCoroutine(ShowAfterDelay(_showDelay=1.5s)) └─ 1.5 秒后:显示复活按钮 + 设置焦点 玩家点击复活按钮: DeathScreenController.Raise(EVT_DeathScreenConfirmed) └─ DeathRespawnService.StartRespawnCoroutine() └─ 从最近检查点复活 ``` **`_showDelay` 设计意图:** 给死亡动画(击退、溶解特效)留出播放窗口,避免死亡画面在视觉上覆盖动画高光时刻。 ### HUD(HUDController) HUD 挂载在 `UIRoot/HUD Canvas`,始终在最低优先级的 UI 层(Sort Order 10)。 GameState → Gameplay / BossFight 时显示,其余状态隐藏。 具体字段配置参见 `Docs/Design/UI_HUD_Spec.md`(美术规范文档)。 --- ## 8. 输入焦点管理(IFocusable) 面板实现 `IFocusable` 接口后,`UIManager.CloseTopPanel()` 会在弹出顶层面板后自动调用下层面板的 `OnFocusRestored()`,将 EventSystem 焦点恢复到该面板的默认第一个可交互元素。 ```csharp public interface IFocusable { void OnFocusRestored(); // 设置 EventSystem.SetSelectedGameObject(defaultButton) } ``` **接入示例(PauseMenuController):** ```csharp public class PauseMenuController : MonoBehaviour, IFocusable { [SerializeField] private Button _firstButton; // Inspector 中指定第一个按钮 public void OnFocusRestored() { EventSystem.current.SetSelectedGameObject(_firstButton.gameObject); } } ``` **为什么需要 IFocusable:** | 场景 | 不处理焦点 | 处理焦点 | |---|---|---| | 从设置面板退回暂停面板 | 无法通过手柄操作,焦点丢失 | 焦点自动回到暂停菜单的第一个按钮 | | 死亡画面出现 | 需要手动移动手柄选中复活按钮 | 自动聚焦,可直接按确认键复活 | | PC 键盘操作 | Tab 键循环焦点失效 | 始终从正确的第一个元素开始循环 | --- ## 9. Inspector 配置指南 ### UIManager(`UIRoot` GameObject,Persistent 场景) | Inspector 字段 | 赋值 | 说明 | |---|---|---| | `_hudRoot` | `UIRoot/HUD Canvas` | HUD 根节点 | | `_deathScreenRoot` | `UIRoot/DeathScreen Canvas` | 死亡画面根节点 | | `_panels` | 数组(4项)| 静态面板注册:Pause / Settings / Map / Shop | | `_onGameStateChanged` | `EVT_GameStateChanged` | 订阅状态变化 | | `_onPauseRequested` | `EVT_PauseRequested` | 订阅暂停请求 | | `_onMapOpen` | `EVT_MapOpen`(可选)| 订阅地图打开请求 | | `_onShopOpen` | `EVT_ShopOpen`(可选)| 订阅商店打开请求 | **`_panels` 数组元素格式:** | PanelId | Root GameObject | |---|---| | `Pause` | `UIRoot/PauseMenuRoot` | | `Settings` | `UIRoot/SettingsRoot` | | `Map` | `UIRoot/MapRoot` | | `Shop` | `UIRoot/ShopRoot` | ### 事件频道速查(UI 相关) | SO 名称 | 类型 | 发布者 | 订阅者 | |---|---|---|---| | `EVT_GameStateChanged` | GameStateEventChannelSO | GameManager | UIManager / MainMenuController | | `EVT_PauseRequested` | VoidEventChannelSO | InputReader | GameManager / UIManager | | `EVT_ResumeRequested` | VoidEventChannelSO | PauseMenuController | GameManager | | `EVT_DeathScreenConfirmed` | VoidEventChannelSO | DeathScreenController | DeathRespawnService | | `EVT_SlotConfirmed` | IntEventChannelSO | SaveSlotController | MainMenuController | | `EVT_MapOpen`(可选) | VoidEventChannelSO | InputReader / HUD 按钮 | UIManager | | `EVT_ShopOpen`(可选) | VoidEventChannelSO | NPC 交互 | UIManager | --- ## 10. 扩展指南 ### 添加新的游戏内面板(以图鉴 Codex 为例) > 说明:本项目**没有技能树**,技能随形态绑定(见 `FormSkillPanel` 与 `09_ProgressionModule`)。下方以一个假想的"图鉴面板"演示通用接入流程。 1. 在 `Assets/_Game/Scenes/Persistent.unity` 的 `UIRoot` 下新建 `CodexRoot`,初始 `SetActive(false)`。 2. 在 `PanelId` 枚举中添加 `Codex`。 3. 在 `UIManager._panels` 数组中注册:`PanelId.Codex → CodexRoot`。 4. 创建 `EVT_CodexOpen`(VoidEventChannelSO)并在 `UIManager.OnEnable` 中订阅 `_onCodexOpen → () => OpenPanel(PanelId.Codex)`。 5. 在 `InputReader` 中添加 `Codex` 输入动作,触发时 Raise `EVT_CodexOpen`。 ### 使用 Addressable 按需加载大型面板 适合一次性使用的大型面板(如藏品图鉴、成就列表),避免 Persistent 场景启动内存占用过高: 1. 将面板 Prefab 标记为 Addressable,设置 Address 为 `"UI_Journal"`。 2. 在 `UIManager._addressablePanels` 中注册:`PanelId.Journal → Address: "UI_Journal"`。 3. 调用 `UIManager.OpenPanel(PanelId.Journal)`——内部自动执行 `Addressables.LoadAssetAsync → Instantiate → 压栈`。 4. 已加载的 Handle 会被缓存(`_addressableHandles`),第二次打开无需重新下载。 ### 添加过场动画期间的 UI 屏蔽 在 `UIManager` 的 `EVT_GameStateChanged(Cutscene)` 分支: ```csharp case GameStateId.Cutscene: _hudRoot.SetActive(false); CloseAllPanels(); _inputBlocker.SetActive(true); // 全屏透明 Raycast Target,屏蔽所有点击输入 break; ``` ### 实现「按任意键继续」的过渡画面 在 `UIManager` 中监听 `EVT_GameStateChanged(LoadingScene)`: ```csharp case GameStateId.LoadingScene: // 等待输入后才真正触发场景加载(视觉节奏控制) StartCoroutine(WaitForAnyKeyThenLoad()); break; ``` --- ## 11. 商业对标评估 以下评估以"丝之歌"级别商业 2D 动作游戏(Steam/主机发行标准)为基准,逐项审核当前 UI 系统的完备性与玩家体验成熟度。 ### ✅ 已达到商业标准 | 维度 | 当前实现 | 评价 | |---|---|---| | **面板栈管理** | Stack + O(1) 成员检查 | 支持任意深度的嵌套面板,避免重复打开 | | **状态驱动 UI** | EVT_GameStateChanged 统一驱动 HUD/Death 可见性 | UI 层零侵入游戏逻辑 | | **组件-事件解耦** | UIManager/MainMenuController 不持有 GameManager 直接引用 | Core 程序集不依赖 UI 程序集(反向依赖零) | | **Loading 防闪烁** | `_minDisplayTime = 0.5s` | 消除快速设备上的 Loading Screen 闪烁问题 | | **加载进度可视化** | 进度 0→1 连续更新,0.9 上限留出 100% 假象 | 与主流 AAA 游戏相同的用户感知优化 | | **死亡节奏控制** | `_showDelay = 1.5s` 延迟显示复活按钮 | 保护死亡动画的视觉高光,符合《空洞骑士》级别体验 | | **Addressable 面板** | 异步加载 + Handle 缓存 | 大型面板不占用启动内存 | ### ⚠️ 建议改进项 | 维度 | 当前状态 | 建议 | |---|---|---| | **手柄导航完整性** | IFocusable 接口已定义,但未见所有面板都实现 | 对 Pause / Death / Settings / SaveSlot 的每个面板补全 `OnFocusRestored`,主机认证前必须测试全程无鼠标操作 | | **输入方案自适应 UI** | 未见键盘 vs 手柄按钮图标自动切换 | 建议监听 `InputSystem.onActionChange` 事件,切换 Sprite Atlas(键盘: `[Space]` → 手柄: `[A]`)| | **主菜单背景** | 按钮组有入场动画,但无背景动效 | 商业发布版本通常有视差滚动背景或粒子动效,配合 BGM 形成视听配合 | | **存档槽截图预览** | 当前槽卡片无截图 | 补全后与 `04_SaveSystem_DataPersistence_Guide.md` 第 9 节「存档截图预览」联动 | | **过渡动画统一管理** | 各面板自行处理 SetActive,无统一的打开/关闭动画 | 建议在 UIManager.OpenPanel / CloseTopPanel 中统一调用 DOTween / LeanTween 淡入淡出,保持全局一致的 UI 节奏感 | | **Loading 提示文字本地化** | `_tipMessages` 为 string[] 存储 Key,需手动填写 | 建议改为 `LocalizedStringTable` 引用,确保多语言运行时不遗漏 | ### ❌ 商业发行前必须补全 | 维度 | 当前状态 | 影响 | |---|---|---| | **主机 UI 合规(TRC/TCR)** | 未见针对 PS5/Xbox Series 的按钮提示图标覆盖 | Sony TRC 要求所有交互提示图标与手柄型号实时匹配(DualSense vs DualShock 4) | | **无障碍选项** | 未见字体大小调整、色盲模式、振动开关等 | Steam 无障碍标签与主机认证的基础要求;建议至少提供字体缩放和高对比模式 | | **ESC / 返回键全局处理** | 未见统一的 Cancel 输入处理器 | 当前各面板自行响应 Cancel,可能出现 ESC 无法关闭某个面板的问题;建议在 UIManager 层统一处理 Cancel → CloseTopPanel | --- ## 12. 常见问题排查 ### ❌ 暂停后按 ESC/Cancel 无法关闭设置面板,只能关闭暂停菜单 **原因:** 设置面板没有订阅 `Cancel` 输入动作,`UIManager` 也没有全局 Cancel 处理。 **解决:** 在 `UIManager.Update()` 中添加全局 Cancel 输入监听: ```csharp private void Update() { if (_inputReader.IsCancelPressed && _panelStack.Count > 0) CloseTopPanel(); } ``` --- ### ❌ 从主菜单返回游戏时 HUD 不显示 **原因:** `EVT_GameStateChanged(Gameplay)` 触发时,`UIManager` 的 `OnEnable` 尚未执行(场景还在加载)。 **解决:** `UIManager.OnEnable` 必须在 Persistent 场景中运行(它始终存在),不应随主菜单场景卸载。检查 `UIManager` 是否错误地挂载在主菜单场景的 Canvas 上而非 Persistent 场景的 `UIRoot` 上。 --- ### ❌ 手柄操作时焦点在面板打开后丢失(无高亮按钮) **原因:** 面板没有实现 `IFocusable`,或实现了但 `_firstButton` 为空引用。 **解决:** 1. 确认面板组件实现了 `IFocusable`。 2. 在 Inspector 中为 `_firstButton` 赋值(面板打开时应选中的默认按钮)。 3. 确保面板在 `SetActive(true)` 后的下一帧 `OnEnable` 调用 `EventSystem.SetSelectedGameObject`(使用 `StartCoroutine` 延迟一帧执行,避免 EventSystem 还未刷新)。 --- ### ❌ Addressable 面板第一次打开慢,之后快 **行为正常。** 第一次调用 `Addressables.LoadAssetAsync` 会进行网络/磁盘下载,之后 Handle 被缓存。 如果希望消除首次延迟,可在游戏启动的 `PreloadCoroutine` 中预热特定面板: ```csharp await Addressables.DownloadDependenciesAsync("UI_Journal"); ``` --- ### ❌ 主菜单场景卸载后 MainMenuController 事件订阅没有清理,导致空引用报错 **原因:** `OnEnable` 中订阅了 SO 事件频道,但 `OnDisable` 中没有取消订阅,场景卸载后 SO 仍持有对已销毁对象的回调引用。 **解决:** 确保 `MainMenuController.OnDisable` 中对所有 `_onXxx?.Unsubscribe(HandleXxx)` 显式取消订阅,或使用 `EventSubscription` Dispose 模式(框架已提供): ```csharp private EventSubscription _stateSubscription; private void OnEnable() => _stateSubscription = _onGameStateChanged.Subscribe(HandleGameStateChanged); private void OnDisable() => _stateSubscription?.Dispose(); ``` --- *文档最后更新:2026-06-04*