chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

@@ -0,0 +1,573 @@
# 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;
}
}
```