Files
zeling_v2/Docs/Design/10_UISystem.md
2026-05-08 11:04:00 +08:00

1700 lines
57 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.
# 10 · UI 系统
> **命名空间** `BaseGames.UI`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · `BaseGames.Combat`Boss HP· TextMeshPro · DOTweenP1 过渡动画)
---
## 目录
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 (ImageFillAmount 驱动)
└── 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<GameState> 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 / MaxSoul0.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 ImageGeo 硬币图标)
└── 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 → 00.4s
else StartCoroutine(SlideOut()); // 延迟 1s → y: 0 → -800.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 (全屏黑底 CanvasGroupalpha 从 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<Image>().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<int, int> OnHPChanged; // (current, max)
event System.Action<int> OnPhaseChanged;
}
}
```
```csharp
// EnemyBaseCombat 层)实现接口
public class EnemyBase : MonoBehaviour, IBossHPProvider { /* ... */ }
// BossHPBarUI 层)仅依赖接口,不 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<Sprite>(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<NotificationData> _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 工具槽 HUDToolSlotHUD
详细设计见 [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<Button>().onClick.AddListener(
() => _onUISound.Raise(_confirmSound));
public void OnPointerEnter(PointerEventData _) => _onUISound.Raise(_hoverSound);
public void OnSelect(BaseEventData _) => _onUISound.Raise(_hoverSound);
}
```
> 不可交互按钮(`interactable = false`)点击时发送 `"ui_error"` 音效,在 `UIButton.OnPointerClick` 中通过 `!button.interactable` 判断触发。
### 21.4 AudioMixer 组
```
AudioMixer
└── Master
├── BGM
├── SFX_Gameplay
└── SFX_UI ← UI 独立通道,设置菜单中单独音量控制
```
---
## 22. 控制器导航 & 面板栈
### 22.1 EventSystem 导航规范
Unity EventSystem 的导航必须**显式配置**,不使用 `Automatic` 模式(`Automatic` 模式在复杂布局下跳跃不可预测):
```
每个交互 Panel 规范:
1. Panel 激活时调用 EventSystem.SetSelectedGameObject(_firstSelected)
2. 所有 Button/Slider/Toggle 的 Navigation 设置为 Explicit
3. 最后一个元素的 Down 导航回到第一个(循环导航)
4. Panel 关闭时保存并还原前一 Panel 的焦点对象
```
```csharp
public class NavigablePanelBase : MonoBehaviour
{
[SerializeField] GameObject _firstSelected; // Inspector 中指定第一个获得焦点的元素
protected virtual void OnEnable()
=> StartCoroutine(FocusNextFrame()); // 必须等一帧 EventSystem 才能接收
IEnumerator FocusNextFrame()
{
yield return null;
EventSystem.current?.SetSelectedGameObject(_firstSelected);
}
protected virtual void OnDisable()
=> EventSystem.current?.SetSelectedGameObject(null);
}
```
所有 Panel`PauseMenuPanel``SettingsPanel``SaveSlotPanel` 等)继承 `NavigablePanelBase`
### 22.2 面板栈PanelStack
解决「从暂停→设置→按 Back 应回到暂停而非主菜单」的问题:
```csharp
public class PanelStack : MonoBehaviour
{
readonly Stack<(GameObject panel, GameObject prevFocus)> _stack = new();
/// <summary>打开新面板,将当前面板推入栈</summary>
public void Push(GameObject newPanel)
{
var prevFocus = EventSystem.current?.currentSelectedGameObject;
if (_stack.TryPeek(out var top))
top.panel.SetActive(false); // 隐藏(不销毁)前面板
_stack.Push((newPanel, prevFocus));
newPanel.SetActive(true);
}
/// <summary>关闭当前面板,恢复上一层</summary>
public void Pop()
{
if (_stack.Count == 0) return;
var (cur, _) = _stack.Pop();
cur.SetActive(false);
if (_stack.TryPeek(out var prev))
{
prev.panel.SetActive(true);
StartCoroutine(RestoreFocusNextFrame(prev.prevFocus));
}
}
IEnumerator RestoreFocusNextFrame(GameObject focus)
{
yield return null;
EventSystem.current?.SetSelectedGameObject(focus);
}
}
```
**集成点**`UIManager` 持有 `PanelStack` 引用,所有 `OpenSettings()``OpenSaveSlot()` 等调用改为 `_panelStack.Push(panel)`;所有 Back 按钮调用 `_panelStack.Pop()`
### 22.3 手柄 Back 按钮全局处理
```csharp
// 在 InputReaderSO 中添加 UICancel 事件(对应手柄 East Button / 键盘 Escape
// UIManager 订阅,调用 _panelStack.Pop()(若栈非空)
// 若栈为空且当前在 Pause 状态,则调用 ResumeGame()
```
---
## 23. 输入设备图标自动切换
多平台商业游戏要求按钮提示图标跟随**最后活跃输入设备**自动切换(键鼠 / Xbox / PS4 / PS5 / Switch
### 23.1 InputIconLibrarySO
```csharp
[CreateAssetMenu(menuName = "UI/Input Icon Library")]
public class InputIconLibrarySO : ScriptableObject
{
[Serializable]
public struct IconSet
{
public DeviceType device;
public Sprite[] icons; // 按 InputActionID 索引
}
public IconSet[] iconSets;
public Sprite Get(DeviceType device, string actionId)
{
// 通过 actionId 查找对应图标
}
}
public enum DeviceType { KeyboardMouse, Xbox, PlayStation4, PlayStation5, Switch }
```
### 23.2 InputDeviceDetector
```csharp
[DefaultExecutionOrder(-100)]
public class InputDeviceDetector : MonoBehaviour
{
public static DeviceType Current { get; private set; } = DeviceType.KeyboardMouse;
[SerializeField] DeviceTypeEventChannelSO _onDeviceChanged;
void OnEnable()
=> InputSystem.onActionChange += OnActionChange;
void OnDisable()
=> InputSystem.onActionChange -= OnActionChange;
void OnActionChange(object obj, InputActionChange change)
{
if (change != InputActionChange.ActionPerformed) return;
if (obj is not InputAction action) return;
var device = action.activeControl?.device;
var newType = device switch
{
Gamepad g when g.name.Contains("DualSense") || g.name.Contains("DualShock")
=> g.name.Contains("DualSense") ? DeviceType.PlayStation5 : DeviceType.PlayStation4,
Gamepad g when g.name.Contains("Switch")
=> DeviceType.Switch,
Gamepad => DeviceType.Xbox,
_ => DeviceType.KeyboardMouse
};
if (newType != Current)
{
Current = newType;
_onDeviceChanged.Raise(Current);
}
}
}
```
### 23.3 InputPromptImage 组件
替换所有显示按钮提示图标的 `Image`
```csharp
public class InputPromptImage : MonoBehaviour
{
[SerializeField] string _actionId; // 如 "Jump"、"Attack"
[SerializeField] Image _image;
[SerializeField] InputIconLibrarySO _library;
[SerializeField] DeviceTypeEventChannelSO _onDeviceChanged;
void OnEnable()
{
_onDeviceChanged.OnEventRaised += Refresh;
Refresh(InputDeviceDetector.Current);
}
void OnDisable() => _onDeviceChanged.OnEventRaised -= Refresh;
void Refresh(DeviceType device)
=> _image.sprite = _library.Get(device, _actionId);
}
```
> 凡是在 InteractPrompt、PauseMenu、HUD 底部提示InputHints中出现的按键图标均使用 `InputPromptImage` 而非静态 Sprite。
---
## 24. 浮动战斗文字(伤害数字)
### 24.1 设计规范
动作类游戏标配,以像素风格呈现:
| 类型 | 颜色 | 字号 | 动画 |
|------|------|------|------|
| 普通伤害 | 白色 | 16px | 向上飘动 0.6s + 淡出 |
| 暴击伤害 | 黄色 + 大 1.3× | 21px | 弹入(Scale 1.4→1) + 上飘 + 淡出 |
| 玩家受伤 | 红色 | 14px | 向上偏移 + 快速淡出 0.4s |
| 治疗 | 绿色 | 14px | 向上飘动 + 淡出 |
| 格挡/弹反 | 蓝色 | 16px | 晃动后淡出 |
| 免疫(弹反护体)| 灰色 | 12px | "IMMUNE" 文字,短暂显示 |
### 24.2 FloatingTextManager
```csharp
[DefaultExecutionOrder(+40)]
public class FloatingTextManager : MonoBehaviour
{
[SerializeField] FloatingText _prefab;
[SerializeField] Transform _worldCanvas; // World Space CanvasSorting Order 15
[SerializeField] Camera _cam;
[SerializeField] DamageInfoEventChannelSO _onDamageDealt; // DamageInfo 包含伤害值、类型、世界坐标
[SerializeField] HealInfoEventChannelSO _onHealReceived;
ObjectPool<FloatingText> _pool;
void Awake() => _pool = new(Create, null, null, null, false, 20, 40);
void OnEnable()
{
_onDamageDealt.OnEventRaised += SpawnDamage;
_onHealReceived.OnEventRaised += SpawnHeal;
}
void SpawnDamage(DamageInfo info)
{
var ft = _pool.Get();
ft.transform.position = WorldToCanvasPos(info.WorldPosition);
ft.Play(info.Amount.ToString(), DamageTypeToStyle(info.Type), () => _pool.Release(ft));
}
Vector3 WorldToCanvasPos(Vector3 worldPos)
{
var vp = _cam.WorldToViewportPoint(worldPos);
var rt = _worldCanvas as RectTransform;
return new Vector3(
(vp.x - 0.5f) * rt.sizeDelta.x,
(vp.y - 0.5f) * rt.sizeDelta.y,
0f);
}
}
```
### 24.3 FloatingText 动画
```csharp
public class FloatingText : MonoBehaviour
{
[SerializeField] TMP_Text _text;
[SerializeField] CanvasGroup _cg;
public void Play(string content, FloatingTextStyle style, Action onDone)
{
_text.text = content;
_text.color = style.color;
transform.localScale = Vector3.one * style.scale;
DOTween.Sequence()
.Append(transform.DOLocalMoveY(transform.localPosition.y + style.riseY, style.duration)
.SetEase(Ease.OutQuad))
.Join(_cg.DOFade(0f, style.duration).SetDelay(style.duration * 0.4f))
.OnComplete(() => onDone?.Invoke());
}
}
```
---
## 25. HUD 自适应可见性 & 过场隐藏
### 25.1 过场/演出期间 HUD 隐藏
| 触发条件 | HUD 状态 | 过渡方式 |
|---------|---------|--------|
| 进入 Boss 开场演出 | 隐藏 | `CanvasGroup.DOFade(0, 0.3f)` |
| 演出结束(玩家可操控)| 显示 | `CanvasGroup.DOFade(1, 0.2f)` |
| 对话触发 | 隐藏(仅保留 DialogueBox| 即时 |
| 加载场景 | 隐藏(被 LoadingOverlay 覆盖)| N/A |
| 死亡画面 | 隐藏 | DeathScreen 全覆盖 |
```csharp
public class HUDVisibilityController : MonoBehaviour
{
[SerializeField] CanvasGroup _hudCg;
[SerializeField] VoidEventChannelSO _onCutsceneStarted;
[SerializeField] VoidEventChannelSO _onCutsceneEnded;
[SerializeField] VoidEventChannelSO _onDialogueStarted;
[SerializeField] VoidEventChannelSO _onDialogueEnded;
void OnEnable()
{
_onCutsceneStarted.OnEventRaised += () => SetHUD(false, 0.3f);
_onCutsceneEnded.OnEventRaised += () => SetHUD(true, 0.2f);
_onDialogueStarted.OnEventRaised += () => SetHUD(false, 0.15f);
_onDialogueEnded.OnEventRaised += () => SetHUD(true, 0.15f);
}
void SetHUD(bool visible, float duration)
{
_hudCg.DOFade(visible ? 1f : 0f, duration)
.SetUpdate(true); // 不受 timeScale 影响
_hudCg.interactable = visible;
_hudCg.blocksRaycasts = visible;
}
}
```
### 25.2 非战斗 HUD 自动淡出Hollow Knight 风格)
```
设置 > 游戏 > HUD 可见性:
○ 始终显示
● 非战斗时淡出(默认)
○ 仅受伤时显示
```
```csharp
public class HUDAutoFade : MonoBehaviour
{
[SerializeField] CanvasGroup _hudCg;
[SerializeField] float _fadeDelay = 4f; // 离开战斗后 N 秒开始淡出
[SerializeField] float _fadedAlpha = 0.25f;
[SerializeField] BoolEventChannelSO _onInCombatChanged; // true=进战斗,false=离开战斗
[SerializeField] VoidEventChannelSO _onPlayerDamaged;
float _timer;
bool _inCombat;
void OnEnable()
{
_onInCombatChanged.OnEventRaised += b => { _inCombat = b; if (b) ShowFull(); };
_onPlayerDamaged.OnEventRaised += ShowFull;
}
void ShowFull()
{
_hudCg.DOFade(1f, 0.15f);
_timer = _fadeDelay;
}
void Update()
{
if (_inCombat) return;
_timer -= Time.unscaledDeltaTime;
if (_timer < 0f) _hudCg.DOFade(_fadedAlpha, 1.5f).SetUpdate(true);
}
}
```
---
## 26. 安全区Safe Area适配
针对 iOS 刘海屏、Android 打孔屏、主机 TV 边距Action-Safe Area所有 HUD 内容必须在安全区内。
### 26.1 SafeAreaAnchor 组件
```csharp
/// <summary>
/// 挂载在 Canvas_HUD 的根 RectTransform 上,
/// 运行时根据 Screen.safeArea 自动调整 offsetMin/offsetMax。
/// </summary>
public class SafeAreaAnchor : MonoBehaviour
{
RectTransform _rt;
Rect _lastSafeArea = Rect.zero;
void Awake() => _rt = GetComponent<RectTransform>();
void Update() => ApplyIfChanged();
void ApplyIfChanged()
{
var safe = Screen.safeArea;
if (safe == _lastSafeArea) return;
_lastSafeArea = safe;
Apply(safe);
}
void Apply(Rect safe)
{
var screen = new Vector2(Screen.width, Screen.height);
_rt.anchorMin = new Vector2(safe.xMin / screen.x, safe.yMin / screen.y);
_rt.anchorMax = new Vector2(safe.xMax / screen.x, safe.yMax / screen.y);
_rt.offsetMin = _rt.offsetMax = Vector2.zero;
}
}
```
**挂载策略**
- `Canvas_HUD``HUDPanel`:挂载 `SafeAreaAnchor`(心形容器/Geo 等不能被刘海遮挡)
- `Canvas_Menu`**不挂载**(全屏菜单背景允许延伸到不安全区)
- `Canvas_Overlay`**不挂载**(全屏遮罩需覆盖整个屏幕)
### 26.2 TV Safe Area 预设
对主机平台在设置中提供「UI 边距」滑块(默认 0%,最高 10%),在 `SafeAreaAnchor` 基础上叠加额外边距:
```csharp
float tvMargin = PlayerPrefs.GetFloat("TVSafeMargin", 0f); // 0.0~0.1
```
---
## 27. 首次启动序列Logo & 法务公告)
商业发行必须在主菜单前展示发行商/开发商 Logo 及法律声明,且**不可跳过前 N 秒**(部分平台平台政策要求)。
### 27.1 启动流程
```
游戏启动
↓ [BootScene 加载]
↓ SplashSequenceManager 运行
├── 显示 Unity LogoBuildSettings 中关闭时可省略)
├── 发行商 Logo2.5s0.5s 淡入 + 1.5s 停留 + 0.5s 淡出)
├── 开发商 Logo2.0s,同上)
├── 技术许可声明TextMeshPro 全屏0.5s 后允许跳过)
├── 法务/评级声明ESRB/CERO 评级图标1s 后允许跳过)
└── → AsyncLoadScene(MainMenuScene)
```
### 27.2 SplashSequenceManager
```csharp
public class SplashSequenceManager : MonoBehaviour
{
[SerializeField] SplashEntry[] _entries; // 可配置的 Logo 序列
[SerializeField] CanvasGroup _bg;
[SerializeField] Image _logo;
[SerializeField] TMP_Text _legalText;
[Serializable]
public struct SplashEntry
{
public Sprite sprite; // null 则显示 legalText
public string legalTextKey; // 文字公告时使用
public float displayTime;
public float minBeforeSkip; // 最少显示时长(不可跳过)
}
async void Start()
{
foreach (var entry in _entries)
{
await ShowEntry(entry);
}
SceneManager.LoadSceneAsync("MainMenu");
}
async Task ShowEntry(SplashEntry entry)
{
// 淡入显示 → 等待 → 监听跳过输入minBeforeSkip 后才响应)→ 淡出
_bg.alpha = 0f;
_logo.sprite = entry.sprite;
await _bg.DOFade(1f, 0.4f).AsyncWaitForCompletion();
float elapsed = 0f;
while (elapsed < entry.displayTime)
{
elapsed += Time.unscaledDeltaTime;
bool canSkip = elapsed >= entry.minBeforeSkip;
if (canSkip && Input.anyKeyDown) break;
await Task.Yield();
}
await _bg.DOFade(0f, 0.3f).AsyncWaitForCompletion();
}
}
```
### 27.3 评级图标规范
```
Assets/Sprites/UI/Ratings/
├── Rating_ESRB_T.png (T for Teen)
├── Rating_ESRB_M.png
├── Rating_CERO_B.png (日本)
├── Rating_PEGI_12.png (欧洲)
└── Rating_USK_12.png (德国)
```
评级图标在法务声明页右下角显示,尺寸遵循各评级机构官方规范。
---
## 28. PC 平台光标管理
PC 版在游戏中必须隐藏鼠标光标,在菜单中显示;同时支持锁定到屏幕中心(防止多显示器误操作)。
### 28.1 CursorManager
```csharp
[DefaultExecutionOrder(+50)]
public class CursorManager : MonoBehaviour
{
[SerializeField] GameStateEventChannelSO _onGameStateChanged;
void OnEnable() => _onGameStateChanged.OnEventRaised += HandleState;
void OnDisable() => _onGameStateChanged.OnEventRaised -= HandleState;
void HandleState(GameState state)
{
bool showCursor = state is
GameState.MainMenu or
GameState.Paused or
GameState.Settings;
Cursor.visible = showCursor;
Cursor.lockState = showCursor
? CursorLockMode.None
: CursorLockMode.Confined; // Confined 限制在窗口内,防止多显示器误移
}
}
```
### 28.2 平台条件编译
```csharp
void HandleState(GameState state)
{
#if UNITY_STANDALONE_WIN || UNITY_STANDALONE_OSX || UNITY_STANDALONE_LINUX
// 仅 PC 平台执行光标管理
bool showCursor = state is GameState.MainMenu or GameState.Paused or GameState.Settings;
Cursor.visible = showCursor;
Cursor.lockState = showCursor ? CursorLockMode.None : CursorLockMode.Confined;
#endif
// 主机/移动平台光标始终隐藏Cursor 代码不执行
}
```
### 28.3 手柄模式下的虚拟光标
手柄操作时不使用系统光标,改用 EventSystem 的 `StandaloneInputModule` 导航(已在 §22 覆盖)。切换到手柄后额外隐藏系统光标:
```csharp
// InputDeviceDetector 中,切换到 Gamepad 时:
Cursor.visible = false;
Cursor.lockState = CursorLockMode.Locked;
// 切回 KeyboardMouse处于菜单状态时
Cursor.visible = true;
Cursor.lockState = CursorLockMode.None;
```
---
## 29. 本地化深度集成
### 29.1 多语言字体资产策略
不同语系需要不同字体资产TMP Font Asset不可混用
| 语系 | 字体资产 | 备注 |
|------|---------|------|
| 中文简体 | `TMP_PixelCJK_SC.asset` | 需包含全部 GB2312 常用汉字 |
| 中文繁体 | `TMP_PixelCJK_TC.asset` | 繁体字形 |
| 日语 | `TMP_PixelJP.asset` | 含假名 + 常用汉字 |
| 韩语 | `TMP_PixelKR.asset` | 含谚文全套 |
| 英语/欧洲 | `TMP_PixelLatin.asset` | 含拉丁扩展字符集(法语重音等)|
| 阿拉伯语P2| `TMP_Arabic.asset` | RTL 布局,需 TextMeshPro RTL 支持 |
```csharp
// LocalizedFontSetter.cs — 挂载在所有 TMP_Text 对象上(或通过基类统一处理)
public class LocalizedFontSetter : MonoBehaviour
{
[SerializeField] TMP_Text _text;
[SerializeField] FontLibrarySO _fontLibrary;
[SerializeField] LocaleEventChannelSO _onLocaleChanged;
void OnEnable()
{
_onLocaleChanged.OnEventRaised += Apply;
Apply(LanguageManager.CurrentLocale);
}
void Apply(string locale)
=> _text.font = _fontLibrary.GetForLocale(locale);
}
```
### 29.2 文本溢出策略
| 场景 | 策略 | 实现 |
|------|------|------|
| 按钮文字 | `Overflow = Truncate` + `...` 后缀 | 设计时留出 20% 扩展空间 |
| 对话框 | `Overflow = ScrollRect`(纵向滚动)| 对话框高度固定,内容超出可滚动 |
| HUD 计数器 | 字号随数字位数缩小 | `TMP AutoSize` 设最大/最小字号 |
| 通知卡片 | 单行强制截断Tooltip 显示全文 | `TMP Overflow = Truncate` |
| 任务日志 | 固定宽度内换行,不截断 | `TMP Overflow = Overflow`(纵向延伸)|
**设计规范**UI 设计稿以中文为参考(字符密度最高),其他语言文本不超出中文的 2 倍宽度(德语/芬兰语文本极长,需特别测试)。
### 29.3 本地化图片替换
Logo、含文字的 UI 图(标题图、成就图标描述部分)需按语言替换:
```csharp
[CreateAssetMenu(menuName = "UI/Localized Sprite")]
public class LocalizedSpriteSO : ScriptableObject
{
[Serializable] public struct LocaleSprite { public string locale; public Sprite sprite; }
public LocaleSprite[] sprites;
public Sprite fallback;
public Sprite Get(string locale)
{
foreach (var ls in sprites)
if (ls.locale == locale) return ls.sprite;
return fallback;
}
}
```
### 29.4 数字/日期格式本地化
```csharp
// 格式化时始终传入 CultureInfo不使用系统默认文化主机平台可能为 InvariantCulture
string FormatPlaytime(double seconds)
{
int h = (int)(seconds / 3600);
int m = (int)(seconds % 3600 / 60);
// 使用 LanguageManager 提供的格式字符串(避免硬编码中文"h"/"m"
return _lang.Format("ui.playtime.format", h, m);
// zh: "{0}时{1}分" | en: "{0}h {1}m" | ja: "{0}時間{1}分"
}
```