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

523 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 扩展指南
### 添加新的游戏内面板(以图鉴 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<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*