将 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>
23 KiB
UI 系统架构手册
文件位置:
Docs/Guides/05_UISystem_Architecture_Guide.md
版本:1.0 · 适用项目:zeling_v2
目录
- 架构概览
- UIManager 面板栈
- GameState 驱动的 UI 显隐逻辑
- 主菜单流程(MainMenuController)
- 存档槽选择面板(SaveSlotController)
- Loading Screen 体验设计
- 游戏内覆盖层 UI(Pause / Death / HUD)
- 输入焦点管理(IFocusable)
- Inspector 配置指南
- 扩展指南
- 商业对标评估
- 常见问题排查
1. 架构概览
UI 系统采用面板栈(Panel Stack)+ 游戏状态驱动的混合模式,将 UI 生命周期与游戏逻辑彻底解耦:
┌────────────────────────────────────────────────────────────────────┐
│ GameManager(FSM) │
│ └─ Raise EVT_GameStateChanged(GameStateId) │
└────────────────────────┬───────────────────────────────────────────┘
│ (SO 事件频道,跨程序集)
┌────────────────────────▼───────────────────────────────────────────┐
│ UIManager(IUIManager) │
│ ├─ 面板栈(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.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 焦点恢复到该面板的默认第一个可交互元素。
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 配置指南
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)。下方以一个假想的"图鉴面板"演示通用接入流程。
- 在
Assets/_Game/Scenes/Persistent.unity的UIRoot下新建CodexRoot,初始SetActive(false)。 - 在
PanelId枚举中添加Codex。 - 在
UIManager._panels数组中注册:PanelId.Codex → CodexRoot。 - 创建
EVT_CodexOpen(VoidEventChannelSO)并在UIManager.OnEnable中订阅_onCodexOpen → () => OpenPanel(PanelId.Codex)。 - 在
InputReader中添加Codex输入动作,触发时 RaiseEVT_CodexOpen。
使用 Addressable 按需加载大型面板
适合一次性使用的大型面板(如藏品图鉴、成就列表),避免 Persistent 场景启动内存占用过高:
- 将面板 Prefab 标记为 Addressable,设置 Address 为
"UI_Journal"。 - 在
UIManager._addressablePanels中注册:PanelId.Journal → Address: "UI_Journal"。 - 调用
UIManager.OpenPanel(PanelId.Journal)——内部自动执行Addressables.LoadAssetAsync → Instantiate → 压栈。 - 已加载的 Handle 会被缓存(
_addressableHandles),第二次打开无需重新下载。
添加过场动画期间的 UI 屏蔽
在 UIManager 的 EVT_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) 触发时,UIManager 的 OnEnable 尚未执行(场景还在加载)。
解决: UIManager.OnEnable 必须在 Persistent 场景中运行(它始终存在),不应随主菜单场景卸载。检查 UIManager 是否错误地挂载在主菜单场景的 Canvas 上而非 Persistent 场景的 UIRoot 上。
❌ 手柄操作时焦点在面板打开后丢失(无高亮按钮)
原因: 面板没有实现 IFocusable,或实现了但 _firstButton 为空引用。
解决:
- 确认面板组件实现了
IFocusable。 - 在 Inspector 中为
_firstButton赋值(面板打开时应选中的默认按钮)。 - 确保面板在
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