UI系统优化

This commit is contained in:
2026-05-25 11:54:37 +08:00
parent c7057db27d
commit 3c812cfb41
130 changed files with 4738 additions and 477 deletions

View File

@@ -1,6 +1,7 @@
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core.Events;
@@ -11,15 +12,28 @@ namespace BaseGames.UI.Menus
[SerializeField] private TMP_Text _deathMessage;
[SerializeField] private Button _btnRespawn;
[Header("时序")]
[Tooltip("死亡画面出现前的缓冲延迟(秒)。调整此值可匹配死亡动画时长。")]
[SerializeField] private float _showDelay = 1.5f;
[Tooltip("死亡文字默认显示内容,会被本地化系统覆盖(如果已配置)。")]
[SerializeField] private string _defaultDeathText = "決死";
[Header("Event Channels")]
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
private WaitForSecondsRealtime _showDelayWait;
private void Awake()
{
_showDelayWait = new WaitForSecondsRealtime(_showDelay);
}
private void OnEnable()
{
// 死亡界面由 UIManager 在游戏状态变为 Dead 时通过 SetActive(true) 激活。
// _onPlayerDied 事件此时已经触发完毕,订阅它不会收到回调。
// 直接在 OnEnable 启动延迟显示协程即可保证 1.5s 缓冲。
StartCoroutine(ShowAfterDelay(1.5f));
StartCoroutine(ShowAfterDelay(_showDelay));
}
private void OnDisable()
@@ -29,17 +43,22 @@ namespace BaseGames.UI.Menus
private IEnumerator ShowAfterDelay(float delay)
{
yield return new WaitForSeconds(delay);
yield return _showDelayWait;
Show();
}
private void Show()
{
if (_deathMessage != null) _deathMessage.text = _defaultDeathText;
if (_btnRespawn != null)
{
_btnRespawn.onClick.RemoveAllListeners();
_btnRespawn.onClick.AddListener(Confirm);
}
// 手柄导航:死亡界面显示后将焦点置于复活按钮
EventSystem.current?.SetSelectedGameObject(_btnRespawn?.gameObject);
}
private void Confirm()

View File

@@ -1,5 +1,6 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
@@ -11,10 +12,10 @@ namespace BaseGames.UI
/// 挂载在 Canvas_Menu → PauseMenuPanel GameObject 上。
/// 按钮绑定在 Awake 中完成;由 UIManager 负责面板开关。
/// </summary>
public class PauseMenuController : MonoBehaviour
public class PauseMenuController : MonoBehaviour, IFocusable
{
[SerializeField] private UIManager _uiManager;
[SerializeField] private GameObject _settingsRoot; // SettingsPanel 根 GameObject
// UIManager 通过 ServiceLocator 解析,开启时自动获取,无需 Inspector 直接绑定具体类型
private IUIManager _uiManager;
[Header("按钮引用")]
[SerializeField] private Button _btnResume;
@@ -28,10 +29,23 @@ namespace BaseGames.UI
private void Awake()
{
_btnResume.onClick.AddListener(Resume);
_btnSettings.onClick.AddListener(OpenSettings);
_btnMainMenu.onClick.AddListener(GoToMainMenu);
_btnQuit.onClick.AddListener(Application.Quit);
_btnResume?.onClick.AddListener(Resume);
_btnSettings?.onClick.AddListener(OpenSettings);
_btnMainMenu?.onClick.AddListener(GoToMainMenu);
_btnQuit?.onClick.AddListener(Application.Quit);
}
private void OnEnable()
{
// 暂停面板由 UIManager 开启,此时 ServiceLocator 已就绪
_uiManager = ServiceLocator.GetOrDefault<IUIManager>();
// 手柄导航:打开时将焦点置于第一个按钮
EventSystem.current?.SetSelectedGameObject(_btnResume?.gameObject);
}
private void OnDisable()
{
_uiManager = null;
}
// ── 按钮回调 ──────────────────────────────────────────────────────────
@@ -39,18 +53,17 @@ namespace BaseGames.UI
private void Resume()
{
_onResumeRequested?.Raise();
_uiManager.CloseTopPanel();
_uiManager?.CloseTopPanel();
}
private void OpenSettings()
{
if (_settingsRoot != null)
_uiManager.OpenPanel(_settingsRoot);
_uiManager?.OpenPanel(PanelId.Settings);
}
private void GoToMainMenu()
{
_uiManager.CloseTopPanel();
_uiManager?.CloseTopPanel();
_onSceneLoadRequest?.Raise(new SceneLoadRequest
{
SceneName = AddressKeys.SceneMainMenu,
@@ -58,5 +71,11 @@ namespace BaseGames.UI
ShowLoadingScreen = false,
});
}
// ── IFocusable ────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶时(关闭子面板后)自动移回第一个按鈕。</summary>
public void OnFocusRestored()
=> EventSystem.current?.SetSelectedGameObject(_btnResume?.gameObject);
}
}

View File

@@ -1,11 +1,14 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Localization;
namespace BaseGames.UI.Menus
{
@@ -14,32 +17,49 @@ namespace BaseGames.UI.Menus
/// </summary>
public class SaveSlotController : MonoBehaviour
{
[SerializeField] private SaveSlotUI[] _slotUIs; // 存档槽 UI数量由 Inspector 决定)
[SerializeField] private SaveSlotUI[] _slotUIs;
[Header("Event Channels")]
[SerializeField] private IntEventChannelSO _onSlotConfirmed; // 携带槽索引,供 GameManager 监听
[SerializeField] private IntEventChannelSO _onSlotConfirmed;
private CancellationTokenSource _cts;
private void Awake()
{
for (int i = 0; i < _slotUIs.Length; i++)
if (_slotUIs[i] != null) _slotUIs[i].Init(i, this);
}
private void OnEnable()
{
var task = RefreshAsync();
_cts = new CancellationTokenSource();
var ct = _cts.Token;
var task = RefreshAsync(ct);
task.ContinueWith(t =>
{
if (t.IsFaulted)
if (t.IsFaulted && !(t.Exception?.InnerException is OperationCanceledException))
Debug.LogException(t.Exception?.InnerException ?? t.Exception, this);
}, TaskScheduler.FromCurrentSynchronizationContext());
}
private async Task RefreshAsync()
private void OnDisable()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
private async Task RefreshAsync(CancellationToken ct = default)
{
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
// 并行加载所有槽位摘要,减少主菜单等待时间
var tasks = new Task<SlotSummary>[_slotUIs.Length];
for (int i = 0; i < _slotUIs.Length; i++)
tasks[i] = svc.GetSlotSummaryAsync(i);
var summaries = await Task.WhenAll(tasks);
ct.ThrowIfCancellationRequested(); // 组件已失活时不写 UI
for (int i = 0; i < _slotUIs.Length; i++)
{
@@ -80,7 +100,7 @@ namespace BaseGames.UI.Menus
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
await svc.DeleteSlotAsync(slotIndex);
await RefreshAsync();
await RefreshAsync(_cts?.Token ?? CancellationToken.None);
}
}
@@ -98,6 +118,14 @@ namespace BaseGames.UI.Menus
[SerializeField] private Button _selectButton;
[SerializeField] private Button _deleteButton;
[Header("删除确认(可选)")]
[Tooltip("删除确认对话框根节点,为 null 时删除按钮直接执行。")]
[SerializeField] private GameObject _deleteConfirmRoot;
[Tooltip("确认删除按钮。")]
[SerializeField] private Button _btnConfirmDelete;
[Tooltip("取消删除按钮。")]
[SerializeField] private Button _btnCancelDelete;
private int _slotIndex;
private SaveSlotController _controller;
@@ -112,11 +140,31 @@ namespace BaseGames.UI.Menus
_selectButton.onClick.RemoveAllListeners();
_selectButton.onClick.AddListener(() => _controller.OnSlotSelected(_slotIndex));
}
if (_deleteButton != null)
{
_deleteButton.onClick.RemoveAllListeners();
_deleteButton.onClick.AddListener(() => _controller.OnSlotDeleteRequested(_slotIndex));
// 配置了确认对话框时先弹出确认,未配置时直接删除
_deleteButton.onClick.AddListener(ShowDeleteConfirm);
}
if (_btnConfirmDelete != null)
{
_btnConfirmDelete.onClick.RemoveAllListeners();
_btnConfirmDelete.onClick.AddListener(() =>
{
HideDeleteConfirm();
_controller.OnSlotDeleteRequested(_slotIndex);
});
}
if (_btnCancelDelete != null)
{
_btnCancelDelete.onClick.RemoveAllListeners();
_btnCancelDelete.onClick.AddListener(HideDeleteConfirm);
}
HideDeleteConfirm();
}
/// <summary>用摘要数据刷新显示summary 为 null 表示空槽。</summary>
@@ -128,13 +176,51 @@ namespace BaseGames.UI.Menus
if (_dataIndicator != null) _dataIndicator.SetActive(hasData);
if (_deleteButton != null) _deleteButton.gameObject.SetActive(hasData);
// 刷新时重置确认对话框状态
HideDeleteConfirm();
if (!hasData) return;
if (_playtimeText != null) _playtimeText.text = FormatPlaytime(summary.Playtime);
if (_regionText != null) _regionText.text = summary.SceneName ?? string.Empty;
if (_regionText != null)
{
string key = summary.SceneName ?? string.Empty;
string loc = !string.IsNullOrEmpty(key)
? LocalizationManager.Get(key, LocalizationTable.UI)
: null;
_regionText.text = !string.IsNullOrEmpty(loc) && loc != key ? loc : key;
}
if (_lastSavedText != null) _lastSavedText.text = FormatDateTime(summary.LastSaved);
}
// ── 删除确认 ──────────────────────────────────────────────────────────
/// <summary>
/// 显示删除确认对话框。
/// 未配置 _deleteConfirmRoot 时直接执行删除(向下兼容旧 Prefab
/// </summary>
private void ShowDeleteConfirm()
{
if (_deleteConfirmRoot == null)
{
// 旧 Prefab 未添加确认根节点,直接删除
_controller.OnSlotDeleteRequested(_slotIndex);
return;
}
_deleteConfirmRoot.SetActive(true);
// 手柄导航:将焦点移至"确认"按钮,防止误触选择按钮
EventSystem.current?.SetSelectedGameObject(_btnConfirmDelete?.gameObject);
}
private void HideDeleteConfirm()
{
if (_deleteConfirmRoot != null) _deleteConfirmRoot.SetActive(false);
}
// ── 格式化工具 ────────────────────────────────────────────────────────
private static string FormatPlaytime(float seconds)
{
int h = (int)(seconds / 3600);

View File

@@ -1,6 +1,7 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
namespace BaseGames.UI
@@ -9,9 +10,11 @@ namespace BaseGames.UI
/// 设置面板控制器(架构 10_UIModule §7
/// 驱动 SettingsManager 的音量与画面设置,并从当前配置初始化控件值。
/// </summary>
public class SettingsPanelController : MonoBehaviour
public class SettingsPanelController : MonoBehaviour, IFocusable
{
[SerializeField] private SettingsManager _settings;
// ISettingsService 通过 ServiceLocator 获取,无需 Inspector 直接注入具体类,
// 支持测试场景替换 Mock 实现。
private ISettingsService _settings;
[Header("音量滑条")]
[SerializeField] private Slider _masterVolume;
@@ -28,12 +31,13 @@ namespace BaseGames.UI
private static readonly int[] FpsOptions = { 30, 60, 120, -1 };
private void Start()
private void OnEnable()
{
_settings = ServiceLocator.GetOrDefault<ISettingsService>();
if (_settings == null) return;
var data = _settings.Current;
// 初始化控件值(不触发 onChange先移除监听再设置值再添加)
// 初始化控件值(先移除监听再设置值再添加,防止面板重开时重复注册
InitSlider(_masterVolume, data.MasterVolume, v => _settings.SetMasterVolume(v));
InitSlider(_bgmVolume, data.BGMVolume, v => _settings.SetBGMVolume(v));
InitSlider(_sfxVolume, data.SFXVolume, v => _settings.SetSFXVolume(v));
@@ -41,17 +45,22 @@ namespace BaseGames.UI
if (_vSyncToggle != null)
{
_vSyncToggle.onValueChanged.RemoveAllListeners();
_vSyncToggle.isOn = data.VSync;
_vSyncToggle.onValueChanged.AddListener(v => _settings.SetVSync(v));
}
if (_fpsDropdown != null)
{
_fpsDropdown.onValueChanged.RemoveAllListeners();
int idx = System.Array.IndexOf(FpsOptions, data.TargetFPS);
_fpsDropdown.value = idx >= 0 ? idx : 1; // default 60
_fpsDropdown.onValueChanged.AddListener(i =>
_settings.SetTargetFrameRate(FpsOptions[Mathf.Clamp(i, 0, FpsOptions.Length - 1)]));
}
// 手柄导航:打开设置面板时将焦点置于主音量滑条
EventSystem.current?.SetSelectedGameObject(_masterVolume?.gameObject);
}
// ── 辅助 ──────────────────────────────────────────────────────────────
@@ -59,8 +68,15 @@ namespace BaseGames.UI
private static void InitSlider(Slider slider, float value, UnityEngine.Events.UnityAction<float> onChange)
{
if (slider == null) return;
slider.onValueChanged.RemoveAllListeners();
slider.value = value;
slider.onValueChanged.AddListener(onChange);
}
// ── IFocusable ────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶时将焦点移回主音量滑条。</summary>
public void OnFocusRestored()
=> EventSystem.current?.SetSelectedGameObject(_masterVolume?.gameObject);
}
}