574 lines
18 KiB
Markdown
574 lines
18 KiB
Markdown
# 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<ShopItemSO> 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<string> _soldUniqueItems = new(); // 唯一已购商品 ID
|
||
Dictionary<string, int> _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;
|
||
}
|
||
|
||
/// <summary>尝试购买商品。成功返回 true,失败返回 false + 失败原因。</summary>
|
||
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<ShopItemSO> 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<ShopTransaction> { }
|
||
```
|
||
|
||
---
|
||
|
||
## 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<UIDocument>();
|
||
_itemGrid = _doc.rootVisualElement.Q("ItemGrid");
|
||
_geoLabel = _doc.rootVisualElement.Q<Label>("GeoAmount");
|
||
|
||
_doc.rootVisualElement.Q<Button>("BuyButton").clicked += OnBuyClicked;
|
||
Refresh();
|
||
}
|
||
|
||
public void Refresh()
|
||
{
|
||
_itemGrid.Clear();
|
||
foreach (var item in _controller.GetDisplayInventory())
|
||
{
|
||
var cell = CreateItemCell(item);
|
||
_itemGrid.Add(cell);
|
||
}
|
||
}
|
||
|
||
VisualElement CreateItemCell(ShopItemSO item)
|
||
{
|
||
var cell = new VisualElement();
|
||
cell.AddToClassList("shop-item-cell");
|
||
// icon, name, price
|
||
var icon = new Image { image = item.icon.texture };
|
||
var name = new Label(item.displayName);
|
||
var price = new Label($"{_controller.GetActualPrice(item)} Geo");
|
||
cell.Add(icon); cell.Add(name); cell.Add(price);
|
||
cell.RegisterCallback<ClickEvent>(_ => SelectItem(item));
|
||
if (!_controller.IsAvailable(item))
|
||
cell.AddToClassList("sold-out");
|
||
return cell;
|
||
}
|
||
|
||
void SelectItem(ShopItemSO item)
|
||
{
|
||
_selectedItem = item;
|
||
var root = _doc.rootVisualElement;
|
||
root.Q<Image>("PreviewIcon").image = item.icon.texture;
|
||
root.Q<Label>("PreviewName").text = item.displayName;
|
||
root.Q<Label>("PreviewDescription").text = item.description;
|
||
root.Q<Label>("PriceTag").text = $"{_controller.GetActualPrice(item)} Geo";
|
||
}
|
||
|
||
void OnBuyClicked()
|
||
{
|
||
if (_selectedItem == null) return;
|
||
bool ok = _controller.TryPurchase(_selectedItem, out var result);
|
||
if (!ok)
|
||
{
|
||
// 显示失败原因(Geo 不足 / 已售罄)
|
||
Debug.Log($"购买失败: {result}");
|
||
return;
|
||
}
|
||
Refresh();
|
||
SelectItem(_selectedItem); // 刷新预览(可能变为 Sold Out)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. IInteractable 集成
|
||
|
||
`ShopNPC` 实现 `IInteractable`,玩家交互后打开商店面板:
|
||
|
||
```csharp
|
||
namespace BaseGames.World.Shop
|
||
{
|
||
public class ShopNPC : MonoBehaviour, IInteractable
|
||
{
|
||
[SerializeField] ShopController _shopController;
|
||
[SerializeField] VoidEventChannelSO _onShopOpened;
|
||
[SerializeField] VoidEventChannelSO _onShopClosed;
|
||
|
||
// IInteractable
|
||
public bool CanInteract => true; // 商人始终可交互
|
||
|
||
public void Interact(Transform player)
|
||
{
|
||
_onShopOpened.Raise(); // UIManager 监听并显示 ShopPanel
|
||
}
|
||
|
||
public void OnPlayerEnterRange(Transform player) { /* 显示交互提示 */ }
|
||
public void OnPlayerExitRange() { /* 隐藏交互提示 */ }
|
||
}
|
||
}
|
||
```
|
||
|
||
### 商店打开/关闭流程
|
||
|
||
```
|
||
玩家按 [F] 交互
|
||
│
|
||
▼
|
||
ShopNPC.Interact()
|
||
└─ OnShopOpened.Raise()
|
||
│
|
||
▼
|
||
UIManager 监听 OnShopOpened
|
||
└─ ShopPanel.gameObject.SetActive(true)
|
||
└─ InputReaderSO.EnableUIInput() // 切换到 UI Action Map
|
||
│
|
||
▼
|
||
玩家关闭商店
|
||
└─ OnShopClosed.Raise()
|
||
└─ InputReaderSO.EnableGameplayInput()
|
||
```
|
||
|
||
---
|
||
|
||
## 9. SaveData 集成
|
||
|
||
商店状态需持久化,确保重启游戏后唯一商品不会"复活":
|
||
|
||
### SaveData 商店字段
|
||
|
||
```json
|
||
"shops": {
|
||
"Shop_Forest_Merchant": {
|
||
"soldUniqueItems": ["Item_Charm_QuickSlash", "Item_MapFragment_Forest"],
|
||
"purchaseCounts": {
|
||
"Item_HealthPotion_Small": 3,
|
||
"Item_HealthPotion_Large": 1
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 读/写接口
|
||
|
||
```csharp
|
||
// ShopController
|
||
public ShopSaveData GetSaveData() => new ShopSaveData
|
||
{
|
||
SoldUniqueItems = _soldUniqueItems.ToList(),
|
||
PurchaseCounts = new Dictionary<string, int>(_purchaseCount),
|
||
};
|
||
|
||
public void LoadSaveData(ShopSaveData data)
|
||
{
|
||
_soldUniqueItems = new HashSet<string>(data.SoldUniqueItems);
|
||
_purchaseCount = new Dictionary<string, int>(data.PurchaseCounts);
|
||
}
|
||
```
|
||
|
||
> `ShopSaveData` 存储在 `SaveData.shops` 字典中,键为 `ShopInventorySO.shopId`。
|
||
|
||
---
|
||
|
||
## 10. 事件频道
|
||
|
||
| 频道 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `OnShopOpened.asset` | `VoidEventChannelSO` | 商店打开(UIManager 监听)|
|
||
| `OnShopClosed.asset` | `VoidEventChannelSO` | 商店关闭 |
|
||
| `OnItemPurchased.asset` | `ShopTransactionEvent` | 商品购买成功(PlayerStats 监听,扣 Geo + 应用效果)|
|
||
|
||
**`PlayerStats` 监听 `OnItemPurchased` 并执行效果**:
|
||
|
||
```csharp
|
||
// PlayerStats.OnEnable()
|
||
_onItemPurchased.OnEventRaised += ApplyShopItem;
|
||
|
||
void ApplyShopItem(ShopTransaction tx)
|
||
{
|
||
AddGeo(-tx.GeoSpent); // 扣 Geo
|
||
switch (tx.Item.itemType)
|
||
{
|
||
case ShopItemType.HealthRestoration:
|
||
Heal(tx.Item.healthRestoreAmount);
|
||
break;
|
||
case ShopItemType.CharmItem:
|
||
// EquipmentManager 也监听此事件,自动添加到背包
|
||
break;
|
||
case ShopItemType.KeyItem:
|
||
_onCollectiblePickedUp.Raise(tx.Item.keyItemId);
|
||
break;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 编辑器友好设计
|
||
|
||
### ShopInventorySO 自定义 Inspector
|
||
|
||
```csharp
|
||
[CustomEditor(typeof(ShopInventorySO))]
|
||
public class ShopInventorySOEditor : Editor
|
||
{
|
||
public override VisualElement CreateInspectorGUI()
|
||
{
|
||
var root = new VisualElement();
|
||
InspectorElement.FillDefaultInspector(root, serializedObject, this);
|
||
|
||
// 库存统计
|
||
var stats = new Foldout { text = "库存统计" };
|
||
var inv = (ShopInventorySO)target;
|
||
stats.Add(new Label($"商品总数: {inv.defaultInventory.Count}"));
|
||
stats.Add(new Label($"唯一商品: {inv.defaultInventory.Count(i => i != null && i.isUnique)}"));
|
||
stats.Add(new Label($"价格范围: {(inv.defaultInventory.Where(i=>i!=null).Select(i=>i.basePrice).DefaultIfEmpty(0).Min())} ~ " +
|
||
$"{(inv.defaultInventory.Where(i=>i!=null).Select(i=>i.basePrice).DefaultIfEmpty(0).Max())} Geo"));
|
||
root.Add(stats);
|
||
return root;
|
||
}
|
||
}
|
||
```
|