246 lines
9.0 KiB
C#
246 lines
9.0 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// 驱动主菜单存档槽选择面板(新游戏 / 继续 / 删除)。
|
||
/// </summary>
|
||
public class SaveSlotController : MonoBehaviour
|
||
{
|
||
[SerializeField] private SaveSlotUI[] _slotUIs;
|
||
|
||
[Header("Event Channels")]
|
||
[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()
|
||
{
|
||
_cts = new CancellationTokenSource();
|
||
var ct = _cts.Token;
|
||
var task = RefreshAsync(ct);
|
||
task.ContinueWith(t =>
|
||
{
|
||
if (t.IsFaulted && !(t.Exception?.InnerException is OperationCanceledException))
|
||
Debug.LogException(t.Exception?.InnerException ?? t.Exception, this);
|
||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||
}
|
||
|
||
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++)
|
||
{
|
||
if (_slotUIs[i] == null) continue;
|
||
_slotUIs[i].Refresh(summaries[i]);
|
||
}
|
||
}
|
||
|
||
/// <summary>选中指定槽位(新局或继续)。由 SaveSlotUI 内部按钮调用。</summary>
|
||
public void OnSlotSelected(int slotIndex)
|
||
{
|
||
if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return;
|
||
_ = SelectSlotAsync(slotIndex);
|
||
}
|
||
|
||
private async Task SelectSlotAsync(int slotIndex)
|
||
{
|
||
var svc = ServiceLocator.GetOrDefault<ISaveService>();
|
||
if (svc == null) return;
|
||
|
||
if (svc.HasSave(slotIndex))
|
||
await svc.LoadAsync(slotIndex);
|
||
else
|
||
svc.CreateSlot(slotIndex);
|
||
|
||
_onSlotConfirmed?.Raise(slotIndex);
|
||
}
|
||
|
||
/// <summary>删除指定槽位存档并刷新 UI。由 SaveSlotUI 内部按钮调用。</summary>
|
||
public void OnSlotDeleteRequested(int slotIndex)
|
||
{
|
||
if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return;
|
||
_ = DeleteAndRefreshAsync(slotIndex);
|
||
}
|
||
|
||
private async Task DeleteAndRefreshAsync(int slotIndex)
|
||
{
|
||
var svc = ServiceLocator.GetOrDefault<ISaveService>();
|
||
if (svc == null) return;
|
||
await svc.DeleteSlotAsync(slotIndex);
|
||
await RefreshAsync(_cts?.Token ?? CancellationToken.None);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 单个存档槽卡片组件,负责显示存档摘要或空槽提示。
|
||
/// </summary>
|
||
public class SaveSlotUI : MonoBehaviour
|
||
{
|
||
[SerializeField] private TMP_Text _playtimeText;
|
||
[SerializeField] private TMP_Text _regionText;
|
||
[SerializeField] private TMP_Text _lastSavedText;
|
||
[SerializeField] private Image _formIcon;
|
||
[SerializeField] private GameObject _emptyIndicator; // 空槽时显示的"新游戏"提示
|
||
[SerializeField] private GameObject _dataIndicator; // 有数据时显示的内容根
|
||
[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;
|
||
|
||
/// <summary>由 SaveSlotController 在 Awake 或初始化时调用以完成按钮绑定。</summary>
|
||
public void Init(int slotIndex, SaveSlotController controller)
|
||
{
|
||
_slotIndex = slotIndex;
|
||
_controller = controller;
|
||
|
||
if (_selectButton != null)
|
||
{
|
||
_selectButton.onClick.RemoveAllListeners();
|
||
_selectButton.onClick.AddListener(() => _controller.OnSlotSelected(_slotIndex));
|
||
}
|
||
|
||
if (_deleteButton != null)
|
||
{
|
||
_deleteButton.onClick.RemoveAllListeners();
|
||
// 配置了确认对话框时先弹出确认,未配置时直接删除
|
||
_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>
|
||
public void Refresh(SlotSummary summary)
|
||
{
|
||
bool hasData = summary != null;
|
||
|
||
if (_emptyIndicator != null) _emptyIndicator.SetActive(!hasData);
|
||
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)
|
||
{
|
||
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);
|
||
int m = (int)((seconds % 3600) / 60);
|
||
int s = (int)(seconds % 60);
|
||
return $"{h:D2}:{m:D2}:{s:D2}";
|
||
}
|
||
|
||
private static string FormatDateTime(string iso8601)
|
||
{
|
||
if (string.IsNullOrEmpty(iso8601)) return string.Empty;
|
||
if (DateTime.TryParse(iso8601,
|
||
System.Globalization.CultureInfo.InvariantCulture,
|
||
System.Globalization.DateTimeStyles.RoundtripKind,
|
||
out DateTime dt))
|
||
{
|
||
return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
|
||
}
|
||
return iso8601;
|
||
}
|
||
}
|
||
}
|