地图系统

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,19 @@
{
"name": "BaseGames.Inventory",
"rootNamespace": "BaseGames.Inventory",
"references": [
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.Core.Save",
"BaseGames.Localization"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9e81405e158a5a0438e877577a9a2d84
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,54 @@
using System.Collections.Generic;
namespace BaseGames.Inventory
{
/// <summary>
/// 背包管理服务接口。UI 层ItemInventoryPanel通过此接口读取道具状态
/// 与 <see cref="InventoryManager"/> 具体实现解耦,便于独立测试和跨场景复用。
/// 设计对照 <see cref="BaseGames.Equipment.IEquipmentService"/>。
/// </summary>
public interface IInventoryService
{
/// <summary>当前持有的全部道具条目(只读视图,含数量)。</summary>
IReadOnlyList<InventoryEntry> Items { get; }
/// <summary>持有道具种类数(不含数量)。</summary>
int DistinctCount { get; }
/// <summary>查询指定道具的持有数量;未持有返回 0。</summary>
int GetCount(string itemId);
/// <summary>是否持有指定道具(数量 ≥ 1。</summary>
bool Has(string itemId);
/// <summary>
/// 增加道具。stackable 道具按 amount 叠加(受 maxStack 限制);
/// 非 stackable 恒为 1。返回实际新增数量0 表示已满或道具无效)。
/// </summary>
int AddItem(string itemId, int amount = 1);
/// <summary>移除道具(如消耗品使用 / 剧情消耗)。返回实际移除数量。</summary>
int RemoveItem(string itemId, int amount = 1);
/// <summary>清除某道具的"新获得"未读标记(玩家查看背包后调用)。</summary>
void MarkSeen(string itemId);
/// <summary>道具集合变化(增删 / 数量变更时触发UI 据此刷新。</summary>
event System.Action OnInventoryChanged;
}
/// <summary>背包条目:道具数据 + 当前数量 + 未读标记。</summary>
public readonly struct InventoryEntry
{
public readonly ItemSO Item;
public readonly int Count;
public readonly bool IsNew;
public InventoryEntry(ItemSO item, int count, bool isNew)
{
Item = item;
Count = count;
IsNew = isNew;
}
}
}

View File

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

View File

@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
namespace BaseGames.Inventory
{
/// <summary>
/// 运行时背包管理器。
/// 挂在 Persistent 场景 [Services] 下,事件驱动记录玩家持有的道具,
/// 实现 <see cref="ISaveable"/> 持久化。设计对照 <see cref="BaseGames.World.Map.MapManager"/>
/// 与 <see cref="BaseGames.Equipment.EquipmentManager"/> 的服务注册 / 存档模式。
/// </summary>
[DefaultExecutionOrder(-700)]
public class InventoryManager : MonoBehaviour, ISaveable, IInventoryService
{
[SerializeField] private ItemDatabaseSO _database;
[Header("Event Channels - Listen")]
[Tooltip("道具拾取itemId。Collectible 拾取 Item 类型时广播 EVT_ItemPickup。")]
[SerializeField] private StringEventChannelSO _onItemPickup;
[Header("Event Channels - Raise")]
[Tooltip("道具首次获得时广播itemId供 HUD Toast / 地图碎片揭示等订阅。")]
[SerializeField] private StringEventChannelSO _onItemAcquired;
[Tooltip("背包内容变化时广播(无负载),供 UI 轻量刷新。")]
[SerializeField] private VoidEventChannelSO _onInventoryChanged;
[Tooltip("地图碎片道具获得时广播揭示区域regionId。对应 EVT_RevealRegion。")]
[SerializeField] private StringEventChannelSO _onRevealRegion;
private readonly Dictionary<string, int> _counts = new();
private readonly HashSet<string> _newItems = new();
private readonly List<InventoryEntry> _view = new();
private bool _viewDirty = true;
private bool _isDuplicate;
private readonly CompositeDisposable _subs = new();
public event Action OnInventoryChanged;
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (ServiceLocator.GetOrDefault<IInventoryService>() != null) { _isDuplicate = true; Destroy(gameObject); return; }
ServiceLocator.Register<IInventoryService>(this);
Debug.Assert(_database != null, "[InventoryManager] _database 未赋值,请在 Inspector 中指定 ItemDatabaseSO。", this);
}
private void OnEnable()
{
if (_isDuplicate) return;
_onItemPickup?.Subscribe(OnItemPickup).AddTo(_subs);
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
}
private void OnDisable()
{
if (_isDuplicate) return;
_subs.Clear();
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
}
private void OnDestroy()
{
if (_isDuplicate) return;
ServiceLocator.Unregister<IInventoryService>(this);
}
// ── 事件驱动拾取 ──────────────────────────────────────────────────────
private void OnItemPickup(string itemId) => AddItem(itemId, 1);
// ── IInventoryService ─────────────────────────────────────────────────
public IReadOnlyList<InventoryEntry> Items
{
get { RebuildViewIfDirty(); return _view; }
}
public int DistinctCount => _counts.Count;
public int GetCount(string itemId)
=> !string.IsNullOrEmpty(itemId) && _counts.TryGetValue(itemId, out var c) ? c : 0;
public bool Has(string itemId) => GetCount(itemId) > 0;
public int AddItem(string itemId, int amount = 1)
{
if (string.IsNullOrEmpty(itemId) || amount <= 0) return 0;
var item = _database != null ? _database.Find(itemId) : null;
if (item == null) { Debug.LogWarning($"[InventoryManager] 找不到道具: {itemId}", this); return 0; }
bool firstTime = !_counts.ContainsKey(itemId);
int current = firstTime ? 0 : _counts[itemId];
int target;
if (!item.stackable)
{
if (current >= 1) return 0; // 非叠加道具已持有,忽略
target = 1;
}
else
{
target = current + amount;
if (item.maxStack > 0) target = Mathf.Min(target, item.maxStack);
}
int added = target - current;
if (added <= 0) return 0;
_counts[itemId] = target;
_newItems.Add(itemId);
_viewDirty = true;
if (firstTime)
{
_onItemAcquired?.Raise(itemId);
// 地图碎片:揭示对应区域
if (item.category == ItemCategory.MapShard && !string.IsNullOrEmpty(item.revealRegionId))
_onRevealRegion?.Raise(item.revealRegionId);
}
RaiseChanged();
return added;
}
public int RemoveItem(string itemId, int amount = 1)
{
if (string.IsNullOrEmpty(itemId) || amount <= 0) return 0;
if (!_counts.TryGetValue(itemId, out var current) || current <= 0) return 0;
int removed = Mathf.Min(current, amount);
int target = current - removed;
if (target <= 0)
{
_counts.Remove(itemId);
_newItems.Remove(itemId);
}
else
{
_counts[itemId] = target;
}
_viewDirty = true;
RaiseChanged();
return removed;
}
public void MarkSeen(string itemId)
{
if (string.IsNullOrEmpty(itemId)) return;
if (_newItems.Remove(itemId))
{
_viewDirty = true;
RaiseChanged();
}
}
// ── 内部 ──────────────────────────────────────────────────────────────
private void RaiseChanged()
{
_onInventoryChanged?.Raise();
OnInventoryChanged?.Invoke();
}
private void RebuildViewIfDirty()
{
if (!_viewDirty) return;
_view.Clear();
foreach (var kv in _counts)
{
var item = _database != null ? _database.Find(kv.Key) : null;
if (item == null) continue;
_view.Add(new InventoryEntry(item, kv.Value, _newItems.Contains(kv.Key)));
}
// 按分类枚举顺序、再按本地化名/ID 稳定排序,供 UI 分组展示
_view.Sort((a, b) =>
{
int c = a.Item.category.CompareTo(b.Item.category);
return c != 0 ? c : string.CompareOrdinal(a.Item.itemId, b.Item.itemId);
});
_viewDirty = false;
}
// ── ISaveable ─────────────────────────────────────────────────────────
public void OnSave(SaveData data)
{
data.Inventory.ItemCounts.Clear();
foreach (var kv in _counts) data.Inventory.ItemCounts[kv.Key] = kv.Value;
data.Inventory.NewItemIds.Clear();
data.Inventory.NewItemIds.AddRange(_newItems);
}
public void OnLoad(SaveData data)
{
_counts.Clear();
_newItems.Clear();
if (data.Inventory?.ItemCounts != null)
foreach (var kv in data.Inventory.ItemCounts)
if (!string.IsNullOrEmpty(kv.Key) && kv.Value > 0)
_counts[kv.Key] = kv.Value;
if (data.Inventory?.NewItemIds != null)
foreach (var id in data.Inventory.NewItemIds)
if (_counts.ContainsKey(id)) _newItems.Add(id);
_viewDirty = true;
RaiseChanged();
}
}
}

View File

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

View File

@@ -0,0 +1,20 @@
namespace BaseGames.Inventory
{
/// <summary>
/// 道具分类(背包 Tab 内分组与排序依据)。
/// 新增分类时在此追加ItemInventoryPanel 的分组顺序与此枚举声明顺序一致。
/// </summary>
public enum ItemCategory
{
/// <summary>关键剧情道具(推进主线 / 解锁区域,不可丢弃)。</summary>
KeyItem = 0,
/// <summary>钥匙类(开启特定门 / 机关)。</summary>
Key = 1,
/// <summary>可消耗品(药水、临时增益等,拥有数量可叠加)。</summary>
Consumable = 2,
/// <summary>地图碎片(拾取后揭示对应区域地图)。</summary>
MapShard = 3,
/// <summary>收藏 / 杂项(图鉴、纪念物,无直接用途)。</summary>
Collectible = 4,
}
}

View File

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

View File

@@ -0,0 +1,46 @@
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.Inventory
{
/// <summary>
/// 道具目录 SO背包系统
/// 全局唯一资产(建议 Assets/_Game/Data/Inventory/ItemDatabase.asset
/// 通过 itemId 查找 <see cref="ItemSO"/> 引用。
/// 由 <see cref="InventoryManager"/> 在拾取 / OnLoad 时查询。
/// 设计对照 <see cref="BaseGames.Equipment.CharmCatalogSO"/>,但内部用字典缓存以支持高频查询。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Inventory/ItemDatabase")]
public class ItemDatabaseSO : ScriptableObject
{
[SerializeField] private ItemSO[] _items;
public IReadOnlyList<ItemSO> AllItems => _items;
private Dictionary<string, ItemSO> _index;
/// <summary>按 itemId 查找道具,找不到返回 null。</summary>
public ItemSO Find(string itemId)
{
if (string.IsNullOrEmpty(itemId) || _items == null) return null;
EnsureIndex();
return _index.TryGetValue(itemId, out var item) ? item : null;
}
private void EnsureIndex()
{
if (_index != null) return;
_index = new Dictionary<string, ItemSO>(_items.Length);
foreach (var item in _items)
if (item != null && !string.IsNullOrEmpty(item.itemId))
_index[item.itemId] = item;
}
/// <summary>编辑器修改 _items 后清空索引,下次 Find 重建。</summary>
public void InvalidateIndex() => _index = null;
#if UNITY_EDITOR
private void OnValidate() => _index = null;
#endif
}
}

View File

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

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Localization;
namespace BaseGames.Inventory
{
/// <summary>
/// 道具数据 SO背包系统
/// 资产路径建议: Assets/_Game/Data/Inventory/Items/Item_{Name}.asset
/// 命名/本地化约定与 <see cref="BaseGames.Equipment.CharmSO"/> 保持一致Items 表)。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Inventory/Item")]
public class ItemSO : ScriptableObject, ILocalizableAsset
{
[Header("Identity")]
[Tooltip("全局唯一 ID如 \"Item_AncientKey\"。与拾取广播的 itemId 对应。")]
public string itemId;
[Tooltip("本地化 KeyItems 表)。")]
public string displayNameKey;
[TextArea(2, 4)]
public string descriptionKey;
[Header("Classification")]
public ItemCategory category;
[Header("Visual")]
public Sprite icon;
[Header("Stacking")]
[Tooltip("可叠加消耗品等。false 时同 ID 只记 1 份。")]
public bool stackable;
[Tooltip("叠加上限stackable=true 时生效,<=0 表示不限)。")]
public int maxStack = 99;
[Header("Map Reveal")]
[Tooltip("category=MapShard 时,拾取后揭示的区域 ID对应 MapManager 区域)。")]
public string revealRegionId;
[Header("Lore")]
[Tooltip("是否唯一(剧情关键 / 一次性)。")]
public bool isUnique;
public IEnumerable<LocalizationKeyRef> GetLocalizationKeys()
{
if (!string.IsNullOrEmpty(displayNameKey))
yield return new LocalizationKeyRef(displayNameKey, LocalizationTable.Items, nameof(displayNameKey));
if (!string.IsNullOrEmpty(descriptionKey))
yield return new LocalizationKeyRef(descriptionKey, LocalizationTable.Items, nameof(descriptionKey));
}
}
}

View File

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