1700 lines
57 KiB
Markdown
1700 lines
57 KiB
Markdown
# 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<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 / 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<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
|
||
// 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<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 工具槽 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<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 Canvas,Sorting 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 Logo(BuildSettings 中关闭时可省略)
|
||
├── 发行商 Logo(2.5s,0.5s 淡入 + 1.5s 停留 + 0.5s 淡出)
|
||
├── 开发商 Logo(2.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}分"
|
||
}
|
||
```
|