地图系统
This commit is contained in:
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:
|
||||
Reference in New Issue
Block a user