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

18 KiB
Raw Permalink Blame History

28 · 商店系统

命名空间 BaseGames.World.Shop
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.DialogueIInteractable· BaseGames.UI · BaseGames.PlayerPlayerStats


目录

  1. 系统总览
  2. ShopItemSO — 商品数据
  3. ShopInventorySO — 商店库存
  4. 价格规则
  5. 补货时机
  6. ShopController — 运行时逻辑
  7. 商店 UIShopPanel
  8. IInteractable 集成
  9. SaveData 集成
  10. 事件频道
  11. 编辑器友好设计

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 5080 Geo 蜂蜜球
大型 HP 回复(全量) 200350 Geo 生命果
魅力1 Notch 120180 Geo 快速斩
魅力2 Notch 200280 Geo 强化冲刺
魅力3 Notch 350480 Geo 毒刺
地图碎片(区域) 150200 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 事件频道StringEventChannelSOpayload = 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. 商店 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 逻辑)

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;
    }
}