Files
zeling_v2/Docs/Guides/05_UISystem_Architecture_Guide.md
Joywayer 9aaa2b6452 docs: 修正进度系统文档中虚构的技能树系统
将 10_Manual_ProgressionSystem.md 中不存在的 SkillTreeSO/技能点/技能树解锁
流程,改写为真实实现:技能(FormSkillSO)随形态由 FormController 注入 SkillManager,
施放消耗魂力/灵力;能力通过 AbilityType 位掩码解锁(PlayerStats/AbilityFlags)。
同步更正 MT-PROG-06 的 HasAbility/存档字段引用,并统一 05/07/11 文档措辞为
'形态技能一览(FormSkillPanel)',明确本项目无技能树。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:39:57 +08:00

23 KiB
Raw Blame History

UI 系统架构手册

文件位置:Docs/Guides/05_UISystem_Architecture_Guide.md
版本1.0 · 适用项目zeling_v2


目录

  1. 架构概览
  2. UIManager 面板栈
  3. GameState 驱动的 UI 显隐逻辑
  4. 主菜单流程MainMenuController
  5. 存档槽选择面板SaveSlotController
  6. Loading Screen 体验设计
  7. 游戏内覆盖层 UIPause / Death / HUD
  8. 输入焦点管理IFocusable
  9. Inspector 配置指南
  10. 扩展指南
  11. 商业对标评估
  12. 常见问题排查

1. 架构概览

UI 系统采用面板栈Panel Stack+ 游戏状态驱动的混合模式,将 UI 生命周期与游戏逻辑彻底解耦:

┌────────────────────────────────────────────────────────────────────┐
│  GameManagerFSM                                                │
│    └─ Raise EVT_GameStateChanged(GameStateId)                      │
└────────────────────────┬───────────────────────────────────────────┘
                         │ (SO 事件频道,跨程序集)
┌────────────────────────▼───────────────────────────────────────────┐
│  UIManagerIUIManager                                           │
│    ├─ 面板栈Stack<GameObject>                                  │
│    │    ├─ 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 面板栈

核心数据结构

private Stack<GameObject>      _panelStack;    // 当前打开的面板顺序
private HashSet<GameObject>    _openPanelSet;  // O(1) 成员检查
private Dictionary<PanelId, GameObject>        _panelRegistry;       // 静态面板注册表
private Dictionary<PanelId, AddressablePanelHandle> _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.9Addressables 下载)→ 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. 游戏内覆盖层 UIPause / 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 设计意图: 给死亡动画(击退、溶解特效)留出播放窗口,避免死亡画面在视觉上覆盖动画高光时刻。

HUDHUDController

HUD 挂载在 UIRoot/HUD Canvas,始终在最低优先级的 UI 层Sort Order 10

GameState → Gameplay / BossFight 时显示,其余状态隐藏。
具体字段配置参见 Docs/Design/UI_HUD_Spec.md(美术规范文档)。


8. 输入焦点管理IFocusable

面板实现 IFocusable 接口后,UIManager.CloseTopPanel() 会在弹出顶层面板后自动调用下层面板的 OnFocusRestored(),将 EventSystem 焦点恢复到该面板的默认第一个可交互元素。

public interface IFocusable
{
    void OnFocusRestored();   // 设置 EventSystem.SetSelectedGameObject(defaultButton)
}

接入示例PauseMenuController

public class PauseMenuController : MonoBehaviour, IFocusable
{
    [SerializeField] private Button _firstButton; // Inspector 中指定第一个按钮

    public void OnFocusRestored()
    {
        EventSystem.current.SetSelectedGameObject(_firstButton.gameObject);
    }
}

为什么需要 IFocusable

场景 不处理焦点 处理焦点
从设置面板退回暂停面板 无法通过手柄操作,焦点丢失 焦点自动回到暂停菜单的第一个按钮
死亡画面出现 需要手动移动手柄选中复活按钮 自动聚焦,可直接按确认键复活
PC 键盘操作 Tab 键循环焦点失效 始终从正确的第一个元素开始循环

9. Inspector 配置指南

UIManagerUIRoot GameObjectPersistent 场景)

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 为例)

说明:本项目没有技能树,技能随形态绑定(见 FormSkillPanel09_ProgressionModule)。下方以一个假想的"图鉴面板"演示通用接入流程。

  1. Assets/_Game/Scenes/Persistent.unityUIRoot 下新建 CodexRoot,初始 SetActive(false)
  2. PanelId 枚举中添加 Codex
  3. UIManager._panels 数组中注册:PanelId.Codex → CodexRoot
  4. 创建 EVT_CodexOpenVoidEventChannelSO并在 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 屏蔽

UIManagerEVT_GameStateChanged(Cutscene) 分支:

case GameStateId.Cutscene:
    _hudRoot.SetActive(false);
    CloseAllPanels();
    _inputBlocker.SetActive(true);  // 全屏透明 Raycast Target屏蔽所有点击输入
    break;

实现「按任意键继续」的过渡画面

UIManager 中监听 EVT_GameStateChanged(LoadingScene)

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 输入监听:

private void Update()
{
    if (_inputReader.IsCancelPressed && _panelStack.Count > 0)
        CloseTopPanel();
}

从主菜单返回游戏时 HUD 不显示

原因: EVT_GameStateChanged(Gameplay) 触发时,UIManagerOnEnable 尚未执行(场景还在加载)。
解决: 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 中预热特定面板:

await Addressables.DownloadDependenciesAsync("UI_Journal");

主菜单场景卸载后 MainMenuController 事件订阅没有清理,导致空引用报错

原因: OnEnable 中订阅了 SO 事件频道,但 OnDisable 中没有取消订阅,场景卸载后 SO 仍持有对已销毁对象的回调引用。
解决: 确保 MainMenuController.OnDisable 中对所有 _onXxx?.Unsubscribe(HandleXxx) 显式取消订阅,或使用 EventSubscription Dispose 模式(框架已提供):

private EventSubscription _stateSubscription;

private void OnEnable()
    => _stateSubscription = _onGameStateChanged.Subscribe(HandleGameStateChanged);

private void OnDisable()
    => _stateSubscription?.Dispose();

文档最后更新2026-06-04