57 KiB
10 · UI 系统
命名空间
BaseGames.UI
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Combat(Boss HP)· TextMeshPro · DOTween(P1 过渡动画)
目录
- 设计原则
- Canvas 架构
- UIManager
- HUD 组件
- Boss HP 条
- 主菜单(MainMenu)
- Pause 菜单
- 死亡画面
- 加载过渡遮罩
- 设置菜单
- UI 事件频道
- 编辑器友好设计
- 加载画面(Loading Screen)
- 无障碍设计(Accessibility)
- UIManager 解耦改进说明
- 存档点交互 & 存档槽选择界面
- 存档进行中指示(Save Indicator)
- 通知系统(Toast / 成就 / 任务)
- HUD 扩展:工具槽 & 形态指示
- 全屏功能界面索引
- UI 音效系统
- 控制器导航 & 面板栈
- 输入设备图标自动切换
- 浮动战斗文字(伤害数字)
- HUD 自适应可见性 & 过场隐藏
- 安全区(Safe Area)适配
- 首次启动序列(Logo & 法务公告)
- PC 平台光标管理
- 本地化深度集成
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 显隐。
类结构
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):
[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 以心形图标列表示,而非连续血条(像素风标准做法):
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 淡出隐藏:
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):
| 时刻 | 事件 |
|---|---|
| 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 控制淡入淡出:
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(),保存设置
音量应用逻辑
// 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. 编辑器友好设计
UIManagerCustom 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
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 |
边缘增强 + 提高对比度 |
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 层:
// 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;
}
}
// 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# 实现骨架
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 ("正在保存..." / "已保存")
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
[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 动画
右侧滑入 → 停留 → 淡出右侧退出:
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。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。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 | 暂停内 / 快捷键 M |
Canvas_Menu |
| 护符/装备 | 17_EquipmentSystem | 暂停内 / 快捷键 C |
Canvas_Menu |
| 技能/形态 | 21_SpellSystem | 暂停内 | Canvas_Menu |
| 任务日志 | 38_QuestSystem §QuestLogUI | 暂停内 / 快捷键 J |
Canvas_Menu |
| 商店 | 28_ShopSystem | NPC 交互触发 | Canvas_Menu |
| 对话框 | 15_DialogueSystem | 对话事件触发 | Canvas_Overlay |
| 挑战房间结算 | 39_ChallengeRoomSystem | 挑战完成事件 | Canvas_Overlay |
| 难度选择 | 29_DifficultyModesGuide §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
[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 组件扩展
// 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 的焦点对象
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 应回到暂停而非主菜单」的问题:
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 按钮全局处理
// 在 InputReaderSO 中添加 UICancel 事件(对应手柄 East Button / 键盘 Escape)
// UIManager 订阅,调用 _panelStack.Pop()(若栈非空)
// 若栈为空且当前在 Pause 状态,则调用 ResumeGame()
23. 输入设备图标自动切换
多平台商业游戏要求按钮提示图标跟随最后活跃输入设备自动切换(键鼠 / Xbox / PS4 / PS5 / Switch)。
23.1 InputIconLibrarySO
[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
[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:
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
[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 动画
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 全覆盖 |
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 可见性:
○ 始终显示
● 非战斗时淡出(默认)
○ 仅受伤时显示
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 组件
/// <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 基础上叠加额外边距:
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
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
[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 平台条件编译
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 覆盖)。切换到手柄后额外隐藏系统光标:
// 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 支持 |
// 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 图(标题图、成就图标描述部分)需按语言替换:
[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 数字/日期格式本地化
// 格式化时始终传入 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}分"
}