地图系统

This commit is contained in:
2026-06-05 18:41:33 +08:00
parent 613f2a4d13
commit fe4fd60083
234 changed files with 33090 additions and 4899 deletions

View File

@@ -21,9 +21,11 @@
"BaseGames.Spells",
"BaseGames.Quest",
"BaseGames.Skills",
"BaseGames.Inventory",
"Unity.Addressables",
"Unity.ResourceManager",
"BaseGames.Feedback"
"BaseGames.Feedback",
"BaseGames.World.Map"
],
"autoReferenced": true,
"overrideReferences": false,

View File

@@ -54,8 +54,10 @@ namespace BaseGames.UI.HUD
private void OnEnable()
{
_onHPChanged?.Subscribe(UpdateHP).AddTo(_subs);
// MaxHP 必须先于 HP 订阅:粘性回放时先 RebuildHPCells 建满格子,再 UpdateHP 设激活数,
// 否则受伤档CurrentHP<MaxHP回放顺序颠倒会导致 UpdateHP 作用于空列表后被全量重建覆盖。
_onMaxHPChanged?.Subscribe(RebuildHPCells).AddTo(_subs);
_onHPChanged?.Subscribe(UpdateHP).AddTo(_subs);
_onSoulPowerChanged?.Subscribe(UpdateSoul).AddTo(_subs);
_onSpiritPowerChanged?.Subscribe(UpdateSpirit).AddTo(_subs);
_onLingZhuChanged?.Subscribe(UpdateLingZhu).AddTo(_subs);

View File

@@ -26,8 +26,9 @@ namespace BaseGames.UI
private void OnDeviceChanged(InputDeviceType _)
{
// 通过静态注册表刷新O(n) 遍历已启用实例,无需全场景搜索
InputIconImage.RefreshAll();
// InputIconImage 已通过订阅 IInputIconService.OnIconSetChanged 自主刷新,
// 无需重复调用 RefreshAll()——否则每次设备切换每个 InputIconImage 会执行两次 Refresh。
// 此处保留供将来添加设备切换时的其他 UI 响应(提示动画、音效反馈等)。
}
}
@@ -93,6 +94,14 @@ namespace BaseGames.UI
{
if (_image == null) return;
// 若组件在 IInputIconService 注册前 Enable此处补重试并补订阅
if (_iconService == null)
{
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
if (_iconService != null)
_iconService.OnIconSetChanged += Refresh;
}
Sprite sprite = null;
if (_mode == LookupMode.ByActionName && !string.IsNullOrEmpty(_actionName))

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 597c802194af48d4fa1045a16e2657e5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,191 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.UI.Inventory
{
/// <summary>
/// 统一背包菜单容器(暂停菜单的 Tab Hub
///
/// 单一职责:管理一组 TabMap / Inventory / Tools / Journal / Quests / Options
/// 处理 L/R 肩键循环、Tab 头部高亮、进出动画、并将焦点委托给当前 Tab。
///
/// 解耦设计Hub 不引用任何具体 Tab 类型,只持有 Tab 根 <see cref="GameObject"/>
/// (与 <see cref="UIManager"/> 的 PanelRegistration 同理。Tab 内容若实现
/// <see cref="IFocusable"/>,激活时自动接管焦点。
///
/// 生命周期:由 <see cref="UIManager"/> 面板栈管理PanelId.Inventory
/// OnEnable 时打开上次停留的 Tab并播放滑入动画。
/// </summary>
public class InventoryHubPanel : MonoBehaviour, IFocusable
{
// ── Tab 注册 ──────────────────────────────────────────────────────────
[Serializable]
public struct TabEntry
{
[Tooltip("Tab 内容根节点(激活时 SetActive(true))。")]
public GameObject content;
[Tooltip("Tab 头部按钮(点击直接跳转;高亮显示当前选中)。可空。")]
public Button headerButton;
[Tooltip("Tab 头部高亮节点(选中时 SetActive(true))。可空。")]
public GameObject headerHighlight;
}
[Header("Tabs按显示顺序")]
[SerializeField] private TabEntry[] _tabs;
[Tooltip("默认打开的 Tab 索引。")]
[SerializeField] private int _defaultTabIndex = 0;
[Tooltip("关闭后是否记住上次停留的 Tabfalse 则每次打开回到默认 Tab。")]
[SerializeField] private bool _rememberLastTab = true;
[Header("进场动画")]
[SerializeField] private CanvasGroup _rootGroup;
[SerializeField] private RectTransform _slideTarget;
[Tooltip("滑入起始偏移(像素,向下)。")]
[SerializeField] private float _entrySlideOffset = 60f;
[SerializeField] private float _entryDuration = 0.3f;
[Header("Event Channels - Listen")]
[Tooltip("L/R 肩键:下一 Tab。对应 EVT_InventoryTabNext。")]
[SerializeField] private VoidEventChannelSO _onTabNext;
[Tooltip("L/R 肩键:上一 Tab。对应 EVT_InventoryTabPrev。")]
[SerializeField] private VoidEventChannelSO _onTabPrev;
[Header("Event Channels - Raise")]
[Tooltip("当前 Tab 变化时广播索引。对应 EVT_InventoryTabChanged。")]
[SerializeField] private IntEventChannelSO _onTabChanged;
private int _currentIndex = -1;
private static int _persistedIndex = -1; // 跨开关记忆(静态:面板重建后仍保留)
private Coroutine _entryAnim;
private readonly CompositeDisposable _subs = new();
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (_tabs != null)
for (int i = 0; i < _tabs.Length; i++)
{
int captured = i;
if (_tabs[i].headerButton != null)
{
_tabs[i].headerButton.onClick.RemoveAllListeners();
_tabs[i].headerButton.onClick.AddListener(() => SelectTab(captured));
}
if (_tabs[i].content != null) _tabs[i].content.SetActive(false);
if (_tabs[i].headerHighlight != null) _tabs[i].headerHighlight.SetActive(false);
}
}
private void OnEnable()
{
_onTabNext?.Subscribe(NextTab).AddTo(_subs);
_onTabPrev?.Subscribe(PrevTab).AddTo(_subs);
int start = _rememberLastTab && _persistedIndex >= 0 ? _persistedIndex : _defaultTabIndex;
_currentIndex = -1; // 强制 SelectTab 执行切换逻辑
SelectTab(start, raise: true, animateEntry: true);
}
private void OnDisable()
{
_subs.Clear();
if (_entryAnim != null) { StopCoroutine(_entryAnim); _entryAnim = null; }
}
// ── Tab 切换 API ──────────────────────────────────────────────────────
public void NextTab() => StepTab(+1);
public void PrevTab() => StepTab(-1);
private void StepTab(int dir)
{
if (_tabs == null || _tabs.Length == 0) return;
int next = _currentIndex;
// 跳过未配置 content 的空槽,最多绕一圈
for (int i = 0; i < _tabs.Length; i++)
{
next = (next + dir + _tabs.Length) % _tabs.Length;
if (_tabs[next].content != null) break;
}
SelectTab(next);
}
/// <summary>切换到指定 Tab。无效索引或与当前相同则忽略。</summary>
public void SelectTab(int index) => SelectTab(index, raise: true, animateEntry: false);
private void SelectTab(int index, bool raise, bool animateEntry)
{
if (_tabs == null || _tabs.Length == 0) return;
index = Mathf.Clamp(index, 0, _tabs.Length - 1);
if (index == _currentIndex) return;
// 关闭旧 Tab
if (_currentIndex >= 0 && _currentIndex < _tabs.Length)
{
if (_tabs[_currentIndex].content != null) _tabs[_currentIndex].content.SetActive(false);
if (_tabs[_currentIndex].headerHighlight != null) _tabs[_currentIndex].headerHighlight.SetActive(false);
}
_currentIndex = index;
_persistedIndex = index;
// 打开新 Tab
var tab = _tabs[index];
if (tab.content != null) tab.content.SetActive(true);
if (tab.headerHighlight != null) tab.headerHighlight.SetActive(true);
// 焦点委托Tab 内容若实现 IFocusable 则接管,否则聚焦其 Tab 头按钮
var focusable = tab.content != null ? tab.content.GetComponent<IFocusable>() : null;
if (focusable != null) focusable.OnFocusRestored();
else if (tab.headerButton != null)
EventSystem.current?.SetSelectedGameObject(tab.headerButton.gameObject);
if (raise) _onTabChanged?.Raise(index);
if (animateEntry) PlayEntryAnim();
}
// ── 进场动画 ──────────────────────────────────────────────────────────
private void PlayEntryAnim()
{
if (_entryAnim != null) StopCoroutine(_entryAnim);
_entryAnim = StartCoroutine(EntryRoutine());
}
private System.Collections.IEnumerator EntryRoutine()
{
Vector2 endPos = _slideTarget != null ? _slideTarget.anchoredPosition : Vector2.zero;
Vector2 startPos = endPos - new Vector2(0f, _entrySlideOffset);
float elapsed = 0f;
while (elapsed < _entryDuration)
{
float t = Mathf.SmoothStep(0f, 1f, elapsed / _entryDuration);
if (_rootGroup != null) _rootGroup.alpha = t;
if (_slideTarget != null) _slideTarget.anchoredPosition = Vector2.Lerp(startPos, endPos, t);
elapsed += Time.unscaledDeltaTime;
yield return null;
}
if (_rootGroup != null) _rootGroup.alpha = 1f;
if (_slideTarget != null) _slideTarget.anchoredPosition = endPos;
_entryAnim = null;
}
// ── IFocusable ────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶(关闭子面板后)时,将焦点交还当前 Tab。</summary>
public void OnFocusRestored()
{
if (_currentIndex < 0 || _currentIndex >= (_tabs?.Length ?? 0)) return;
var tab = _tabs[_currentIndex];
var focusable = tab.content != null ? tab.content.GetComponent<IFocusable>() : null;
if (focusable != null) focusable.OnFocusRestored();
else if (tab.headerButton != null)
EventSystem.current?.SetSelectedGameObject(tab.headerButton.gameObject);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0f4869d5a670b824b99b25699f7c888c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,166 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Inventory;
using BaseGames.Localization;
namespace BaseGames.UI.Inventory
{
/// <summary>
/// 背包道具 TabInventory 分栏)。
/// 左侧网格列出持有道具(按分类分组排序),右侧详情面板显示选中道具的名称/描述/数量。
///
/// 数据来源ServiceLocator 获取 <see cref="IInventoryService"/>。
/// 反应式更新:订阅 <see cref="IInventoryService.OnInventoryChanged"/> 重建格子。
/// 设计对照 <see cref="BaseGames.UI.CharmEquipPanel"/>(对象池 + 重建模式)。
/// </summary>
public class ItemInventoryPanel : MonoBehaviour, IFocusable
{
[Header("道具网格")]
[SerializeField] private Transform _gridContainer;
[SerializeField] private ItemSlotView _slotTemplate; // kept inactive作为对象池原型
[Header("详情面板")]
[SerializeField] private Image _detailIcon;
[SerializeField] private TMP_Text _detailNameText;
[SerializeField] private TMP_Text _detailDescText;
[SerializeField] private TMP_Text _detailCountText;
[Tooltip("空背包提示节点(无道具时显示)。")]
[SerializeField] private GameObject _emptyHint;
[Header("Event Channels")]
[Tooltip("背包内容变化(无负载)。与 InventoryManager._onInventoryChanged 共享同一 SO。")]
[SerializeField] private VoidEventChannelSO _onInventoryChanged;
private IInventoryService _service;
private readonly List<ItemSlotView> _activeSlots = new();
private readonly Queue<ItemSlotView> _pool = new();
private ItemSlotView _selected;
private readonly CompositeDisposable _subs = new();
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (_slotTemplate != null) _slotTemplate.gameObject.SetActive(false);
}
private void OnEnable()
{
_service = ServiceLocator.GetOrDefault<IInventoryService>();
// 双通道C# 事件(直接)+ SO 频道编辑器可视任一触发即重建Rebuild 幂等)
if (_service != null) _service.OnInventoryChanged += Rebuild;
_onInventoryChanged?.Subscribe(Rebuild).AddTo(_subs);
Rebuild();
}
private void OnDisable()
{
if (_service != null) _service.OnInventoryChanged -= Rebuild;
_subs.Clear();
RecycleAll();
}
// ── 重建 ──────────────────────────────────────────────────────────────
private void Rebuild()
{
RecycleAll();
if (_service == null || _gridContainer == null || _slotTemplate == null) return;
var items = _service.Items;
if (_emptyHint != null) _emptyHint.SetActive(items.Count == 0);
foreach (var entry in items)
{
if (entry.Item == null) continue;
var slot = Spawn();
slot.Bind(entry, OnSlotSelected);
_activeSlots.Add(slot);
}
// 默认选中第一格,刷新详情
if (_activeSlots.Count > 0) SelectSlot(_activeSlots[0]);
else ClearDetail();
}
private ItemSlotView Spawn()
{
ItemSlotView slot = _pool.Count > 0 ? _pool.Dequeue() : Instantiate(_slotTemplate, _gridContainer);
slot.transform.SetParent(_gridContainer, false);
slot.gameObject.SetActive(true);
return slot;
}
private void RecycleAll()
{
foreach (var slot in _activeSlots)
{
if (slot == null) continue;
slot.SetSelected(false);
slot.gameObject.SetActive(false);
_pool.Enqueue(slot);
}
_activeSlots.Clear();
_selected = null;
}
// ── 选中 & 详情 ───────────────────────────────────────────────────────
private void OnSlotSelected(ItemSO item)
{
foreach (var slot in _activeSlots)
if (slot != null && slot.Item == item) { SelectSlot(slot); break; }
}
private void SelectSlot(ItemSlotView slot)
{
if (slot == null) return;
if (_selected != null) _selected.SetSelected(false);
_selected = slot;
_selected.SetSelected(true);
var item = slot.Item;
_service?.MarkSeen(item.itemId); // 查看后清除未读角标
ShowDetail(slot);
}
private void ShowDetail(ItemSlotView slot)
{
var item = slot.Item;
if (_detailIcon != null)
{
_detailIcon.sprite = item.icon;
_detailIcon.enabled = item.icon != null;
}
if (_detailNameText != null) _detailNameText.text = slot.ResolveName();
if (_detailDescText != null)
{
string loc = LocalizationManager.Get(item.descriptionKey, LocalizationTable.Items);
_detailDescText.text = !string.IsNullOrEmpty(loc) && loc != item.descriptionKey ? loc : string.Empty;
}
if (_detailCountText != null)
{
int count = _service?.GetCount(item.itemId) ?? 0;
_detailCountText.text = item.stackable ? $"x{count}" : string.Empty;
_detailCountText.enabled = item.stackable;
}
}
private void ClearDetail()
{
if (_detailIcon != null) _detailIcon.enabled = false;
if (_detailNameText != null) _detailNameText.text = string.Empty;
if (_detailDescText != null) _detailDescText.text = string.Empty;
if (_detailCountText != null) _detailCountText.text = string.Empty;
}
// ── IFocusable ────────────────────────────────────────────────────────
public void OnFocusRestored()
{
if (_selected != null && _selected.SelectButton != null)
EventSystem.current?.SetSelectedGameObject(_selected.SelectButton.gameObject);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 615e139f9c5c2c24bb3408c59ecea370
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,77 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Inventory;
using BaseGames.Localization;
namespace BaseGames.UI.Inventory
{
/// <summary>
/// 背包道具格子视图(对照 <see cref="BaseGames.UI.CharmCardView"/> 的显式序列化绑定风格)。
/// 由 <see cref="ItemInventoryPanel"/> 通过对象池实例化并调用 <see cref="Bind"/>。
/// </summary>
[DisallowMultipleComponent]
public class ItemSlotView : MonoBehaviour
{
[Header("Visual")]
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _countText;
[Tooltip("\"新获得\"角标节点IsNew 时显示)。")]
[SerializeField] private GameObject _newBadge;
[Header("Interaction")]
[SerializeField] private Button _selectButton;
[Tooltip("选中高亮节点(可空)。")]
[SerializeField] private GameObject _selectedHighlight;
private ItemSO _item;
public ItemSO Item => _item;
public Button SelectButton => _selectButton;
/// <summary>绑定道具条目。点击时回调 onSelect用于右侧详情面板。</summary>
public void Bind(InventoryEntry entry, Action<ItemSO> onSelect)
{
_item = entry.Item;
if (_item == null) return;
if (_icon != null)
{
_icon.sprite = _item.icon;
_icon.enabled = _item.icon != null;
}
if (_countText != null)
{
// 仅可叠加且数量 > 1 时显示数量角标
bool show = _item.stackable && entry.Count > 1;
_countText.text = show ? entry.Count.ToString() : string.Empty;
_countText.enabled = show;
}
if (_newBadge != null) _newBadge.SetActive(entry.IsNew);
if (_selectButton != null)
{
_selectButton.onClick.RemoveAllListeners();
var captured = _item;
if (onSelect != null)
_selectButton.onClick.AddListener(() => onSelect(captured));
}
}
public void SetSelected(bool selected)
{
if (_selectedHighlight != null) _selectedHighlight.SetActive(selected);
}
/// <summary>本地化道具名(容错:未找到键回落 itemId。</summary>
public string ResolveName()
{
if (_item == null) return string.Empty;
string loc = LocalizationManager.Get(_item.displayNameKey, LocalizationTable.Items);
return !string.IsNullOrEmpty(loc) && loc != _item.displayNameKey ? loc : _item.itemId;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 78e4f6fe3bbc2c94c8b7f9884f1a1dc5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,203 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Localization;
using BaseGames.Quest;
using QuestStateEnum = BaseGames.Core.Events.QuestState;
namespace BaseGames.UI.Inventory
{
/// <summary>
/// 全屏任务日志 TabJournal/Quests 分栏)。
/// 左侧列出进行中 / 已完成任务,右侧详情显示选中任务的名称、描述与目标进度。
///
/// 数据来源ServiceLocator 获取 <see cref="IQuestManager"/>,读取 GetQuestsInState 快照。
/// 与 HUD 的 <see cref="BaseGames.UI.HUD.QuestTrackerWidget"/> 不同:后者只显示当前追踪任务,
/// 本面板提供完整列表浏览。本地化统一使用 Quest 表。
/// </summary>
public class QuestLogPanel : MonoBehaviour, IFocusable
{
[Header("任务列表")]
[SerializeField] private Transform _listContainer;
[Tooltip("任务行模板(含 Button + TMP_Textkept inactive。")]
[SerializeField] private Button _rowTemplate;
[Header("详情")]
[SerializeField] private TMP_Text _detailTitle;
[SerializeField] private TMP_Text _detailDesc;
[SerializeField] private Transform _objectiveContainer;
[Tooltip("目标行模板TMP_Textkept inactive。")]
[SerializeField] private TMP_Text _objectiveRowTemplate;
[Tooltip("无进行中任务时的提示节点。")]
[SerializeField] private GameObject _emptyHint;
[Header("Event Channels - 刷新触发(任一即重建)")]
[SerializeField] private QuestStateChangedEventChannel _onQuestStateChanged;
private IQuestManager _quests;
private readonly List<string> _activeIds = new();
private readonly List<Button> _rows = new();
private readonly Queue<Button> _rowPool = new();
private readonly List<TMP_Text> _objRows = new();
private readonly Queue<TMP_Text> _objPool = new();
private string _selectedQuestId;
private readonly CompositeDisposable _subs = new();
private void Awake()
{
if (_rowTemplate != null) _rowTemplate.gameObject.SetActive(false);
if (_objectiveRowTemplate != null) _objectiveRowTemplate.gameObject.SetActive(false);
}
private void OnEnable()
{
_quests = ServiceLocator.GetOrDefault<IQuestManager>();
_onQuestStateChanged?.Subscribe(OnStateChanged).AddTo(_subs);
Rebuild();
}
private void OnDisable()
{
_subs.Clear();
RecycleRows();
RecycleObjectives();
}
private void OnStateChanged(QuestStateChangedEvent _) => Rebuild();
// ── 列表重建 ──────────────────────────────────────────────────────────
private void Rebuild()
{
RecycleRows();
if (_quests == null || _listContainer == null || _rowTemplate == null) return;
_activeIds.Clear();
CollectInto(QuestStateEnum.Active);
CollectInto(QuestStateEnum.Completed);
if (_emptyHint != null) _emptyHint.SetActive(_activeIds.Count == 0);
foreach (var id in _activeIds)
{
var row = SpawnRow();
BindRow(row, id);
_rows.Add(row);
}
if (_activeIds.Count > 0) SelectQuest(_activeIds[0]);
else ClearDetail();
}
private void CollectInto(QuestStateEnum state)
{
var snapshot = _quests.GetQuestsInState(state);
for (int i = 0; i < snapshot.Count; i++)
if (!_activeIds.Contains(snapshot[i])) _activeIds.Add(snapshot[i]);
}
private Button SpawnRow()
{
Button row = _rowPool.Count > 0 ? _rowPool.Dequeue() : Instantiate(_rowTemplate, _listContainer);
row.transform.SetParent(_listContainer, false);
row.gameObject.SetActive(true);
return row;
}
private void BindRow(Button row, string questId)
{
var label = row.GetComponentInChildren<TMP_Text>(includeInactive: true);
if (label != null) label.text = ResolveQuestTitle(questId);
row.onClick.RemoveAllListeners();
string captured = questId;
row.onClick.AddListener(() => SelectQuest(captured));
}
private void RecycleRows()
{
foreach (var row in _rows)
{
if (row == null) continue;
row.gameObject.SetActive(false);
_rowPool.Enqueue(row);
}
_rows.Clear();
}
// ── 详情 ──────────────────────────────────────────────────────────────
private void SelectQuest(string questId)
{
_selectedQuestId = questId;
if (_quests == null || !_quests.TryGetQuest(questId, out var quest) || quest == null)
{
ClearDetail();
return;
}
if (_detailTitle != null) _detailTitle.text = ResolveQuestTitle(questId);
if (_detailDesc != null)
{
string loc = LocalizationManager.Get(quest.descriptionKey, LocalizationTable.Quest);
_detailDesc.text = !string.IsNullOrEmpty(loc) && loc != quest.descriptionKey ? loc : string.Empty;
}
RebuildObjectives(quest);
}
private void RebuildObjectives(QuestSO quest)
{
RecycleObjectives();
if (_objectiveContainer == null || _objectiveRowTemplate == null || quest.objectives == null) return;
foreach (var obj in quest.objectives)
{
if (obj == null) continue;
var row = _objPool.Count > 0 ? _objPool.Dequeue() : Instantiate(_objectiveRowTemplate, _objectiveContainer);
row.transform.SetParent(_objectiveContainer, false);
row.gameObject.SetActive(true);
string loc = LocalizationManager.Get(obj.displayTextKey, LocalizationTable.Quest);
row.text = !string.IsNullOrEmpty(loc) && loc != obj.displayTextKey ? loc : obj.displayTextKey;
_objRows.Add(row);
}
}
private void RecycleObjectives()
{
foreach (var row in _objRows)
{
if (row == null) continue;
row.gameObject.SetActive(false);
_objPool.Enqueue(row);
}
_objRows.Clear();
}
private void ClearDetail()
{
if (_detailTitle != null) _detailTitle.text = string.Empty;
if (_detailDesc != null) _detailDesc.text = string.Empty;
RecycleObjectives();
}
private string ResolveQuestTitle(string questId)
{
if (_quests != null && _quests.TryGetQuest(questId, out var quest) && quest != null
&& !string.IsNullOrEmpty(quest.displayNameKey))
{
string loc = LocalizationManager.Get(quest.displayNameKey, LocalizationTable.Quest);
return !string.IsNullOrEmpty(loc) && loc != quest.displayNameKey ? loc : questId;
}
return questId;
}
// ── IFocusable ────────────────────────────────────────────────────────
public void OnFocusRestored()
{
if (_rows.Count > 0 && _rows[0] != null)
EventSystem.current?.SetSelectedGameObject(_rows[0].gameObject);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3253ddb276762c441aada7fe6313ad72
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -32,6 +32,8 @@ namespace BaseGames.UI.MainMenu
[SerializeField] private CanvasGroup _mainButtonsGroup;
[SerializeField] private RectTransform _mainButtonsRect; // 用于滑入动画
[SerializeField] private GameObject _saveSlotPanel;
[Tooltip("存档槽面板控制器。打开前调用 SetMode 区分新游戏 / 继续语境。")]
[SerializeField] private BaseGames.UI.Menus.SaveSlotController _saveSlotController;
[SerializeField] private GameObject _settingsPanel;
[SerializeField] private GameObject _creditsPanel;
@@ -157,8 +159,16 @@ namespace BaseGames.UI.MainMenu
// ── 按钮回调 ─────────────────────────────────────────────────────────
private void OnNewGameClicked() => SetPanel(_saveSlotPanel, true);
private void OnContinueClicked() => SetPanel(_saveSlotPanel, true);
private void OnNewGameClicked()
{
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.NewGame);
SetPanel(_saveSlotPanel, true);
}
private void OnContinueClicked()
{
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.Continue);
SetPanel(_saveSlotPanel, true);
}
private void OnSettingsClicked() => SetPanel(_settingsPanel, true);
private void OnCreditsClicked() => SetPanel(_creditsPanel, true);
@@ -168,9 +178,16 @@ namespace BaseGames.UI.MainMenu
{
SetPanel(_saveSlotPanel, false);
// 继续游戏:存档已记录检查点场景时加载该场景并落在存档点出生位;
// 否则(新游戏 / 存档尚无检查点)加载首关。
var svc = ServiceLocator.GetOrDefault<ISaveService>();
string checkpointScene = svc?.LastCheckpointScene;
bool hasCheckpoint = !string.IsNullOrEmpty(checkpointScene);
_onSceneLoadRequest?.Raise(new SceneLoadRequest
{
SceneName = _firstGameSceneKey,
SceneName = hasCheckpoint ? checkpointScene : _firstGameSceneKey,
EntryTransitionId = hasCheckpoint ? svc.LastCheckpointSpawnId : null,
TransitionType = TransitionType.Scene,
ShowLoadingScreen = true,
});

View File

@@ -0,0 +1,107 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core.Events;
using BaseGames.Localization;
namespace BaseGames.UI.MainMenu
{
/// <summary>
/// 新游戏模式选择面板(普通 / 钢铁之魂):开新档前选择难度模式。
///
/// 设计:
/// · 自包含、场景无关——本地 SetActive 显隐 + 回调,不走 UIManager 面板栈,
/// 与 MainMenuController 现有的子面板管理方式一致。
/// · 选定后通过 onModeChosen 回调把 DifficultyLevel 交还给调用方SaveSlotController
/// 由调用方负责 CreateSlot(slot, steelSoul) + IDifficultyService.BeginNewGame(level)。
/// · 钢铁之魂为破坏性/高难选项,默认焦点置于普通,并显示一段警示文案。
/// </summary>
public class NewGameModeController : MonoBehaviour
{
[Header("根节点(显隐用,留空则用本 GameObject")]
[SerializeField] private GameObject _root;
[Header("按钮")]
[SerializeField] private Button _btnNormal;
[SerializeField] private Button _btnSteelSoul;
[SerializeField] private Button _btnBack;
[Header("钢铁之魂说明")]
[Tooltip("选中钢铁之魂时显示的警示文案(一命模式,死亡即清档)。走本地化键 MODE_STEELSOUL_DESC。")]
[SerializeField] private TMP_Text _steelSoulDescText;
[SerializeField] private string _steelSoulDescKey = "MODE_STEELSOUL_DESC";
private Action<DifficultyLevel> _onModeChosen;
private Action _onBack;
private void Awake()
{
_btnNormal? .onClick.AddListener(() => Choose(DifficultyLevel.Normal));
_btnSteelSoul?.onClick.AddListener(() => Choose(DifficultyLevel.SteelSoul));
_btnBack? .onClick.AddListener(HandleBack);
SetVisible(false);
}
/// <summary>
/// 弹出模式选择。
/// </summary>
/// <param name="onModeChosen">玩家选定模式后回调(面板已自动关闭),携带难度档位。</param>
/// <param name="onBack">点击返回 / 取消后回调(可选)。</param>
public void Show(Action<DifficultyLevel> onModeChosen, Action onBack = null)
{
_onModeChosen = onModeChosen;
_onBack = onBack;
if (_steelSoulDescText != null && !string.IsNullOrEmpty(_steelSoulDescKey))
{
string s = LocalizationManager.Get(_steelSoulDescKey, LocalizationTable.UI);
_steelSoulDescText.text = string.IsNullOrEmpty(s) ? _steelSoulDescKey : s;
}
SetVisible(true);
// 默认焦点置于普通模式(避免误选一命模式)
EventSystem.current?.SetSelectedGameObject(_btnNormal != null
? _btnNormal.gameObject
: _btnSteelSoul?.gameObject);
}
/// <summary>外部强制关闭,不触发回调。</summary>
public void Close()
{
_onModeChosen = null;
_onBack = null;
SetVisible(false);
}
// ── 回调 ──────────────────────────────────────────────────────────────
private void Choose(DifficultyLevel level)
{
var cb = _onModeChosen;
SetVisible(false);
_onModeChosen = null;
_onBack = null;
cb?.Invoke(level);
}
private void HandleBack()
{
var cb = _onBack;
SetVisible(false);
_onModeChosen = null;
_onBack = null;
cb?.Invoke();
}
// ── 工具 ──────────────────────────────────────────────────────────────
private void SetVisible(bool visible)
{
var go = _root != null ? _root : gameObject;
go.SetActive(visible);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 84dfb5f813928934b9da0994f3c074eb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,121 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Localization;
namespace BaseGames.UI.Menus
{
/// <summary>
/// 通用模态确认对话框(是/否):用于删除 / 覆盖 / 退出等确认场景。
///
/// 设计:
/// · 自包含、场景无关——通过本地 SetActive 显隐 + 回调 API 工作,不依赖 UIManager 面板栈,
/// 因此既能用于主菜单场景(不走 UIManager也能在游戏内复用。
/// · 标题 / 正文 / 按钮文案均走本地化键LocalizationManager.Get传 null 则保留 Inspector 原文。
/// · 默认焦点置于"取消"按钮,防止手柄连按误触确认(破坏性操作安全默认)。
///
/// 用法:
/// _confirmDialog.Show("CONFIRM_DELETE_TITLE", "CONFIRM_DELETE_BODY",
/// onConfirm: () => DoDelete(),
/// onCancel: () => {});
/// </summary>
public class ConfirmDialogController : MonoBehaviour
{
[Header("根节点(显隐用,留空则用本 GameObject")]
[SerializeField] private GameObject _root;
[Header("文本")]
[SerializeField] private TMP_Text _titleText;
[SerializeField] private TMP_Text _bodyText;
[Tooltip("确认按钮标签(可选,传 confirmKey 时覆盖)")]
[SerializeField] private TMP_Text _confirmLabel;
[Tooltip("取消按钮标签(可选,传 cancelKey 时覆盖)")]
[SerializeField] private TMP_Text _cancelLabel;
[Header("按钮")]
[SerializeField] private Button _btnConfirm;
[SerializeField] private Button _btnCancel;
private Action _onConfirm;
private Action _onCancel;
private void Awake()
{
_btnConfirm?.onClick.AddListener(HandleConfirm);
_btnCancel? .onClick.AddListener(HandleCancel);
SetVisible(false);
}
/// <summary>
/// 弹出确认框。
/// </summary>
/// <param name="titleKey">标题本地化键null 保留原文。</param>
/// <param name="bodyKey">正文本地化键null 保留原文。</param>
/// <param name="onConfirm">点击确认后回调(确认框已自动关闭)。</param>
/// <param name="onCancel">点击取消后回调(可选)。</param>
/// <param name="confirmKey">确认按钮文案本地化键(可选)。</param>
/// <param name="cancelKey">取消按钮文案本地化键(可选)。</param>
public void Show(string titleKey, string bodyKey, Action onConfirm, Action onCancel = null,
string confirmKey = null, string cancelKey = null)
{
_onConfirm = onConfirm;
_onCancel = onCancel;
if (_titleText != null && titleKey != null) _titleText.text = Loc(titleKey);
if (_bodyText != null && bodyKey != null) _bodyText.text = Loc(bodyKey);
if (_confirmLabel != null && confirmKey != null) _confirmLabel.text = Loc(confirmKey);
if (_cancelLabel != null && cancelKey != null) _cancelLabel.text = Loc(cancelKey);
SetVisible(true);
// 安全默认:焦点置于取消,避免手柄/键盘连按直接确认破坏性操作
EventSystem.current?.SetSelectedGameObject(_btnCancel != null
? _btnCancel.gameObject
: _btnConfirm?.gameObject);
}
/// <summary>外部强制关闭(如父面板被关闭时)。不触发任何回调。</summary>
public void Close()
{
_onConfirm = null;
_onCancel = null;
SetVisible(false);
}
// ── 按钮回调 ──────────────────────────────────────────────────────────
private void HandleConfirm()
{
var cb = _onConfirm;
SetVisible(false);
_onConfirm = null;
_onCancel = null;
cb?.Invoke();
}
private void HandleCancel()
{
var cb = _onCancel;
SetVisible(false);
_onConfirm = null;
_onCancel = null;
cb?.Invoke();
}
// ── 工具 ──────────────────────────────────────────────────────────────
private void SetVisible(bool visible)
{
var go = _root != null ? _root : gameObject;
go.SetActive(visible);
}
private static string Loc(string key)
{
string s = LocalizationManager.Get(key, LocalizationTable.UI);
return string.IsNullOrEmpty(s) ? key : s;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d33b9d77d704f12438e54e4a74ff00c6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,98 @@
using UnityEngine;
using BaseGames.Core;
using BaseGames.Localization;
using BaseGames.World.Map;
namespace BaseGames.UI.Menus
{
/// <summary>
/// 地图传送确认控制器UI 侧桥接)。
/// <para>
/// 订阅 <see cref="MapPanel.OnTeleportStationSelected"/>:玩家在全屏地图点击一个可传送站点时,
/// 弹出 <see cref="ConfirmDialogController"/> 二次确认,确认后调用
/// <see cref="ITeleportService.RequestTeleport"/> 发起传送,并按需关闭地图面板。
/// </para>
/// <para>
/// 放在 UI 程序集(已引用 BaseGames.World.Map——MapPanel 自身不反向依赖 UI避免循环引用。
/// 这是"补全传送"闭环的最后一环:地图选点 → 确认 → 传送。
/// </para>
/// </summary>
public class MapTeleportConfirmController : MonoBehaviour
{
[Header("引用")]
[Tooltip("全屏地图面板(订阅其 OnTeleportStationSelected。")]
[SerializeField] private MapPanel _mapPanel;
[Tooltip("通用确认框;留空则点击站点后直接传送(无二次确认)。")]
[SerializeField] private ConfirmDialogController _confirmDialog;
[Header("文案(本地化键)")]
[SerializeField] private string _confirmTitleKey = "TELEPORT_CONFIRM_TITLE";
[Tooltip("确认正文前缀本地化键;后面拼接目的地房间显示名。")]
[SerializeField] private string _confirmBodyPrefixKey = "TELEPORT_CONFIRM_BODY";
[SerializeField] private string _confirmYesKey = "CONFIRM_YES";
[SerializeField] private string _confirmNoKey = "CONFIRM_NO";
[Header("行为")]
[Tooltip("确认传送后是否关闭地图面板CloseTopPanel。")]
[SerializeField] private bool _closeMapOnConfirm = true;
private void OnEnable()
{
if (_mapPanel != null)
_mapPanel.OnTeleportStationSelected += OnStationSelected;
}
private void OnDisable()
{
if (_mapPanel != null)
_mapPanel.OnTeleportStationSelected -= OnStationSelected;
}
private void OnStationSelected(string roomId)
{
if (string.IsNullOrEmpty(roomId)) return;
// 无确认框:直接传送
if (_confirmDialog == null)
{
DoTeleport(roomId);
return;
}
string destName = ResolveDestName(roomId);
string prefix = LocalizationManager.Get(_confirmBodyPrefixKey, LocalizationTable.UI);
if (string.IsNullOrEmpty(prefix) || prefix == _confirmBodyPrefixKey)
prefix = "传送到:"; // 本地化键缺失时的兜底前缀
string body = prefix + destName;
// ConfirmDialog 对 body 走 Loc 查找,未命中则原样显示——拼接串可直接呈现;
// 确认/取消按钮文案也走本地化键,随语言切换。
_confirmDialog.Show(_confirmTitleKey, body, onConfirm: () => DoTeleport(roomId),
onCancel: null, confirmKey: _confirmYesKey, cancelKey: _confirmNoKey);
}
private void DoTeleport(string roomId)
{
var teleportSvc = ServiceLocator.GetOrDefault<ITeleportService>();
if (teleportSvc == null)
{
Debug.LogWarning("[MapTeleportConfirmController] ITeleportService 未注册,无法传送。");
return;
}
teleportSvc.RequestTeleport(roomId);
if (_closeMapOnConfirm)
ServiceLocator.GetOrDefault<IUIManager>()?.CloseTopPanel();
}
/// <summary>解析目的地房间的玩家可读名DisplayName 走本地化;无则回退 RoomId。</summary>
private static string ResolveDestName(string roomId)
{
var room = ServiceLocator.GetOrDefault<IMapService>()?.Database?.GetRoom(roomId);
if (room == null || string.IsNullOrEmpty(room.DisplayName)) return roomId;
// DisplayName 为本地化 Key 时解析为译文;为普通名称时原样返回(向后兼容)。
return LocalizationManager.Get(room.DisplayName, LocalizationTable.UI);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5f5d16c5b692621459b02ab74a1aaf91
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -9,19 +9,49 @@ using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Localization;
using BaseGames.World.Map;
using BaseGames.UI.MainMenu;
namespace BaseGames.UI.Menus
{
/// <summary>主菜单存档槽面板的打开语境。</summary>
public enum SaveSlotPanelMode
{
/// <summary>继续游戏:仅有档槽可选,点击即读档进入。</summary>
Continue,
/// <summary>新游戏:空槽 → 模式选择 → 建档;有档槽 → 覆盖确认 → 模式选择 → 建档。</summary>
NewGame,
}
/// <summary>
/// 驱动主菜单存档槽选择面板(新游戏 / 继续 / 删除)。
///
/// 前端选档流程:
/// · 新游戏开档前先选模式(普通 / 钢铁之魂),占用槽位需覆盖确认。
/// · 删除强制走通用确认对话框(无静默删除旁路)。
/// · 卡片展示游玩时长 / 区域 / 货币 / 生命 / 钢魂徽章。
///
/// 模式由 MainMenuController 在打开面板前通过 <see cref="SetMode"/> 指定。
/// ConfirmDialog 与 NewGameMode 面板经 Inspector 注入(同处 MainMenu 场景)。
/// </summary>
public class SaveSlotController : MonoBehaviour
public class SaveSlotController : MonoBehaviour, IFocusable
{
[SerializeField] private SaveSlotUI[] _slotUIs;
[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()
@@ -30,6 +60,19 @@ namespace BaseGames.UI.Menus
if (_slotUIs[i] != null) _slotUIs[i].Init(i, this);
}
/// <summary>由 MainMenuController 在 SetActive(true) 之前调用,决定本次打开语境。</summary>
public void SetMode(SaveSlotPanelMode mode) => _mode = mode;
// ── IFocusable ────────────────────────────────────────────────────────
public void OnFocusRestored() => StartCoroutine(RestoreFocusNextFrame());
private System.Collections.IEnumerator RestoreFocusNextFrame()
{
yield return null;
if (UnityEngine.EventSystems.EventSystem.current != null && _defaultFocusButton != null)
UnityEngine.EventSystems.EventSystem.current.SetSelectedGameObject(_defaultFocusButton.gameObject);
}
private void OnEnable()
{
_cts = new CancellationTokenSource();
@@ -44,6 +87,10 @@ namespace BaseGames.UI.Menus
private void OnDisable()
{
// 关闭子对话框,避免下次打开残留
_confirmDialog?.Close();
_modeSelect?.Close();
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
@@ -64,35 +111,96 @@ namespace BaseGames.UI.Menus
for (int i = 0; i < _slotUIs.Length; i++)
{
if (_slotUIs[i] == null) continue;
_slotUIs[i].Refresh(summaries[i]);
_slotUIs[i].Refresh(summaries[i], _mode);
}
}
/// <summary>选中指定槽位(新局或继续)。由 SaveSlotUI 内部按钮调用。</summary>
// ── 选槽 ────────────────────────────────────────────────────────────────
/// <summary>选中指定槽位。行为取决于当前模式与槽位是否有档。由 SaveSlotUI 内部按钮调用。</summary>
public void OnSlotSelected(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return;
_ = SelectSlotAsync(slotIndex);
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
bool hasData = svc.HasSave(slotIndex);
if (_mode == SaveSlotPanelMode.Continue)
{
if (!hasData) return; // 继续模式:空槽不可选
_ = ContinueSlotAsync(slotIndex);
return;
}
// ── 新游戏模式 ──
if (hasData)
{
// 占用槽位:先覆盖确认
if (_confirmDialog != null)
_confirmDialog.Show("CONFIRM_OVERWRITE_TITLE", "CONFIRM_OVERWRITE_BODY",
onConfirm: () => BeginNewGameFlow(slotIndex));
else
BeginNewGameFlow(slotIndex); // 无对话框时退化为直接建档
}
else
{
BeginNewGameFlow(slotIndex);
}
}
private async Task SelectSlotAsync(int slotIndex)
/// <summary>开新档流程:选模式 → 建档。</summary>
private void BeginNewGameFlow(int slotIndex)
{
if (_modeSelect != null)
_modeSelect.Show(level => _ = StartNewGameAsync(slotIndex, level));
else
_ = StartNewGameAsync(slotIndex, DifficultyLevel.Normal); // 无模式面板时退化为普通
}
private async Task StartNewGameAsync(int slotIndex, DifficultyLevel level)
{
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
// 覆盖既有档:先删盘,确保旧数据不残留(建档仅初始化内存,写盘发生在首次存档)
if (svc.HasSave(slotIndex))
await svc.LoadAsync(slotIndex);
else
svc.CreateSlot(slotIndex);
await svc.DeleteSlotAsync(slotIndex);
bool steel = level == DifficultyLevel.SteelSoul;
svc.CreateSlot(slotIndex, steel);
ServiceLocator.GetOrDefault<IDifficultyService>()?.BeginNewGame(level);
_onSlotConfirmed?.Raise(slotIndex);
}
/// <summary>删除指定槽位存档并刷新 UI。由 SaveSlotUI 内部按钮调用。</summary>
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;
_ = DeleteAndRefreshAsync(slotIndex);
if (_confirmDialog == null)
{
Debug.LogWarning("[SaveSlotController] 未配置 ConfirmDialog删除请求被忽略防止静默删除。");
return;
}
_confirmDialog.Show("CONFIRM_DELETE_TITLE", "CONFIRM_DELETE_BODY",
onConfirm: () => _ = DeleteAndRefreshAsync(slotIndex));
}
private async Task DeleteAndRefreshAsync(int slotIndex)
@@ -103,143 +211,4 @@ namespace BaseGames.UI.Menus
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;
}
}
}

View File

@@ -0,0 +1,143 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Save;
using BaseGames.Localization;
using BaseGames.World.Map;
namespace BaseGames.UI.Menus
{
/// <summary>
/// 单个存档槽卡片组件,负责显示存档摘要或空槽提示。
///
/// ⚠ 必须独立成文件Unity 仅为与文件名同名的类生成 MonoScript 资产,
/// 若与 SaveSlotController 合并在同一文件AddComponent 后脚本引用会丢失Missing Script
/// </summary>
public class SaveSlotUI : MonoBehaviour
{
[Header("基本信息")]
[SerializeField] private TMP_Text _playtimeText;
[SerializeField] private TMP_Text _regionText;
[SerializeField] private TMP_Text _lastSavedText;
[SerializeField] private Image _formIcon;
[Header("扩展信息(可选)")]
[Tooltip("货币(灵珠)持有量文本。")]
[SerializeField] private TMP_Text _lingZhuText;
[Tooltip("生命(面具)上限文本。")]
[SerializeField] private TMP_Text _hpText;
[Tooltip("钢铁之魂徽章根节点,仅一命模式存档显示。")]
[SerializeField] private GameObject _steelSoulBadge;
[Header("区域背景图")]
[Tooltip("RegionRegistry.asset用于根据 SceneName 查找区域背景图。")]
[SerializeField] private RegionRegistrySO _regionRegistry;
[Tooltip("存档点不在任何已注册区域时显示的默认背景图。")]
[SerializeField] private Sprite _fallbackBackground;
[Tooltip("显示区域背景图的 Image 组件。")]
[SerializeField] private Image _backgroundImage;
[Header("槽位状态")]
[SerializeField] private GameObject _emptyIndicator; // 空槽时显示的"新游戏"提示
[SerializeField] private GameObject _dataIndicator; // 有数据时显示的内容根
[SerializeField] private Button _selectButton;
[SerializeField] private Button _deleteButton;
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();
// 删除统一走 Controller → 通用确认对话框(不再使用每卡内联确认)
_deleteButton.onClick.AddListener(() => _controller.OnSlotDeleteRequested(_slotIndex));
}
}
/// <summary>用摘要数据刷新显示summary 为 null 表示空槽。</summary>
/// <param name="summary">槽位摘要null 为空槽。</param>
/// <param name="mode">当前面板模式:继续模式下空槽不可选。</param>
public void Refresh(SlotSummary summary, SaveSlotPanelMode mode)
{
bool hasData = summary != null;
if (_emptyIndicator != null) _emptyIndicator.SetActive(!hasData);
if (_dataIndicator != null) _dataIndicator.SetActive(hasData);
if (_deleteButton != null) _deleteButton.gameObject.SetActive(hasData);
// 继续模式下空槽不可选;新游戏模式下空槽可选(建新档)
if (_selectButton != null)
_selectButton.interactable = hasData || mode == SaveSlotPanelMode.NewGame;
if (_steelSoulBadge != null)
_steelSoulBadge.SetActive(hasData && summary.IsSteelSoul);
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);
if (_lingZhuText != null) _lingZhuText.text = summary.CurrentLingZhu.ToString();
if (_hpText != null) _hpText.text = summary.MaxHP.ToString();
RefreshBackground(summary);
}
private void RefreshBackground(SlotSummary summary)
{
if (_backgroundImage == null) return;
Sprite bg = null;
if (summary != null && !string.IsNullOrEmpty(summary.SceneName))
bg = _regionRegistry?.FindBySceneName(summary.SceneName)?.saveSlotBackground;
_backgroundImage.sprite = bg != null ? bg : _fallbackBackground;
_backgroundImage.enabled = _backgroundImage.sprite != null;
}
// ── 格式化工具 ────────────────────────────────────────────────────────
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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5f6853b6f549e314fa93ef199614d96c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -3,6 +3,7 @@ using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
namespace BaseGames.UI
@@ -19,6 +20,7 @@ namespace BaseGames.UI
Shop,
CharmPanel,
SpellSelect,
Inventory,
}
[DefaultExecutionOrder(+50)]
@@ -49,6 +51,10 @@ namespace BaseGames.UI
[SerializeField] private VoidEventChannelSO _onMapOpen;
[SerializeField] private VoidEventChannelSO _onCharmPanelOpen;
[SerializeField] private VoidEventChannelSO _onSpellSelectOpen;
[Tooltip("打开统一背包菜单InventoryHub。对应 EVT_InventoryOpen。")]
[SerializeField] private VoidEventChannelSO _onInventoryOpen;
[Tooltip("UI 取消操作ESC / 手柄 B·Circle全局关闭栈顶面板。对应 EVT_UICancelPressed。")]
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
// ── 面板栈结构 ────────────────────────────────────────────────────────
private readonly Stack<GameObject> _panelStack = new();
@@ -115,6 +121,8 @@ namespace BaseGames.UI
_onMapOpen?.Subscribe(OpenMap).AddTo(_subs);
_onCharmPanelOpen?.Subscribe(OpenCharmPanel).AddTo(_subs);
_onSpellSelectOpen?.Subscribe(OpenSpellSelect).AddTo(_subs);
_onInventoryOpen?.Subscribe(OpenInventory).AddTo(_subs);
_onUICancelPressed?.Subscribe(HandleUICancelPressed).AddTo(_subs);
}
private void OnDisable()
@@ -137,7 +145,7 @@ namespace BaseGames.UI
}
else if (h.Handle.IsValid())
{
Addressables.Release(h.Handle);
AssetLoader.Release(h.Handle);
}
}
_addressableHandles.Clear();
@@ -257,6 +265,12 @@ namespace BaseGames.UI
}
// ── 快捷事件回调 ──────────────────────────────────────────────────────
private void HandleUICancelPressed()
{
if (_panelStack.Count > 0)
CloseTopPanel();
}
private void TogglePause()
{
if (_panelRegistry.TryGetValue(PanelId.Pause, out var pausePanel)
@@ -269,6 +283,7 @@ namespace BaseGames.UI
private void OpenMap() => OpenPanel(PanelId.Map);
private void OpenCharmPanel() => OpenPanel(PanelId.CharmPanel);
private void OpenSpellSelect() => OpenPanel(PanelId.SpellSelect);
private void OpenInventory() => OpenPanel(PanelId.Inventory);
// ── 编辑器工具 ────────────────────────────────────────────────────────
[ContextMenu("验证面板注册表")]