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

57 KiB
Raw Permalink Blame History

10 · UI 系统

命名空间 BaseGames.UI
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.CombatBoss HP· TextMeshPro · DOTweenP1 过渡动画)


目录

  1. 设计原则
  2. Canvas 架构
  3. UIManager
  4. HUD 组件
  5. Boss HP 条
  6. 主菜单MainMenu
  7. Pause 菜单
  8. 死亡画面
  9. 加载过渡遮罩
  10. 设置菜单
  11. UI 事件频道
  12. 编辑器友好设计
  13. 加载画面Loading Screen
  14. 无障碍设计Accessibility
  15. UIManager 解耦改进说明
  16. 存档点交互 & 存档槽选择界面
  17. 存档进行中指示Save Indicator
  18. 通知系统Toast / 成就 / 任务)
  19. HUD 扩展:工具槽 & 形态指示
  20. 全屏功能界面索引
  21. UI 音效系统
  22. 控制器导航 & 面板栈
  23. 输入设备图标自动切换
  24. 浮动战斗文字(伤害数字)
  25. HUD 自适应可见性 & 过场隐藏
  26. 安全区Safe Area适配
  27. 首次启动序列Logo & 法务公告)
  28. PC 平台光标管理
  29. 本地化深度集成

1. 设计原则

  • 状态驱动UIManager 监听 GameState 变化,自动激活/隐藏对应 Canvas Root由中央管理器决定何时显示什么
  • 像素风适配UI 字体使用 TextMeshPro + 像素字体UI 元素整数像素对齐Canvas Scaler 使用 Scale With Screen Size(参考分辨率 1920×1080
  • 零耦合HUD 只订阅事件频道(OnPlayerHPChangedOnPlayerSoulChanged),不持有 PlayerStats 直接引用
  • 分层 CanvasHUD / 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 显隐。

类结构

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 右侧显示空心 受伤时对应心图标 ShakeMMF_Position
HP == 0 全部空心 最后一颗心 Scale 缩小到 0MMF_Scale
MaxHP 增加 新心从右侧弹入 弹入动画(MMF_Scale 0→1

行宽规则:最多 8 个心/行,超出自动换行(最多 3 行,即 MaxHP 上限 24


4.2 Soul 灵魂槽SoulGauge

弧形进度条,Image.type = FilledFill 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 淡出隐藏:

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.PauseEventEscape / 手柄 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 = trueFeel 已内置该选项),保证菜单弹入动画不受暂停影响。


8. 死亡画面

DeathScreenPanel           (全屏黑底 CanvasGroupalpha 从 0 渐入到 1)
├── DeathText              ("你死了"像素大字慢速淡入delay 0.3s)
├── GeoLostText            ("遗失 {N} Geo",有 Shade 机制时显示P1)
└── PromptText             ("按任意键继续"1s 延迟后出现,闪烁循环)

时序由 GameManager 协程控制(详见 11_GameManager.md §5

时刻 事件
0.0s OnPlayerDied 触发,GameState → DeadDeathScreenPanel 开始渐入
1.2s 玩家死亡动画结束
2.0s DeathScreenPanel 完全可见alpha=1
2.0s~ 等待玩家按键(或 3s 超时自动继续)
+0.3s LoadingOverlay 淡入遮盖 → 场景加载 → 淡出

9. 加载过渡遮罩

LoadingOverlayCanvas_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.assetBoolEventChannelSO已存在于 Events/Camera/BossHPBar 直接复用。


12. 编辑器友好设计

  • UIManager Custom Inspector实时显示当前 GameState 文字标签 + 各 Canvas Root 激活状态(绿/红标记)
  • Editor Only 测试按钮UI Toolkit Button + ToggleCreateInspectorGUI() 中实现):
    • [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]
  ...
  [恢复默认]

通过 PlayerInputInputActionRebindingExtensions.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;
    }
}
// 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 实现 IBossHPProviderBossHPBar 改为依赖接口 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_MenuSorting 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 开始前通过 OnSaveStartedVoidEventChannelSO)触发 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 工具槽 HUDToolSlotHUD

详细设计见 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(对话框除外,对话框在游戏运行中叠加)
  • 背景使用 FullscreenDimOverlayCanvas_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);
}

所有 PanelPauseMenuPanelSettingsPanelSaveSlotPanel 等)继承 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 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 动画

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_HUDHUDPanel:挂载 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 LogoBuildSettings 中关闭时可省略)
  ├── 发行商 Logo2.5s0.5s 淡入 + 1.5s 停留 + 0.5s 淡出)
  ├── 开发商 Logo2.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}分"
}