地图系统
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
8
Assets/_Game/Scripts/UI/Inventory.meta
Normal file
8
Assets/_Game/Scripts/UI/Inventory.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 597c802194af48d4fa1045a16e2657e5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
191
Assets/_Game/Scripts/UI/Inventory/InventoryHubPanel.cs
Normal file
191
Assets/_Game/Scripts/UI/Inventory/InventoryHubPanel.cs
Normal 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)。
|
||||
///
|
||||
/// 单一职责:管理一组 Tab(Map / 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("关闭后是否记住上次停留的 Tab;false 则每次打开回到默认 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Inventory/InventoryHubPanel.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Inventory/InventoryHubPanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f4869d5a670b824b99b25699f7c888c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
166
Assets/_Game/Scripts/UI/Inventory/ItemInventoryPanel.cs
Normal file
166
Assets/_Game/Scripts/UI/Inventory/ItemInventoryPanel.cs
Normal 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>
|
||||
/// 背包道具 Tab(Inventory 分栏)。
|
||||
/// 左侧网格列出持有道具(按分类分组排序),右侧详情面板显示选中道具的名称/描述/数量。
|
||||
///
|
||||
/// 数据来源: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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Inventory/ItemInventoryPanel.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Inventory/ItemInventoryPanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 615e139f9c5c2c24bb3408c59ecea370
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
77
Assets/_Game/Scripts/UI/Inventory/ItemSlotView.cs
Normal file
77
Assets/_Game/Scripts/UI/Inventory/ItemSlotView.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Inventory/ItemSlotView.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Inventory/ItemSlotView.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 78e4f6fe3bbc2c94c8b7f9884f1a1dc5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
203
Assets/_Game/Scripts/UI/Inventory/QuestLogPanel.cs
Normal file
203
Assets/_Game/Scripts/UI/Inventory/QuestLogPanel.cs
Normal 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>
|
||||
/// 全屏任务日志 Tab(Journal/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_Text),kept inactive。")]
|
||||
[SerializeField] private Button _rowTemplate;
|
||||
|
||||
[Header("详情")]
|
||||
[SerializeField] private TMP_Text _detailTitle;
|
||||
[SerializeField] private TMP_Text _detailDesc;
|
||||
[SerializeField] private Transform _objectiveContainer;
|
||||
[Tooltip("目标行模板(TMP_Text),kept 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Inventory/QuestLogPanel.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Inventory/QuestLogPanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3253ddb276762c441aada7fe6313ad72
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
107
Assets/_Game/Scripts/UI/MainMenu/NewGameModeController.cs
Normal file
107
Assets/_Game/Scripts/UI/MainMenu/NewGameModeController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 84dfb5f813928934b9da0994f3c074eb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
121
Assets/_Game/Scripts/UI/Menus/ConfirmDialogController.cs
Normal file
121
Assets/_Game/Scripts/UI/Menus/ConfirmDialogController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d33b9d77d704f12438e54e4a74ff00c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f5d16c5b692621459b02ab74a1aaf91
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
143
Assets/_Game/Scripts/UI/Menus/SaveSlotUI.cs
Normal file
143
Assets/_Game/Scripts/UI/Menus/SaveSlotUI.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Menus/SaveSlotUI.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Menus/SaveSlotUI.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f6853b6f549e314fa93ef199614d96c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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("验证面板注册表")]
|
||||
|
||||
Reference in New Issue
Block a user