多轮审查和修复
This commit is contained in:
@@ -9,7 +9,11 @@
|
||||
"rootNamespace": "BaseGames.World.Shop",
|
||||
"references": [
|
||||
"BaseGames.World",
|
||||
"BaseGames.Core.Events"
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Equipment",
|
||||
"BaseGames.Dialogue"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
|
||||
179
Assets/Scripts/World/Shop/ShopController.cs
Normal file
179
Assets/Scripts/World/Shop/ShopController.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// 商店流程控制器(架构 15_MapShopModule §2.3)。
|
||||
/// 管理库存可见性、购买逻辑、补货策略,并通过 ISaveable 持久化购买记录。
|
||||
/// </summary>
|
||||
public class ShopController : MonoBehaviour, ISaveable
|
||||
{
|
||||
[SerializeField] private ShopInventorySO _inventory;
|
||||
[SerializeField] private ShopPanel _shopPanel; // UI 面板(P4 UI 模块实现)
|
||||
|
||||
[Header("Event Channels(广播)")]
|
||||
[SerializeField] private StringEventChannelSO _onShopOpen; // EVT_ShopOpened(shopId)
|
||||
[SerializeField] private VoidEventChannelSO _onShopClosed; // EVT_ShopClosed
|
||||
[SerializeField] private ShopPurchaseEventChannelSO _onItemPurchased; // EVT_ItemPurchased
|
||||
|
||||
[Header("Event Channels(订阅补货触发)")]
|
||||
[SerializeField] private StringEventChannelSO _onBossDefeated; // EVT_BossDefeated
|
||||
[SerializeField] private VoidEventChannelSO _onSavePointActivated; // EVT_SavePointActivated
|
||||
|
||||
// key = itemId,value = 已购次数
|
||||
private Dictionary<string, int> _purchaseCounts = new();
|
||||
private HashSet<string> _soldUniqueItems = new();
|
||||
private List<ShopItemSO> _availableItemsCache;
|
||||
private bool _isDirty = true;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_inventory == null) return;
|
||||
if (_inventory.RestockPolicy == RestockPolicy.OnBossDefeat)
|
||||
_onBossDefeated?.Subscribe(OnBossDefeated).AddTo(_subs);
|
||||
if (_inventory.RestockPolicy == RestockPolicy.OnSavePoint)
|
||||
_onSavePointActivated?.Subscribe(OnSavePointActivated).AddTo(_subs);
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
private void OnBossDefeated(string _) => Restock();
|
||||
private void OnSavePointActivated() => Restock();
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────────
|
||||
|
||||
public void Open()
|
||||
{
|
||||
_shopPanel?.Show(GetAvailableItems(), this);
|
||||
_onShopOpen?.Raise(_inventory.ShopId);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
_shopPanel?.Hide();
|
||||
_onShopClosed?.Raise();
|
||||
}
|
||||
|
||||
/// <summary>返回当前可购买的商品列表(过滤已售唯一品及超限商品)。结果在库存变更时才重建。</summary>
|
||||
public List<ShopItemSO> GetAvailableItems()
|
||||
{
|
||||
if (!_isDirty) return _availableItemsCache;
|
||||
if (_inventory?.DefaultInventory == null)
|
||||
{
|
||||
_availableItemsCache = new List<ShopItemSO>();
|
||||
_isDirty = false;
|
||||
return _availableItemsCache;
|
||||
}
|
||||
_availableItemsCache = _inventory.DefaultInventory
|
||||
.Take(_inventory.MaxDisplaySlots)
|
||||
.Where(item => item != null
|
||||
&& !_soldUniqueItems.Contains(item.ItemId)
|
||||
&& (item.MaxPurchaseCount < 0 || GetPurchaseCount(item.ItemId) < item.MaxPurchaseCount))
|
||||
.ToList();
|
||||
_isDirty = false;
|
||||
return _availableItemsCache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 RestockPolicy 补货:重置非唯一商品的购买次数(已售唯一品不恢复)。
|
||||
/// </summary>
|
||||
public void Restock()
|
||||
{
|
||||
if (_inventory?.DefaultInventory == null) return;
|
||||
var nonUniqueIds = _inventory.DefaultInventory
|
||||
.Where(i => i != null && !i.IsUnique)
|
||||
.Select(i => i.ItemId);
|
||||
foreach (var id in nonUniqueIds)
|
||||
_purchaseCounts.Remove(id);
|
||||
_isDirty = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试购买商品。由 ShopPanel 的购买按钮调用。
|
||||
/// </summary>
|
||||
/// <returns>购买成功返回 true;资金不足或商品不可购返回 false。</returns>
|
||||
public bool TryPurchase(ShopItemSO item, int playerGeo)
|
||||
{
|
||||
if (item == null) return false;
|
||||
int effectivePrice = GetEffectivePrice(item);
|
||||
if (playerGeo < effectivePrice) return false;
|
||||
if (_soldUniqueItems.Contains(item.ItemId)) return false;
|
||||
if (item.MaxPurchaseCount >= 0 && GetPurchaseCount(item.ItemId) >= item.MaxPurchaseCount)
|
||||
return false;
|
||||
|
||||
// 通过事件频道扣 Geo(PlayerStats 监听 EVT_ItemPurchased)
|
||||
_onItemPurchased?.Raise(new ShopPurchaseEvent
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
Price = effectivePrice,
|
||||
});
|
||||
|
||||
// 更新库存状态
|
||||
_purchaseCounts[item.ItemId] = GetPurchaseCount(item.ItemId) + 1;
|
||||
if (item.IsUnique) _soldUniqueItems.Add(item.ItemId);
|
||||
_isDirty = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回应用难度乘数后的实际价格。UI 层可通过此方法显示正确标价。
|
||||
/// </summary>
|
||||
public int GetEffectivePrice(ShopItemSO item)
|
||||
{
|
||||
var scaler = ServiceLocator.GetOrDefault<IDifficultyService>()?.CurrentScaler;
|
||||
if (scaler == null) return item.BasePrice;
|
||||
return Mathf.Max(1, Mathf.RoundToInt(item.BasePrice * scaler.ShopPriceMultiplier));
|
||||
}
|
||||
|
||||
private int GetPurchaseCount(string id)
|
||||
=> _purchaseCounts.TryGetValue(id, out var c) ? c : 0;
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
if (_inventory == null) return;
|
||||
if (!data.Shops.ShopRecords.ContainsKey(_inventory.ShopId))
|
||||
data.Shops.ShopRecords[_inventory.ShopId] = new ShopRecord();
|
||||
|
||||
var record = data.Shops.ShopRecords[_inventory.ShopId];
|
||||
record.SoldUniqueItems = _soldUniqueItems.ToList();
|
||||
record.PurchaseCounts = new Dictionary<string, int>(_purchaseCounts);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
if (_inventory == null) return;
|
||||
if (data.Shops.ShopRecords.TryGetValue(_inventory.ShopId, out var record))
|
||||
{
|
||||
_soldUniqueItems = new HashSet<string>(record.SoldUniqueItems ?? new List<string>());
|
||||
_purchaseCounts = record.PurchaseCounts ?? new Dictionary<string, int>();
|
||||
_isDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ShopPanel ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 商店 UI 面板基类。
|
||||
/// ShopController 通过此接口调用面板显示/隐藏,已解耦具体 UI 实现。
|
||||
/// </summary>
|
||||
public class ShopPanel : MonoBehaviour
|
||||
{
|
||||
public virtual void Show(List<ShopItemSO> items, ShopController controller) { }
|
||||
public virtual void Hide() { }
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Shop/ShopController.cs.meta
Normal file
11
Assets/Scripts/World/Shop/ShopController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 519d4cd7bb9c7df408bb5dce39476ce5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
36
Assets/Scripts/World/Shop/ShopInventorySO.cs
Normal file
36
Assets/Scripts/World/Shop/ShopInventorySO.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// 商店库存 SO(架构 15_MapShopModule §2.2)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Shop/Inventory_{ShopId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "World/Shop/ShopInventory")]
|
||||
public class ShopInventorySO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string ShopId; // 全局唯一,如 "Shop_Village"
|
||||
|
||||
[Header("库存")]
|
||||
public List<ShopItemSO> DefaultInventory = new(); // 初始商品列表
|
||||
public int MaxDisplaySlots = 6; // UI 最多同时显示的商品格数
|
||||
|
||||
[Header("补货策略")]
|
||||
public RestockPolicy RestockPolicy = RestockPolicy.Never;
|
||||
|
||||
[Header("老板信息")]
|
||||
public Sprite KeeperPortrait;
|
||||
public string KeeperName;
|
||||
}
|
||||
|
||||
/// <summary>库存补货时机策略。</summary>
|
||||
public enum RestockPolicy
|
||||
{
|
||||
Never, // 永不补货(唯一商品卖完即消失)
|
||||
OnSavePoint, // 激活存档点时补货
|
||||
OnBossDefeat, // 击败 Boss 后补货
|
||||
Periodic, // 周期性补货(由 ShopController 定时或条件检查)
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Shop/ShopInventorySO.cs.meta
Normal file
11
Assets/Scripts/World/Shop/ShopInventorySO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ea3e80e8c87da3b438ba8ef432ca30d7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
42
Assets/Scripts/World/Shop/ShopItemSO.cs
Normal file
42
Assets/Scripts/World/Shop/ShopItemSO.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Equipment;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// 商店单品 SO(架构 15_MapShopModule §2.1)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Shop/Item_{ItemId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "World/Shop/ShopItem")]
|
||||
public class ShopItemSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string ItemId;
|
||||
public string DisplayName;
|
||||
[TextArea(2, 5)]
|
||||
public string Description;
|
||||
public Sprite Icon;
|
||||
|
||||
[Header("价格")]
|
||||
public int BasePrice;
|
||||
public bool IsUnique; // 购买一次后永久从库存移除
|
||||
|
||||
[Header("商品类型")]
|
||||
public ShopItemType ItemType;
|
||||
|
||||
// 按 ItemType 填写以下字段(其余留空)
|
||||
public int HealthRestoreAmount; // HealthRestoration 类型
|
||||
public CharmSO CharmReference; // CharmItem 类型
|
||||
public string KeyItemId; // KeyItem 类型
|
||||
public int MaxPurchaseCount = -1; // -1 = 无限次
|
||||
}
|
||||
|
||||
public enum ShopItemType
|
||||
{
|
||||
HealthRestoration,
|
||||
CharmItem,
|
||||
KeyItem,
|
||||
ConsumableBuff,
|
||||
MapFragment,
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Shop/ShopItemSO.cs.meta
Normal file
11
Assets/Scripts/World/Shop/ShopItemSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d01bb739dd4fae40a4a5391030c8802
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
62
Assets/Scripts/World/Shop/ShopNPC.cs
Normal file
62
Assets/Scripts/World/Shop/ShopNPC.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.World;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Dialogue;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// 商店 NPC 交互组件(架构 15_MapShopModule §2.4)。
|
||||
/// 实现 IInteractable;玩家与其交互时先触发招呼对话,对话结束后打开商店面板。
|
||||
/// 若无招呼对话,则直接打开商店。
|
||||
/// </summary>
|
||||
public class ShopNPC : MonoBehaviour, IInteractable
|
||||
{
|
||||
[Header("组件引用")]
|
||||
[SerializeField] private ShopController _shopController;
|
||||
|
||||
[Header("招呼对话(可空,留空则直接开店)")]
|
||||
[SerializeField] private DialogueDataSO _greetDialogue;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private DialogueEventChannelSO _dialogueChannel; // EVT_DialogueStart
|
||||
[SerializeField] private VoidEventChannelSO _onDialogueEnded; // EVT_DialogueEnded
|
||||
|
||||
[Header("交互提示")]
|
||||
[SerializeField] private string _interactPrompt = "购买物品";
|
||||
|
||||
private EventSubscription _greetSub;
|
||||
|
||||
// ── IInteractable ─────────────────────────────────────────────────────
|
||||
|
||||
public bool CanInteract => _shopController != null;
|
||||
public string InteractPrompt => _interactPrompt;
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (_greetDialogue != null && _dialogueChannel != null)
|
||||
{
|
||||
_greetSub.Dispose();
|
||||
_greetSub = _onDialogueEnded.Subscribe(OnGreetDialogueEnded);
|
||||
_dialogueChannel.Raise(_greetDialogue);
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenShop();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnGreetDialogueEnded()
|
||||
{
|
||||
_greetSub.Dispose();
|
||||
OpenShop();
|
||||
}
|
||||
|
||||
private void OpenShop() => _shopController?.Open();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/World/Shop/ShopNPC.cs.meta
Normal file
11
Assets/Scripts/World/Shop/ShopNPC.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d7f9d506a48350f43964575c094de208
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user