18 KiB
18 KiB
28 · 商店系统
命名空间
BaseGames.World.Shop
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Dialogue(IInteractable)·BaseGames.UI·BaseGames.Player(PlayerStats)
目录
- 系统总览
- ShopItemSO — 商品数据
- ShopInventorySO — 商店库存
- 价格规则
- 补货时机
- ShopController — 运行时逻辑
- 商店 UI(ShopPanel)
- IInteractable 集成
- SaveData 集成
- 事件频道
- 编辑器友好设计
1. 系统总览
商店系统职责:
├─ ShopItemSO → 商品数据 SO(名称、图标、价格、效果)
├─ ShopInventorySO → 商店库存 SO(商品列表、最大货位数、补货策略)
├─ ShopController → 运行时逻辑(购买校验、Geo 扣减、库存更新)
├─ ShopPanel → UI 面板(商品格、预览、购买确认)
└─ ShopNPC → IInteractable 实现,触发商店打开
零耦合原则:ShopController 通过 SO 事件频道发布 OnItemPurchased,不直接调用 PlayerStats.AddGeo();PlayerStats 监听并自行扣减。
2. ShopItemSO — 商品数据
[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 — 商店库存
[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)
// 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 — 运行时逻辑
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 频道
// 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 逻辑)
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,玩家交互后打开商店面板:
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 商店字段
"shops": {
"Shop_Forest_Merchant": {
"soldUniqueItems": ["Item_Charm_QuickSlash", "Item_MapFragment_Forest"],
"purchaseCounts": {
"Item_HealthPotion_Small": 3,
"Item_HealthPotion_Large": 1
}
}
}
读/写接口
// 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 并执行效果:
// 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
[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;
}
}