Files
zeling_v2/Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs
2026-06-07 11:49:55 +08:00

212 lines
9.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.UI;
using BaseGames.UI.MainMenu;
namespace BaseGames.UI.Menus
{
/// <summary>主菜单存档槽面板的打开语境。</summary>
public enum SaveSlotPanelMode
{
/// <summary>继续游戏:仅有档槽可选,点击即读档进入。</summary>
Continue,
/// <summary>新游戏:空槽 → 模式选择 → 建档;有档槽 → 覆盖确认 → 模式选择 → 建档。</summary>
NewGame,
}
/// <summary>
/// 驱动主菜单存档槽选择面板(新游戏 / 继续 / 删除)。
///
/// <para>本面板经 <see cref="IUINavigator"/> 压栈(由主菜单控制器 Push。覆盖确认 / 模式选择
/// 这两个子对话框走导航器结果面板(<see cref="ConfirmDialogController.ShowAsync"/> /
/// <see cref="NewGameModeController.ShowAsync"/>)——线性 <c>await</c>无回调嵌套ESC 逐层回退。</para>
///
/// 前端选档流程:
/// · 新游戏开档前先选模式(普通 / 钢铁之魂),占用槽位需覆盖确认。
/// · 删除强制走通用确认对话框(无静默删除旁路)。
/// · 卡片展示游玩时长 / 区域 / 货币 / 生命 / 钢魂徽章。
/// 模式由主菜单控制器在压栈前经 <see cref="SetMode"/> 指定。
/// </summary>
public class SaveSlotController : UIPanelBase
{
[SerializeField] private SaveSlotUI[] _slotUIs;
[Header("子对话框Inspector 注入,同处 MainMenu 场景,走导航器结果面板)")]
[Tooltip("通用确认对话框,用于覆盖 / 删除确认。为空时:覆盖退化为直接建档,删除被忽略(绝不静默删除)。")]
[SerializeField] private ConfirmDialogController _confirmDialog;
[Tooltip("新游戏模式选择面板。为空时新游戏退化为普通模式。")]
[SerializeField] private NewGameModeController _modeSelect;
[Header("Event Channels")]
[SerializeField] private IntEventChannelSO _onSlotConfirmed;
[Header("焦点")]
[Tooltip("面板默认聚焦的按钮,通常为第一个存档槽的选择按钮。")]
[SerializeField] private Button _defaultFocusButton;
private SaveSlotPanelMode _mode = SaveSlotPanelMode.NewGame;
private CancellationTokenSource _cts;
private void Awake()
{
for (int i = 0; i < _slotUIs.Length; i++)
if (_slotUIs[i] != null) _slotUIs[i].Init(i, this);
}
/// <summary>由主菜单控制器在压栈前调用,决定本次打开语境。</summary>
public void SetMode(SaveSlotPanelMode mode) => _mode = mode;
/// <summary>默认 / 恢复焦点回到首个存档槽按钮。</summary>
protected override GameObject ResolveFirstSelected()
=> _defaultFocusButton != null ? _defaultFocusButton.gameObject : null;
// ── 生命周期UIPanelBase 驱动)──────────────────────────────────────
protected override void OnPanelOpen()
{
_cts = new CancellationTokenSource();
RunGuarded(RefreshAsync(_cts.Token));
}
protected override void OnPanelClose()
{
// 注意:不再级联 Close 子对话框(旧实现于此强关 confirm/mode是"ESC 一次关多层"的根因)。
// 子对话框生命周期完全交给导航器;若本面板随场景卸载,其 await 由结果面板兜底收口。
_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], _mode);
}
}
// ── 选槽 ────────────────────────────────────────────────────────────────
/// <summary>选中指定槽位。行为取决于当前模式与槽位是否有档。由 SaveSlotUI 内部按钮调用。</summary>
public void OnSlotSelected(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return;
RunGuarded(HandleSlotSelectedAsync(slotIndex));
}
private async Task HandleSlotSelectedAsync(int slotIndex)
{
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
var ct = _cts?.Token ?? CancellationToken.None;
if (_mode == SaveSlotPanelMode.Continue)
{
if (!svc.HasSave(slotIndex)) return; // 继续模式:空槽不可选
await ContinueSlotAsync(slotIndex);
return;
}
// ── 新游戏模式 ──
if (svc.HasSave(slotIndex) && _confirmDialog != null)
{
bool ok = await _confirmDialog.ShowAsync("CONFIRM_OVERWRITE_TITLE", "CONFIRM_OVERWRITE_BODY", ct);
if (!ok) return; // 取消覆盖
}
DifficultyLevel level = DifficultyLevel.Normal;
if (_modeSelect != null)
{
DifficultyLevel? chosen = await _modeSelect.ShowAsync(ct);
if (chosen == null) return; // 取消模式选择
level = chosen.Value;
}
await StartNewGameAsync(slotIndex, level);
}
private async Task StartNewGameAsync(int slotIndex, DifficultyLevel level)
{
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
// 覆盖既有档:先删盘,确保旧数据不残留(建档仅初始化内存,写盘发生在首次存档)
if (svc.HasSave(slotIndex))
await svc.DeleteSlotAsync(slotIndex);
bool steel = level == DifficultyLevel.SteelSoul;
svc.CreateSlot(slotIndex, steel);
ServiceLocator.GetOrDefault<IDifficultyService>()?.BeginNewGame(level);
_onSlotConfirmed?.Raise(slotIndex);
}
private async Task ContinueSlotAsync(int slotIndex)
{
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
bool ok = await svc.LoadAsync(slotIndex);
if (!ok) return; // 损坏 / 不存在:不前进(后续可接错误提示)
_onSlotConfirmed?.Raise(slotIndex);
}
// ── 删除(强制确认)────────────────────────────────────────────────────
/// <summary>请求删除指定槽位。强制经通用确认对话框;无对话框时忽略,绝不静默删除。</summary>
public void OnSlotDeleteRequested(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return;
if (_confirmDialog == null)
{
Debug.LogWarning("[SaveSlotController] 未配置 ConfirmDialog删除请求被忽略防止静默删除。");
return;
}
RunGuarded(DeleteFlowAsync(slotIndex));
}
private async Task DeleteFlowAsync(int slotIndex)
{
var ct = _cts?.Token ?? CancellationToken.None;
bool ok = await _confirmDialog.ShowAsync("CONFIRM_DELETE_TITLE", "CONFIRM_DELETE_BODY", ct);
if (!ok) return;
await 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>fire-and-forget Task 的统一异常护栏(吞掉取消,记录其余)。</summary>
private void RunGuarded(Task task)
{
task.ContinueWith(t =>
{
if (t.IsFaulted && !(t.Exception?.InnerException is OperationCanceledException))
Debug.LogException(t.Exception?.InnerException ?? t.Exception, this);
}, TaskScheduler.FromCurrentSynchronizationContext());
}
}
}