Files
zeling_v2/Docs/Design/28_ShopSystem.md
2026-05-08 11:04:00 +08:00

574 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. [商店 UIShopPanel](#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| 5080 Geo | 蜂蜜球 |
| 大型 HP 回复(全量)| 200350 Geo | 生命果 |
| 魅力1 Notch| 120180 Geo | 快速斩 |
| 魅力2 Notch| 200280 Geo | 强化冲刺 |
| 魅力3 Notch| 350480 Geo | 毒刺 |
| 地图碎片(区域)| 150200 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 事件频道StringEventChannelSOpayload = 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. 商店 UIShopPanel
### 面板结构UXML
```
ShopPanel
├── ShopHeader
│ ├── ShopkeeperPortrait
│ └── ShopkeeperName
├── ItemGrid (ScrollView)
│ └── ShopItemCell × N
│ ├── ItemIcon
│ ├── ItemName
│ └── PriceLabel含 GeoIcon
├── ItemPreviewPanel
│ ├── PreviewIcon (large)
│ ├── PreviewName
│ ├── PreviewDescription
│ ├── PriceTag
│ └── BuyButton
└── PlayerGeoDisplay
├── GeoIcon
└── GeoAmount
```
### ShopPanel.csUI 逻辑)
```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;
}
}
```