地图系统

This commit is contained in:
2026-06-05 18:41:33 +08:00
parent 613f2a4d13
commit fe4fd60083
234 changed files with 33090 additions and 4899 deletions

View File

@@ -0,0 +1,520 @@
# 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. [游戏内覆盖层 UIPause / 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 生命周期与游戏逻辑彻底解耦:
```
┌────────────────────────────────────────────────────────────────────┐
│ 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 面板栈
### 核心数据结构
```csharp
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 焦点恢复到该面板的默认第一个可交互元素。
```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` 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. 扩展指南
### 添加新的游戏内面板(如技能树)
1.`Assets/_Game/Scenes/Persistent.unity``UIRoot` 下新建 `SkillTreeRoot`,初始 `SetActive(false)`
2.`PanelId` 枚举中添加 `SkillTree`
3.`UIManager._panels` 数组中注册:`PanelId.SkillTree → SkillTreeRoot`
4. 创建 `EVT_SkillTreeOpen`VoidEventChannelSO并在 `UIManager.OnEnable` 中订阅 `_onSkillTreeOpen → () => OpenPanel(PanelId.SkillTree)`
5.`InputReader` 中添加 `SkillTree` 输入动作,触发时 Raise `EVT_SkillTreeOpen`
### 使用 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<GameObject> + 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*