地图系统

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

@@ -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: