多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View File

@@ -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,

View 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_ShopOpenedshopId
[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 = itemIdvalue = 已购次数
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;
// 通过事件频道扣 GeoPlayerStats 监听 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() { }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 519d4cd7bb9c7df408bb5dce39476ce5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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 定时或条件检查)
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ea3e80e8c87da3b438ba8ef432ca30d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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,
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2d01bb739dd4fae40a4a5391030c8802
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d7f9d506a48350f43964575c094de208
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: