# 28 · 商店系统 > **命名空间** `BaseGames.World.Shop` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.Dialogue`(IInteractable)· `BaseGames.UI` · `BaseGames.Player`(PlayerStats) --- ## 目录 1. [系统总览](#1-系统总览) 2. [ShopItemSO — 商品数据](#2-shopitemso--商品数据) 3. [ShopInventorySO — 商店库存](#3-shopinventoryso--商店库存) 4. [价格规则](#4-价格规则) 5. [补货时机](#5-补货时机) 6. [ShopController — 运行时逻辑](#6-shopcontroller--运行时逻辑) 7. [商店 UI(ShopPanel)](#7-商店-uishoppanel) 8. [IInteractable 集成](#8-iinteractable-集成) 9. [SaveData 集成](#9-savedata-集成) 10. [事件频道](#10-事件频道) 11. [编辑器友好设计](#11-编辑器友好设计) --- ## 1. 系统总览 ``` 商店系统职责: ├─ ShopItemSO → 商品数据 SO(名称、图标、价格、效果) ├─ ShopInventorySO → 商店库存 SO(商品列表、最大货位数、补货策略) ├─ ShopController → 运行时逻辑(购买校验、Geo 扣减、库存更新) ├─ ShopPanel → UI 面板(商品格、预览、购买确认) └─ ShopNPC → IInteractable 实现,触发商店打开 ``` **零耦合原则**:`ShopController` 通过 SO 事件频道发布 `OnItemPurchased`,不直接调用 `PlayerStats.AddGeo()`;`PlayerStats` 监听并自行扣减。 --- ## 2. ShopItemSO — 商品数据 ```csharp [CreateAssetMenu(menuName = "World/Shop/ShopItem")] public class ShopItemSO : ScriptableObject { [Header("基础信息")] public string itemId; // 全局唯一 ID,如 "Item_HealthPotion_Small" public string displayName; // 显示名称(本地化 key) [TextArea(2, 5)] public string description; // 描述文本 [Header("外观")] public Sprite icon; // 商品图标 [Header("价格")] public int basePrice; // 基础价格(Geo) public bool isUnique; // 是否唯一购买(购买后从库存移除且不补货) [Header("商品类型")] public ShopItemType itemType; // HealthRestoration / CharmItem / KeyItem / ConsumableBuff [Header("效果(根据类型填写)")] public int healthRestoreAmount; // itemType = HealthRestoration 时 public CharmSO charmReference; // itemType = CharmItem 时(引用 CharmSO) public string keyItemId; // itemType = KeyItem 时(唯一 ID) [Header("限购")] public int maxPurchaseCount; // -1 = 无限购买;> 0 = 限购次数 } public enum ShopItemType { HealthRestoration, // 恢复 HP CharmItem, // 魅力(装备系统) KeyItem, // 关键物品(触发 OnCollectiblePickedUp 事件) ConsumableBuff, // 临时增益(如攻击力提升 30s) MapFragment, // 地图碎片(扩展地图显示区域) } ``` **资产存放路径**:`Assets/ScriptableObjects/Shop/Items/` **命名规范**:`Item_{Category}_{Name}.asset` --- ## 3. ShopInventorySO — 商店库存 ```csharp [CreateAssetMenu(menuName = "World/Shop/ShopInventory")] public class ShopInventorySO : ScriptableObject { [Header("商店信息")] public string shopId; // 全局唯一 ID,如 "Shop_Forest_Merchant" public string shopkeeperName; // 商人名称(用于对话) public Sprite shopkeeperPortrait; // 头像 [Header("库存配置")] [Tooltip("商店默认库存(所有可售商品)")] public List defaultInventory; [Tooltip("最大同时陈列货位(-1 = 无限制,显示全部)")] public int maxDisplaySlots = -1; [Header("补货策略")] public RestockPolicy restockPolicy; // 见 §5 [Tooltip("存档点补货:每次激活存档点后重置已售出的可补货商品")] public bool restockOnSavePoint = true; [Tooltip("仅补货一次性消耗品,唯一商品(isUnique)永不补货")] public bool neverRestockUnique = true; } public enum RestockPolicy { Never, // 售出即消失,不补货(稀有魅力/关键物品商店) OnSavePoint, // 每次激活存档点后补货(默认商人) OnBossDefeat, // 击败当前区域 Boss 后一次性补货 Periodic, // 按真实时间补货(如每 24 小时,依赖 SaveData 时间戳) } ``` **资产存放路径**:`Assets/ScriptableObjects/Shop/` **命名规范**:`Shop_{Region}_{Name}.asset` --- ## 4. 价格规则 ### 基础价格表(参考) | 商品类型 | 价格范围 | 示例 | |---------|---------|------| | 小型 HP 回复(1 HP)| 50–80 Geo | 蜂蜜球 | | 大型 HP 回复(全量)| 200–350 Geo | 生命果 | | 魅力(1 Notch)| 120–180 Geo | 快速斩 | | 魅力(2 Notch)| 200–280 Geo | 强化冲刺 | | 魅力(3 Notch)| 350–480 Geo | 毒刺 | | 地图碎片(区域)| 150–200 Geo | 森林区地图 | | 关键物品 | 策划自定 | 铁门钥匙 | ### 折扣机制(可选,P2) ```csharp // ShopController 中可选折扣计算 public int GetActualPrice(ShopItemSO item) { float discount = _discountCharmEquipped ? 0.8f : 1.0f; // 护符折扣示例 return Mathf.RoundToInt(item.basePrice * discount); } ``` --- ## 5. 补货时机 ### OnSavePoint 补货流程 ``` 玩家激活存档点 │ ▼ OnSavePointActivated 事件频道(VoidEventChannelSO) │ ▼ ShopController.OnSavePointActivated() │ ├─ 遍历所有已售出的可补货商品(!isUnique && maxPurchaseCount == -1) └─ 将其从 _soldItems 集合中移除(标记为"重新可购买") ``` ### OnBossDefeat 补货流程 ``` OnBossDefeated 事件频道(StringEventChannelSO,payload = bossId) │ ▼ ShopController.OnBossDefeated(string bossId) │ └─ 若 _inventory.restockPolicy == OnBossDefeat:执行补货 ``` --- ## 6. ShopController — 运行时逻辑 ```csharp namespace BaseGames.World.Shop { public class ShopController : MonoBehaviour { // ── 配置 ───────────────────────────────────────────────── [SerializeField] ShopInventorySO _inventory; [SerializeField] VoidEventChannelSO _onSavePointActivated; [SerializeField] StringEventChannelSO _onBossDefeated; [SerializeField] IntEventChannelSO _onPlayerGeoChanged; // 监听 Geo 变化 [SerializeField] ShopTransactionEvent _onItemPurchased; // 发布购买事件 // ── 运行时状态 ───────────────────────────────────────────── HashSet _soldUniqueItems = new(); // 唯一已购商品 ID Dictionary _purchaseCount = new(); // 各商品购买次数 int _playerGeo; // 从 PlayerStats 同步 void OnEnable() { _onSavePointActivated.OnEventRaised += HandleSavePoint; _onBossDefeated.OnEventRaised += HandleBossDefeated; _onPlayerGeoChanged.OnEventRaised += geo => _playerGeo = geo; } void OnDisable() { _onSavePointActivated.OnEventRaised -= HandleSavePoint; _onBossDefeated.OnEventRaised -= HandleBossDefeated; _onPlayerGeoChanged.OnEventRaised -= geo => _playerGeo = geo; } /// 尝试购买商品。成功返回 true,失败返回 false + 失败原因。 public bool TryPurchase(ShopItemSO item, out ShopPurchaseResult result) { // 1. Geo 是否足够 int price = GetActualPrice(item); if (_playerGeo < price) { result = ShopPurchaseResult.InsufficientGeo; return false; } // 2. 是否已售罄(唯一商品) if (item.isUnique && _soldUniqueItems.Contains(item.itemId)) { result = ShopPurchaseResult.SoldOut; return false; } // 3. 是否超出限购次数 if (item.maxPurchaseCount > 0) { _purchaseCount.TryGetValue(item.itemId, out int count); if (count >= item.maxPurchaseCount) { result = ShopPurchaseResult.PurchaseLimitReached; return false; } } // 4. 执行购买 _purchaseCount[item.itemId] = _purchaseCount.GetValueOrDefault(item.itemId) + 1; if (item.isUnique) _soldUniqueItems.Add(item.itemId); _onItemPurchased.Raise(new ShopTransaction { Item = item, GeoSpent = price }); result = ShopPurchaseResult.Success; return true; } public int GetActualPrice(ShopItemSO item) => item.basePrice; // 可扩展折扣逻辑 public bool IsAvailable(ShopItemSO item) { if (item.isUnique && _soldUniqueItems.Contains(item.itemId)) return false; if (item.maxPurchaseCount > 0) { _purchaseCount.TryGetValue(item.itemId, out int c); return c < item.maxPurchaseCount; } return true; } public IReadOnlyList GetDisplayInventory() { // 过滤不可购买商品,按 maxDisplaySlots 截取 var available = _inventory.defaultInventory .Where(IsAvailable) .ToList(); // 此处 LINQ 仅在 UI 打开时调用,非热路径 if (_inventory.maxDisplaySlots > 0 && available.Count > _inventory.maxDisplaySlots) return available.Take(_inventory.maxDisplaySlots).ToList(); return available; } void HandleSavePoint() { if (_inventory.restockPolicy == RestockPolicy.OnSavePoint) Restock(); } void HandleBossDefeated(string _) { if (_inventory.restockPolicy == RestockPolicy.OnBossDefeat) Restock(); } void Restock() { // 移除非唯一商品的购买记录,使其重新可购 var keysToRemove = _purchaseCount.Keys .Where(id => { var item = _inventory.defaultInventory.Find(i => i.itemId == id); return item != null && !item.isUnique; }) .ToList(); foreach (var key in keysToRemove) _purchaseCount.Remove(key); } } public enum ShopPurchaseResult { Success, InsufficientGeo, SoldOut, PurchaseLimitReached } public struct ShopTransaction { public ShopItemSO Item; public int GeoSpent; } } ``` ### ShopTransactionEvent 频道 ```csharp // ShopTransaction 事件频道(自定义类型频道) [CreateAssetMenu(menuName = "Events/ShopTransactionEvent")] public class ShopTransactionEvent : BaseEventChannel { } ``` --- ## 7. 商店 UI(ShopPanel) ### 面板结构(UXML) ``` ShopPanel ├── ShopHeader │ ├── ShopkeeperPortrait │ └── ShopkeeperName ├── ItemGrid (ScrollView) │ └── ShopItemCell × N │ ├── ItemIcon │ ├── ItemName │ └── PriceLabel(含 GeoIcon) ├── ItemPreviewPanel │ ├── PreviewIcon (large) │ ├── PreviewName │ ├── PreviewDescription │ ├── PriceTag │ └── BuyButton └── PlayerGeoDisplay ├── GeoIcon └── GeoAmount ``` ### ShopPanel.cs(UI 逻辑) ```csharp namespace BaseGames.World.Shop { public class ShopPanel : MonoBehaviour { [SerializeField] ShopController _controller; UIDocument _doc; VisualElement _itemGrid; Label _geoLabel; ShopItemSO _selectedItem; void OnEnable() { _doc = GetComponent(); _itemGrid = _doc.rootVisualElement.Q("ItemGrid"); _geoLabel = _doc.rootVisualElement.Q