# 10 · UI 系统 > **命名空间** `BaseGames.UI` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.Combat`(Boss HP)· TextMeshPro · DOTween(P1 过渡动画) --- ## 目录 1. [设计原则](#1-设计原则) 2. [Canvas 架构](#2-canvas-架构) 3. [UIManager](#3-uimanager) 4. [HUD 组件](#4-hud-组件) 5. [Boss HP 条](#5-boss-hp-条) 6. [主菜单(MainMenu)](#6-主菜单mainmenu) 7. [Pause 菜单](#7-pause-菜单) 8. [死亡画面](#8-死亡画面) 9. [加载过渡遮罩](#9-加载过渡遮罩) 10. [设置菜单](#10-设置菜单) 11. [UI 事件频道](#11-ui-事件频道) 12. [编辑器友好设计](#12-编辑器友好设计) 13. [加载画面(Loading Screen)](#13-加载画面loading-screen) 14. [无障碍设计(Accessibility)](#14-无障碍设计accessibility) 15. [UIManager 解耦改进说明](#15-uimanager-解耦改进说明) 16. [存档点交互 & 存档槽选择界面](#16-存档点交互--存档槽选择界面) 17. [存档进行中指示(Save Indicator)](#17-存档进行中指示save-indicator) 18. [通知系统(Toast / 成就 / 任务)](#18-通知系统toast--成就--任务) 19. [HUD 扩展:工具槽 & 形态指示](#19-hud-扩展工具槽--形态指示) 20. [全屏功能界面索引](#20-全屏功能界面索引) 21. [UI 音效系统](#21-ui-音效系统) 22. [控制器导航 & 面板栈](#22-控制器导航--面板栈) 23. [输入设备图标自动切换](#23-输入设备图标自动切换) 24. [浮动战斗文字(伤害数字)](#24-浮动战斗文字伤害数字) 25. [HUD 自适应可见性 & 过场隐藏](#25-hud-自适应可见性--过场隐藏) 26. [安全区(Safe Area)适配](#26-安全区safe-area适配) 27. [首次启动序列(Logo & 法务公告)](#27-首次启动序列logo--法务公告) 28. [PC 平台光标管理](#28-pc-平台光标管理) 29. [本地化深度集成](#29-本地化深度集成) --- ## 1. 设计原则 - **状态驱动**:`UIManager` 监听 `GameState` 变化,自动激活/隐藏对应 Canvas Root,由中央管理器决定何时显示什么 - **像素风适配**:UI 字体使用 TextMeshPro + 像素字体,UI 元素整数像素对齐,Canvas Scaler 使用 `Scale With Screen Size`(参考分辨率 1920×1080) - **零耦合**:HUD 只订阅事件频道(`OnPlayerHPChanged`、`OnPlayerSoulChanged`),不持有 `PlayerStats` 直接引用 - **分层 Canvas**:HUD / Menu / Overlay 三个独立 Canvas,各自管理 Sorting Order,互不干扰 --- ## 2. Canvas 架构 所有 UI Canvas 挂载在 **Persistent 场景**中,随游戏全程常驻: ``` [UI Root] ← Persistent 场景内 ├── Canvas_HUD Sorting Order: 10 (Screen Space - Overlay) ├── Canvas_Menu Sorting Order: 20 (Screen Space - Overlay) └── Canvas_Overlay Sorting Order: 30 (Screen Space - Overlay,最顶层) ``` ### Canvas_HUD 子结构 ``` Canvas_HUD ├── HUDPanel (RectTransform: 全屏铺满) │ ├── HPContainer (左上角,心形容器列表,水平布局组) │ ├── SoulGauge (下方居中,弧形灵魂槽) │ ├── GeoCounter (右上角,Geo 图标 + TextMeshPro 数字) │ └── AbilityHint (右下角,P1:当前解锁能力图标列) └── BossHPBar (下方居中,默认隐藏,Boss 战激活) ├── BossNameText (TextMeshPro) ├── HPBarBackground (Image) ├── HPBarFill (Image,FillAmount 驱动) └── PhaseMarkers (P1:阶段分割线列表) ``` ### Canvas_Menu 子结构 ``` Canvas_Menu ├── MainMenuPanel (默认激活) │ ├── TitleImage │ ├── MenuButtonGroup (垂直布局) │ └── VersionText ├── PauseMenuPanel (默认隐藏) ├── DeathScreenPanel (默认隐藏) └── SettingsPanel (默认隐藏,从主菜单/暂停菜单叠加打开) ``` ### Canvas_Overlay 子结构 ``` Canvas_Overlay ├── LoadingOverlay (黑色全屏 Image,默认 alpha=0) │ └── LoadingSpinner (P1:旋转加载动画) └── DialogueBox (底部,默认隐藏;见 15_DialogueSystem.md) ├── SpeakerNameText ├── DialogueText └── ContinuePrompt ``` --- ## 3. UIManager `UIManager` 常驻 Persistent 场景,订阅 `GameState` 变化事件并驱动 Canvas 显隐。 ### 类结构 ```csharp namespace BaseGames.UI { [DefaultExecutionOrder(+50)] public class UIManager : MonoBehaviour { [Header("Canvas Roots")] [SerializeField] GameObject _hudRoot; [SerializeField] GameObject _pauseMenuRoot; [SerializeField] GameObject _mainMenuRoot; [SerializeField] GameObject _deathScreenRoot; [SerializeField] GameObject _settingsPanel; [SerializeField] LoadingOverlay _loadingOverlay; [Header("Event Channels")] [SerializeField] GameStateEventChannelSO _onGameStateChanged; // 公开接口(供 GameManager Coroutine 调用) public IEnumerator FadeIn() => _loadingOverlay.FadeIn(); public IEnumerator FadeOut() => _loadingOverlay.FadeOut(); public void OpenSettings() => _settingsPanel.SetActive(true); public void CloseSettings() => _settingsPanel.SetActive(false); void OnEnable() => _onGameStateChanged.OnEventRaised += HandleGameStateChanged; void OnDisable() => _onGameStateChanged.OnEventRaised -= HandleGameStateChanged; void HandleGameStateChanged(GameState newState) { _hudRoot.SetActive(newState is GameState.Gameplay or GameState.BossFight); _pauseMenuRoot.SetActive(newState == GameState.Paused); _mainMenuRoot.SetActive(newState == GameState.MainMenu); _deathScreenRoot.SetActive(newState == GameState.Dead); } } } ``` ### GameStateEventChannelSO 新增事件频道类型(`BaseGames.Core.Events`): ```csharp [CreateAssetMenu(menuName = "Events/GameState Channel")] public class GameStateEventChannelSO : ScriptableObject { public event Action OnEventRaised; public void Raise(GameState state) => OnEventRaised?.Invoke(state); } ``` --- ## 4. HUD 组件 ### 4.1 HP 心形容器(HPContainer) HP 以**心形图标列**表示,而非连续血条(像素风标准做法): ```csharp public class HPContainer : MonoBehaviour { [SerializeField] IntEventChannelSO _onHPChanged; [SerializeField] IntEventChannelSO _onMaxHPChanged; [SerializeField] GameObject _heartPrefab; // 完整心 Sprite [SerializeField] Sprite _heartEmptySprite; // 空心 Sprite(替换 Image.sprite) // Start() → 根据 SaveData.maxHP 生成心形图标列表 // OnHPChanged(int newHP) → 更新填充/空心数量,对应位置播放消失动画 // OnMaxHPChanged(int newMax) → 动态添加新心形图标(Heart Container 升级时) } ``` | 状态 | 显示 | 动画 | |------|------|------| | `HP == MaxHP` | 全部填充心 | 无 | | `HP < MaxHP` | 右侧显示空心 | 受伤时对应心图标 Shake(`MMF_Position`)| | `HP == 0` | 全部空心 | 最后一颗心 Scale 缩小到 0(`MMF_Scale`)| | MaxHP 增加 | 新心从右侧弹入 | 弹入动画(`MMF_Scale` 0→1)| 行宽规则:最多 8 个心/行,超出自动换行(最多 3 行,即 MaxHP 上限 24)。 --- ### 4.2 Soul 灵魂槽(SoulGauge) 弧形进度条,`Image.type = Filled`,`Fill Method = Radial 360`: ``` SoulGauge (Image) fillAmount = Soul / MaxSoul(0.0 ~ 1.0) 颜色渐变: 0% → 深蓝灰 (#1A1A2E) 50% → 蓝色 (#4A90E2) 100% → 亮蓝白 (#8BE8FF) 满槽特效: MMF_Flash 白色光晕 0.1s(订阅 OnPlayerSoulChanged 满时触发) ``` 订阅 `OnPlayerSoulChanged.asset (IntEventChannelSO)`,每帧不直接 Lerp,而是在收到事件时**立即更新** fillAmount(像素风不需要血条追尾)。 --- ### 4.3 Geo 计数器(GeoCounter) ``` GeoCounter ├── GeoIcon (Sprite Image,Geo 硬币图标) └── GeoText (TextMeshPro,白色 + 黑色描边,像素字体) 拾取弹出:FloatingText("+N")从 GeoIcon 位置飞出并淡出(ObjectPool 驱动) 数字更新:直接 SetText,不做 Tween(像素风即时更新风格) ``` 订阅 `OnPlayerGeoChanged.asset (IntEventChannelSO)`。 --- ## 5. Boss HP 条 Boss 战开始时从屏幕下方滑入,Boss 死亡后延迟 1s 淡出隐藏: ```csharp public class BossHPBar : MonoBehaviour { [SerializeField] BoolEventChannelSO _onBossFightToggled; // true=开始,false=结束 [SerializeField] IntEventChannelSO _onBossHPChanged; [SerializeField] StringEventChannelSO _onBossNameSet; [SerializeField] Image _hpBarFill; [SerializeField] TMP_Text _bossNameText; [SerializeField] RectTransform _barRoot; int _maxHP; void OnBossFightToggled(bool started) { if (started) StartCoroutine(SlideIn()); // barRoot.anchoredPosition.y: -80 → 0(0.4s) else StartCoroutine(SlideOut()); // 延迟 1s → y: 0 → -80(0.3s) } void OnBossHPChanged(int hp) => _hpBarFill.fillAmount = (float)hp / _maxHP; } ``` **P1 扩展**:`PhaseMarkers`——根据 `BossPhaseConfigSO` 的 HP 阈值在 HP 条上绘制分割竖线(`UI.Image` 子对象,按比例定位)。 --- ## 6. 主菜单(MainMenu) ### 布局 ``` MainMenuPanel ├── TitleImage (像素艺术标题图,居中偏上) ├── MenuButtonGroup (垂直布局,居中) │ ├── Button_NewGame │ ├── Button_Continue (无存档时 interactable=false,颜色变灰) │ ├── Button_Settings │ └── Button_Quit └── VersionText (右下角,TextMeshPro 小字) ``` ### 存档槽选择 点击 Continue 时,若存在多存档槽(最多 3 个),弹出存档槽选择面板: ``` SaveSlotPanel (叠在 MainMenuPanel 上方) ├── SaveSlot_01 [区域名 | 游戏时长 | HP容器数 | 进度%] ├── SaveSlot_02 ├── SaveSlot_03 └── Button_Back ``` 单存档模式(MVP):只有 1 个槽,Continue 直接加载,不弹面板。 ### 按钮响应 ``` Button_NewGame → SaveManager.DeleteSave() → GameManager.StartNewGame() Button_Continue → GameManager.LoadGame(slotIndex) Button_Settings → UIManager.OpenSettings() Button_Quit → Application.Quit() ``` --- ## 7. Pause 菜单 通过 `InputReaderSO.PauseEvent`(`Escape` / 手柄 `Start`)触发: ``` PauseMenuPanel ├── PauseTitle ("- PAUSED -",像素字体) ├── ButtonGroup │ ├── Button_Resume → GameManager.ResumeGame() │ ├── Button_Settings → UIManager.OpenSettings() │ └── Button_QuitToMenu → 弹出确认对话框 → GameManager.QuitToMainMenu() └── InputHints (底部图标提示:"[ESC] 继续 [Q] 退出") ``` **时间缩放**:Pause 时 `Time.timeScale = 0`。所有菜单 `MMF_Player` 设置 `TimescaleIndependent = true`(Feel 已内置该选项),保证菜单弹入动画不受暂停影响。 --- ## 8. 死亡画面 ``` DeathScreenPanel (全屏黑底 CanvasGroup,alpha 从 0 渐入到 1) ├── DeathText ("你死了",像素大字,慢速淡入,delay 0.3s) ├── GeoLostText ("遗失 {N} Geo",有 Shade 机制时显示;P1) └── PromptText ("按任意键继续",1s 延迟后出现,闪烁循环) ``` **时序由 GameManager 协程控制**(详见 [11_GameManager.md §5](./11_GameManager.md)): | 时刻 | 事件 | |------|------| | 0.0s | `OnPlayerDied` 触发,`GameState → Dead`,DeathScreenPanel 开始渐入 | | 1.2s | 玩家死亡动画结束 | | 2.0s | DeathScreenPanel 完全可见(alpha=1) | | 2.0s~ | 等待玩家按键(或 3s 超时自动继续) | | +0.3s | LoadingOverlay 淡入遮盖 → 场景加载 → 淡出 | --- ## 9. 加载过渡遮罩 `LoadingOverlay` 是 `Canvas_Overlay` 下的全黑 `Image`,通过 `CanvasGroup.alpha` 控制淡入淡出: ```csharp public class LoadingOverlay : MonoBehaviour { [SerializeField] CanvasGroup _canvasGroup; [SerializeField] float _fadeDuration = 0.3f; public IEnumerator FadeIn() { _canvasGroup.gameObject.SetActive(true); float t = 0f; while (t < _fadeDuration) { _canvasGroup.alpha = t / _fadeDuration; t += Time.unscaledDeltaTime; // 使用 unscaledDeltaTime,暂停/死亡时也能工作 yield return null; } _canvasGroup.alpha = 1f; } public IEnumerator FadeOut() { float t = _fadeDuration; while (t > 0f) { _canvasGroup.alpha = t / _fadeDuration; t -= Time.unscaledDeltaTime; yield return null; } _canvasGroup.alpha = 0f; _canvasGroup.gameObject.SetActive(false); } } ``` **使用方**:`SceneLoader` 在异步加载前调用 `yield return FadeIn()`,场景激活后调用 `yield return FadeOut()`。不由 `UIManager` 直接驱动,而是由 `GameManager` 协程在特定时序中显式调用。 --- ## 10. 设置菜单 `SettingsPanel` 叠加在 MainMenuPanel 或 PauseMenuPanel 上方(Sorting Order 相同,z-order 靠后): ``` SettingsPanel ├── Tab_Audio │ ├── Slider_MasterVolume (0.0 ~ 1.0,初始值读自 SettingsManager) │ ├── Slider_BGMVolume │ ├── Slider_SFXVolume │ └── Toggle_Haptics ├── Tab_Controls (P1:键位重映射) │ └── KeyBindingsList (InputSystem PlayerInput rebind API) ├── Tab_Display (P1) │ ├── Toggle_PixelPerfect │ └── Dropdown_Resolution └── Button_Back → UIManager.CloseSettings(),保存设置 ``` ### 音量应用逻辑 ```csharp // SettingsUI.cs // Slider.onValueChanged → AudioManager.SetBGMVolume(value) // → audioMixer.SetFloat("BGMVolume", LinearToDecibel(value)) // // 线性值 → 分贝转换: float LinearToDecibel(float linear) => linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f; ``` 设置持久化通过 `SettingsManager`(独立于存档,写入 `Application.persistentDataPath/settings.json`)。 --- ## 11. UI 事件频道 新增频道(存放于 `Assets/ScriptableObjects/Events/UI/`): | 资产名 | 类型 | 用途 | |--------|------|------| | `OnGameStateChanged.asset` | `GameStateEventChannelSO` | UIManager 监听,驱动 Canvas 切换 | | `OnMaxHPChanged.asset` | `IntEventChannelSO` | HPContainer 增加心形图标 | | `OnBossHPChanged.asset` | `IntEventChannelSO` | BossHPBar 更新 fillAmount | | `OnBossNameSet.asset` | `StringEventChannelSO` | BossHPBar 显示 Boss 名称 | | `OnBossHPMaxSet.asset` | `IntEventChannelSO` | BossHPBar 初始化 MaxHP | > `OnBossFightToggled.asset`(BoolEventChannelSO)已存在于 `Events/Camera/`,BossHPBar 直接复用。 --- ## 12. 编辑器友好设计 - `UIManager` Custom Inspector:实时显示当前 GameState 文字标签 + 各 Canvas Root 激活状态(绿/红标记) - Editor Only 测试按钮(UI Toolkit `Button` + `Toggle`,`CreateInspectorGUI()` 中实现): - `[Simulate: Gameplay]` — 模拟进入 Gameplay 状态(测试 HUD 显示) - `[Simulate: Dead]` — 模拟死亡(测试 DeathScreen 渐入) - `[Simulate: Boss Start]` — 模拟 Boss 战开始(测试 BossHPBar 滑入) - UI Prefab 存放:`Assets/Prefabs/UI/` 按模块分子文件夹(HUD / Menus / Overlays) - RectTransform 规范:按设计区域固定锚点(左上/居中/右上),禁止拉伸式动态布局,防止不同分辨率下错位 --- ## 13. 加载画面(Loading Screen) ### 13.1 组件布局 ``` Canvas_Overlay └── LoadingScreen(默认 SetActive(false)) ├── Background_Art → 黑色基底 + 加载时随机区域概念图(Sprite) ├── ProgressBar │ ├── ProgressBarBG → 灰色底条 │ └── ProgressBarFill→ 白色填充(Image.fillAmount) ├── TipText → TextMeshPro,显示游戏技巧 └── LoadingIcon → 旋转的像素风齿轮(DOTween Rotate 无限) ``` ### 13.2 LoadingScreenManager ```csharp public class LoadingScreenManager : MonoBehaviour { [SerializeField] GameObject _root; [SerializeField] Image _progressFill; [SerializeField] TMP_Text _tipText; [SerializeField] Sprite[] _backgroundArts; // 各区域概念图 [SerializeField] string[] _tipKeys; // LocalizationKeys(游戏技巧) [SerializeField] VoidEventChannelSO _onLoadingStarted; [SerializeField] VoidEventChannelSO _onLoadingComplete; [SerializeField] FloatEventChannelSO _onLoadingProgressUpdated; [SerializeField] LanguageManagerSO _langMgr; void OnEnable() { _onLoadingStarted.OnEventRaised += Show; _onLoadingComplete.OnEventRaised += Hide; _onLoadingProgressUpdated.OnEventRaised += UpdateProgress; } void OnDisable() { _onLoadingStarted.OnEventRaised -= Show; _onLoadingComplete.OnEventRaised -= Hide; _onLoadingProgressUpdated.OnEventRaised -= UpdateProgress; } void Show() { _root.SetActive(true); // 随机背景 _root.GetComponentInChildren().sprite = _backgroundArts[Random.Range(0, _backgroundArts.Length)]; // 随机提示 string key = _tipKeys[Random.Range(0, _tipKeys.Length)]; _tipText.text = _langMgr.Get(key, "UI_Table"); _progressFill.fillAmount = 0f; } void Hide() { // 先确保进度条跑满再淡出 DOTween.To(() => _progressFill.fillAmount, v => _progressFill.fillAmount = v, 1f, 0.2f) .OnComplete(() => _root.SetActive(false)); } void UpdateProgress(float progress) => _progressFill.fillAmount = progress; } ``` ### 13.3 游戏技巧文本(Localization) 提示字符串统一放在 `UI_Table` 中,键名格式 `ui.loading.tip.{index}`(0~19): ``` ui.loading.tip.0 = "弹反成功可以回复灵魂值" ui.loading.tip.1 = "按住方向键向下可以穿过单向平台" ui.loading.tip.2 = "冥影会守护你离开的地方——记得取回你的 Geo" ui.loading.tip.3 = "壁跳需要连续交替踩两侧墙壁才能持续上升" // ... 更多提示 ``` --- ## 14. 无障碍设计(Accessibility) ### 14.1 色盲模式 提供三种色盲辅助模式,通过 URP 后处理 Volume 切换: | 模式 | Volume Override | 说明 | |------|----------------|------| | 关闭(默认) | None | 原始色彩 | | 色盲模式 A(红绿色盲)| `CC_Deuteranopia.asset` | 使用蓝黄色调区分敌我 | | 色盲模式 B(蓝黄色盲)| `CC_Tritanopia.asset` | 使用红绿色调区分 | | 高对比度 | `CC_HighContrast.asset` | 边缘增强 + 提高对比度 | ```csharp public class AccessibilityManager : MonoBehaviour { [SerializeField] Volume[] _colorBlindVolumes; // 索引对应 AccessibilityMode 枚举 [SerializeField] AccessibilitySettingsSO _settings; public void SetColorBlindMode(int modeIndex) { for (int i = 0; i < _colorBlindVolumes.Length; i++) _colorBlindVolumes[i].weight = (i == modeIndex) ? 1f : 0f; _settings.colorBlindMode = modeIndex; PlayerPrefs.SetInt("ColorBlindMode", modeIndex); } } ``` ### 14.2 难度辅助选项 | 选项 | 默认 | 说明 | |------|------|------| | 无敌帧时长 | 1.8s(正常) | 可调至 2.5s(辅助)| | 土狼时间 | 0.15s | 可调至 0.25s(辅助)| | 弹反窗口 | 0.28s | 可调至 0.45s(辅助)| | 输入缓冲 | 见 §23 | 可整体 +50%(辅助)| | 死亡惩罚 | 掉落 Geo + 生成 Shade | 可设"仅重生无其他惩罚"(辅助)| 这些选项存储于 `AccessibilitySettingsSO`,不写入存档(账户级设置),通过 `PlayerPrefs` 持久化。 ### 14.3 字幕与文本 - 对话中 NPC 名称用颜色区分(色盲模式下替换为形状图标区分) - 字体大小可在设置页调整(Small/Normal/Large,对应 TMP 字体大小 × 0.85 / 1.0 / 1.2) - 所有 UI 动画支持"减少动态效果"选项(关闭非关键过渡动画,仅保留淡入淡出) ### 14.4 手柄震动控制 ``` 设置 > 游戏手感 > 手柄震动 整体震动强度: [0% ─────────●────── 100%](默认 80%) 攻击命中: [✓] 开启 受到伤害: [✓] 开启 BOSS 演出: [✓] 开启 行走震动: [ ] 关闭(默认关闭,避免疲劳) ``` 震动设置乘以 `PlayerPrefs.GetFloat("VibrationMultiplier", 0.8f)` 后传入 Lofelt Nice Vibrations API。 ### 14.5 键位自定义(控制重映射) `10_UISystem §10` 的设置菜单扩展: ``` 设置 > 按键设置 > 控制器 / 键鼠 [按下想要修改的操作,然后按目标按键/键位...] 操作 当前绑定 默认绑定 攻击 [J] [Z] 跳跃 [K] [X] 冲刺 [L] [C] 弹反 [U] [A] ... [恢复默认] ``` 通过 `PlayerInput` 的 `InputActionRebindingExtensions.PerformInteractiveRebinding()` 实现,绑定存储于 `PlayerPrefs["InputBindings_Controller"]`(JSON 格式)。 --- ## 15. UIManager 解耦改进说明 ### 15.1 当前依赖关系与问题 `UIManager` 目前通过 `asmdef` 依赖 `BaseGames.Combat`,原因是 **BossHP 条需要读取 `DamageInfo`**(当前 HP / Max HP 来自 Boss 的 `EnemyStats`)。 ``` BaseGames.UI.asmdef └── 依赖: BaseGames.Core · BaseGames.Combat ← 仅为 BossHP 条 ``` 这带来潜在风险:若 UI 层将来需要引用 Dialogue、Equipment 等系统,可能形成循环依赖(Combat 不应反向依赖 UI)。 ### 15.2 改进方案:IBossHPProvider 接口 将 Boss HP 信息通过接口隔离,UI 只依赖接口,不依赖 Combat 层: ```csharp // Assets/Scripts/Core/Interfaces/IBossHPProvider.cs // 归属: BaseGames.Core.asmdef(接口层,所有系统均可访问) namespace BaseGames.Core { public interface IBossHPProvider { int CurrentHP { get; } int MaxHP { get; } int PhaseCount { get; } int CurrentPhase { get; } float NormalizedHP => (float)CurrentHP / MaxHP; event System.Action OnHPChanged; // (current, max) event System.Action OnPhaseChanged; } } ``` ```csharp // EnemyBase(Combat 层)实现接口 public class EnemyBase : MonoBehaviour, IBossHPProvider { /* ... */ } // BossHPBar(UI 层)仅依赖接口,不 using BaseGames.Combat public class BossHPBar : MonoBehaviour { IBossHPProvider _boss; // 由 BossEventChannelSO 传入(泛型参数改为 IBossHPProvider) [SerializeField] BossEventChannelSO _onBossFightStarted; void OnEnable() => _onBossFightStarted.OnEventRaised += RegisterBoss; void RegisterBoss(IBossHPProvider boss) { _boss = boss; _boss.OnHPChanged += UpdateBar; } } ``` ### 15.3 迁移路径 改动成本较低,分两步完成: | 步骤 | 操作 | 影响范围 | |------|------|---------| | 1 | 在 `BaseGames.Core.asmdef` 中新增 `IBossHPProvider` 接口文件 | 仅新增文件 | | 2 | `EnemyBase` 实现 `IBossHPProvider`,`BossHPBar` 改为依赖接口 | `EnemyBase.cs` + `BossHPBar.cs` 各约 10 行改动 | | 3 | `BaseGames.UI.asmdef` 的依赖列表中移除 `BaseGames.Combat` | 编辑 `.asmdef` 文件 | > **P2 优先级**:当前 Combat → UI 路径并未造成循环依赖,改进非紧急。仅在 UI 层需要引入新的跨层依赖前执行。 --- ## 16. 存档点交互 & 存档槽选择界面 ### 16.1 存档点交互提示(InteractPrompt) 玩家靠近存档点时,在存档点上方显示交互提示气泡: ``` Canvas_Overlay └── SavePointPrompt (World Space Canvas,跟随存档点世界坐标) ├── PromptIcon (键盘图标 [E] / 手柄图标 [South Button]) └── PromptText ("存档",本地化键 ui.savepoint.interact) ``` 交互后播放存档动画(主角坐下动画由 Animancer 控制),存档完成后显示"已保存"指示(见 §17)。 ### 16.2 存档槽选择面板(SaveSlotPanel) **触发时机**:主菜单点击「继续游戏」且存在多个有效存档槽时弹出。 **Canvas 层级**:`Canvas_Menu`(Sorting Order 20),叠在 `MainMenuPanel` 上方。 ``` SaveSlotPanel ├── Title ("选择存档",像素字体) ├── SlotList (垂直布局,3 个 SaveSlotCard) │ ├── SaveSlotCard_0 │ ├── SaveSlotCard_1 │ └── SaveSlotCard_2 └── Button_Back ``` #### SaveSlotCard 布局 ``` SaveSlotCard ├── BgImage (Image;异步加载 savePointBgImageKey Sprite;加载中显示占位色块) │ └── BgDimOverlay (半透明黑色蒙层,确保文字可读) ├── SlotEmptyPanel (无存档时显示:"空槽" + "[新游戏]" 按钮) └── SlotDataPanel (有存档时显示) ├── SavePointNameText (TMP,查 savePointLocKey 本地化) ├── HPContainer (心形图标列,只读展示) ├── PlaytimeText ("游玩 Xh Xm",由 playtime 秒格式化) ├── LastSavedText ("最后存档:yyyy-MM-dd HH:mm",UTC→本地时区转换) ├── NgPlusBadge (仅 ngPlusCount > 0 时显示,"◆ NG+N" 金色标签) └── DeleteButton (默认隐藏;长按 0.5s 触发确认弹窗) ``` #### SaveSlotCard C# 实现骨架 ```csharp public class SaveSlotCard : MonoBehaviour { [SerializeField] Image _bgImage; [SerializeField] TMP_Text _savePointName; [SerializeField] HPContainer _hpDisplay; // 复用 HUD HPContainer(只读模式) [SerializeField] TMP_Text _playtimeText; [SerializeField] TMP_Text _lastSavedText; [SerializeField] GameObject _ngPlusBadge; [SerializeField] TMP_Text _ngPlusText; [SerializeField] GameObject _emptyPanel; [SerializeField] GameObject _dataPanel; [SerializeField] LanguageManagerSO _lang; public async void Bind(SaveSlotSummary summary) { if (summary == null) { _emptyPanel.SetActive(true); _dataPanel.SetActive(false); return; } _emptyPanel.SetActive(false); _dataPanel.SetActive(true); // 存档点名称(本地化) _savePointName.text = _lang.Get(summary.Meta.SavePointLocKey, "UI_Table"); // 游玩时长格式化 int totalMin = (int)(summary.Meta.Playtime / 60); _playtimeText.text = $"游玩 {totalMin / 60}h {totalMin % 60}m"; // 最后存档时间(UTC → 本地时区) if (DateTimeOffset.TryParse(summary.Meta.LastSaved, out var dt)) _lastSavedText.text = $"最后存档:{dt.LocalDateTime:yyyy-MM-dd HH:mm}"; // HP 展示 _hpDisplay.SetReadOnly(summary.Player.CurrentHP, summary.Player.MaxHP); // NG+ 标记 bool isNgPlus = summary.Meta.NgPlusCount > 0; _ngPlusBadge.SetActive(isNgPlus); if (isNgPlus) _ngPlusText.text = $"NG+{summary.Meta.NgPlusCount}"; // 背景图(Addressable 异步加载,不阻塞主线程) if (!string.IsNullOrEmpty(summary.Meta.SavePointBgImageKey)) { var handle = Addressables.LoadAssetAsync(summary.Meta.SavePointBgImageKey); _bgImage.sprite = await handle.Task; } } } ``` **删除确认弹窗**: ``` ConfirmDeleteDialog ├── TitleText ("确认删除存档?此操作不可撤销") ├── Button_Yes → SaveManager.DeleteSlotAsync(slot) → RefreshPanel() └── Button_Cancel ``` --- ## 17. 存档进行中指示(Save Indicator) 存档触发(存档点交互 / `WriteDirty`)后,在 HUD 右下角显示短暂存档状态动画: ``` Canvas_HUD └── SaveIndicator (右下角,默认 CanvasGroup.alpha=0) ├── SaveIcon (旋转像素风图标,DOTween 持续旋转) └── SaveText ("正在保存..." / "已保存") ``` ```csharp public class SaveIndicator : MonoBehaviour { [SerializeField] CanvasGroup _cg; [SerializeField] TMP_Text _text; [SerializeField] RectTransform _icon; [SerializeField] BoolEventChannelSO _onSaveStateChanged; // true=成功 void OnEnable() => _onSaveStateChanged.OnEventRaised += OnSaveState; void OnDisable() => _onSaveStateChanged.OnEventRaised -= OnSaveState; public void ShowSaving() { _text.text = "正在保存..."; _cg.DOFade(1f, 0.15f); _icon.DORotate(new Vector3(0, 0, -360f), 1f, RotateMode.FastBeyond360) .SetLoops(-1).SetEase(Ease.Linear); } void OnSaveState(bool success) { _icon.DOKill(); _text.text = success ? "已保存" : "保存失败"; DOTween.Sequence() .AppendInterval(1.2f) .Append(_cg.DOFade(0f, 0.3f)); } } ``` `SaveManager.SaveAsync` 开始前通过 `OnSaveStarted`(`VoidEventChannelSO`)触发 `ShowSaving()`,结束后通过 `OnSaveStateChanged` 传递成功/失败结果。 --- ## 18. 通知系统(Toast / 成就 / 任务) ### 18.1 架构 ``` Canvas_Overlay └── NotificationArea (右上角,垂直布局,最多 3 条并排) └── [NotificationCard] (动态生成,ObjectPool 管理) ├── IconImage ├── TitleText └── BodyText ``` ### 18.2 通知类型与持续时间 | 类型 | 触发时机 | 持续时间 | |------|---------|--------| | `SaveComplete` | 存档成功后 | 1.5s | | `Achievement` | 成就解锁 | 4s | | `QuestNew` | 接取新任务 | 3s | | `QuestUpdate` | 任务目标推进 | 2.5s | | `QuestComplete` | 任务完成 | 3.5s | | `SystemWarning` | 存档失败等错误 | 5s | ### 18.3 NotificationManager ```csharp [DefaultExecutionOrder(+60)] public class NotificationManager : MonoBehaviour { [SerializeField] NotificationCard _cardPrefab; [SerializeField] Transform _area; [SerializeField] int _maxVisible = 3; [SerializeField] BoolEventChannelSO _onSaveStateChanged; [SerializeField] StringEventChannelSO _onAchievementUnlocked; [SerializeField] StringEventChannelSO _onQuestNew; [SerializeField] StringEventChannelSO _onQuestUpdated; [SerializeField] StringEventChannelSO _onQuestCompleted; readonly Queue _queue = new(); void OnEnable() { _onSaveStateChanged.OnEventRaised += ok => Enqueue(ok ? NotificationType.SaveComplete : NotificationType.SystemWarning, ok ? "已保存" : "存档失败"); _onAchievementUnlocked.OnEventRaised += id => Enqueue(NotificationType.Achievement, id); _onQuestNew.OnEventRaised += id => Enqueue(NotificationType.QuestNew, id); _onQuestUpdated.OnEventRaised += id => Enqueue(NotificationType.QuestUpdate, id); _onQuestCompleted.OnEventRaised += id => Enqueue(NotificationType.QuestComplete, id); } void Enqueue(NotificationType type, string payload) { _queue.Enqueue(new(type, payload)); TryShowNext(); } void TryShowNext() { if (_area.childCount >= _maxVisible || _queue.Count == 0) return; var card = Instantiate(_cardPrefab, _area); card.Show(_queue.Dequeue(), OnCardDismissed); } void OnCardDismissed() => TryShowNext(); } ``` ### 18.4 NotificationCard 动画 右侧滑入 → 停留 → 淡出右侧退出: ```csharp public void Show(NotificationData data, Action onDone) { _icon.sprite = IconLibrary.Get(data.Type); _titleText.text = LocalizationHelper.GetNotificationTitle(data); _bodyText.text = LocalizationHelper.GetNotificationBody(data); DOTween.Sequence() .Append(transform.DOLocalMoveX(0, 0.2f).From(220f).SetEase(Ease.OutCubic)) .AppendInterval(NotificationDurations[data.Type]) .Append(_cg.DOFade(0f, 0.25f)) .OnComplete(() => { onDone?.Invoke(); Destroy(gameObject); }); } ``` --- ## 19. HUD 扩展:工具槽 & 形态指示 ### 19.1 工具槽 HUD(ToolSlotHUD) 详细设计见 [37_ToolSystem](./37_ToolSystem.md)。Canvas 挂载: ``` Canvas_HUD └── ToolSlotHUD (右下角,水平布局) ├── ToolSlot_0 │ ├── ToolIconBg (槽位背景框) │ ├── ToolIcon (当前工具图标;空槽显示占位图) │ └── CooldownOverlay (圆形 FillAmount 冷却覆盖层) └── ToolSlot_1 └── ... ``` - 订阅 `OnToolEquipped(int slot, string toolId)` 更新图标 - 订阅 `OnToolCooldownChanged(int slot, float normalized)` 更新冷却蒙层 - 当前激活槽白色描边高亮,非激活槽降低亮度 ### 19.2 形态切换指示(FormIndicator) 详细设计见 [21_SpellSystem](./21_SpellSystem.md)。Canvas 挂载: ``` Canvas_HUD └── FormIndicator (左下角,灵魂槽右侧) ├── FormIconCurrent (当前形态图标,全亮,Scale 略大) └── FormIconRow (已解锁形态小图标列,切换时高亮目标) ``` - 订阅 `OnFormChanged(string formId)` 切换高亮图标 - 形态图标资产命名规范:`{formId}_Icon.png`,存放于 `Assets/Sprites/UI/Forms/` - 切换时 Scale 0.8→1.0 脉冲动画(0.15s DOTween) --- ## 20. 全屏功能界面索引 以下全屏功能界面各有专属文档,本文档仅说明挂载点和打开方式: | 界面 | 文档 | 打开方式 | Canvas 挂载 | |------|------|---------|------------| | **地图** | [16_MapSystem](./16_MapSystem.md) | 暂停内 / 快捷键 `M` | `Canvas_Menu` | | **护符/装备** | [17_EquipmentSystem](./17_EquipmentSystem.md) | 暂停内 / 快捷键 `C` | `Canvas_Menu` | | **技能/形态** | [21_SpellSystem](./21_SpellSystem.md) | 暂停内 | `Canvas_Menu` | | **任务日志** | [38_QuestSystem](./38_QuestSystem.md) §QuestLogUI | 暂停内 / 快捷键 `J` | `Canvas_Menu` | | **商店** | [28_ShopSystem](./28_ShopSystem.md) | NPC 交互触发 | `Canvas_Menu` | | **对话框** | [15_DialogueSystem](./15_DialogueSystem.md) | 对话事件触发 | `Canvas_Overlay` | | **挑战房间结算** | [39_ChallengeRoomSystem](./39_ChallengeRoomSystem.md) | 挑战完成事件 | `Canvas_Overlay` | | **难度选择** | [29_DifficultyModesGuide](./29_DifficultyModesGuide.md) §9 | 新游戏流程 | `Canvas_Menu` | **全屏界面统一规范**: - 打开时 `Time.timeScale = 0`(对话框除外,对话框在游戏运行中叠加) - 背景使用 `FullscreenDimOverlay`(`Canvas_Overlay` 下,`CanvasGroup.alpha = 0.65f`) - 关闭时发送 `OnGameStateChanged(GameState.Gameplay)` 恢复时间缩放 - 所有全屏界面支持 `Escape` / 手柄 `East Button (B)` 关闭 --- ## 21. UI 音效系统 所有 UI 交互均需音效反馈。统一通过 `UIAudioManager`(订阅 UI 事件频道)发声,**不在各 UI 组件中内嵌 AudioSource**,保证集中管理和统一调节。 ### 21.1 音效事件表 | 触发时机 | 音效分类 | 资产键示例 | 备注 | |---------|---------|----------|------| | 按钮 Hover(鼠标 / 导航移入)| `ui_hover` | `SFX_UI_Hover` | 音量较小,避免密集重复刺耳 | | 按钮 Click(确认)| `ui_confirm` | `SFX_UI_Confirm` | | | 按钮 Click(取消/返回)| `ui_cancel` | `SFX_UI_Cancel` | | | 面板 Open | `ui_panel_open` | `SFX_UI_PanelOpen` | | | 面板 Close | `ui_panel_close` | `SFX_UI_PanelClose` | | | 错误操作(不可用按钮)| `ui_error` | `SFX_UI_Error` | | | 存档成功 | `ui_save_done` | `SFX_UI_SaveDone` | 轻柔提示音 | | 成就解锁 | `ui_achievement` | `SFX_Achievement` | 较隆重的提示音 | | Pause 开启 | `ui_pause` | `SFX_UI_Pause` | 时间停止感 | | 死亡画面淡入 | `ui_death` | `SFX_UI_Death` | | ### 21.2 UIAudioManager ```csharp [DefaultExecutionOrder(+55)] public class UIAudioManager : MonoBehaviour { [SerializeField] AudioSource _uiAudioSource; // 独立 AudioMixer Group: UI [SerializeField] UISoundLibrarySO _library; // ScriptableObject,键→AudioClip 映射 // 事件频道订阅 [SerializeField] StringEventChannelSO _onUISound; // payload = 音效键名 void OnEnable() => _onUISound.OnEventRaised += Play; void OnDisable() => _onUISound.OnEventRaised -= Play; public void Play(string key) { if (_library.TryGet(key, out var clip)) _uiAudioSource.PlayOneShot(clip); } } ``` ### 21.3 Button 组件扩展 ```csharp // UIButton.cs — 替换所有 UI 中的普通 Button 使用 [RequireComponent(typeof(Button))] public class UIButton : MonoBehaviour, ISelectHandler, IPointerEnterHandler { [SerializeField] string _hoverSound = "ui_hover"; [SerializeField] string _confirmSound = "ui_confirm"; [SerializeField] StringEventChannelSO _onUISound; void Awake() => GetComponent