From fcceb0ce450568ed96bc0dd4c765d80790e577c1 Mon Sep 17 00:00:00 2001 From: Konstantin Dyachenko Date: Sun, 1 Mar 2026 21:04:34 +0700 Subject: [PATCH] Rework shop to support multiple item types and add unified PlayerModel Generalize the shop from selling only ModifierDefinition to any IShopItem (modifiers + dice). Add purchase condition checks, hover tooltips, and fixed prices. Introduce PlayerModel as a facade over CurrencyBank, InventoryModel, and DiceCollection for centralized state and save/load. New files: - IShopItem interface for purchasable items - ShopCatalog SO (unified catalog for all item types) - ShopTooltipView (description on hover) - PlayerModel (aggregates player state) - DiceCollection (owned dice tracking) - DiceCatalog SO (dice definition registry) - DiceCollectionTests Modified: - ModifierDefinition/DieDefinitionSO implement IShopItem - ShopModel/ShopView/ShopItemView/ShopController use IShopItem - SaveData v3 with OwnedDiceIds - GameController uses PlayerModel for save/load - GameLifetimeScope registers new services Co-Authored-By: Claude Opus 4.6 --- Assets/Scripts/DI/GameLifetimeScope.cs | 14 +- Assets/Scripts/Dice/DiceCatalog.cs | 32 +++++ Assets/Scripts/Dice/DieDefinitionSO.cs | 15 +- .../Definition/ModifierDefinition.cs | 4 +- Assets/Scripts/Persistence/SaveData.cs | 3 +- Assets/Scripts/Player/DiceCollection.cs | 74 ++++++++++ Assets/Scripts/Player/PlayerModel.cs | 29 ++++ Assets/Scripts/Shop/IShopItem.cs | 23 +++ Assets/Scripts/Shop/ShopCatalog.cs | 45 ++++++ Assets/Scripts/Shop/ShopController.cs | 13 +- Assets/Scripts/Shop/ShopItemView.cs | 35 ++++- Assets/Scripts/Shop/ShopModel.cs | 51 ++++--- Assets/Scripts/Shop/ShopTooltipView.cs | 37 +++++ Assets/Scripts/Shop/ShopView.cs | 26 +++- .../Tests/Editor/DiceCollectionTests.cs | 133 ++++++++++++++++++ .../Scripts/Tests/Editor/SaveSystemTests.cs | 17 +++ Assets/Scripts/Tests/Editor/ShopModelTests.cs | 45 +++++- Assets/Scripts/UI/GameController.cs | 34 +++-- 18 files changed, 576 insertions(+), 54 deletions(-) create mode 100644 Assets/Scripts/Dice/DiceCatalog.cs create mode 100644 Assets/Scripts/Player/DiceCollection.cs create mode 100644 Assets/Scripts/Player/PlayerModel.cs create mode 100644 Assets/Scripts/Shop/IShopItem.cs create mode 100644 Assets/Scripts/Shop/ShopCatalog.cs create mode 100644 Assets/Scripts/Shop/ShopTooltipView.cs create mode 100644 Assets/Scripts/Tests/Editor/DiceCollectionTests.cs diff --git a/Assets/Scripts/DI/GameLifetimeScope.cs b/Assets/Scripts/DI/GameLifetimeScope.cs index b44a6f8..c993efc 100644 --- a/Assets/Scripts/DI/GameLifetimeScope.cs +++ b/Assets/Scripts/DI/GameLifetimeScope.cs @@ -2,6 +2,7 @@ using UnityEngine; using VContainer; using VContainer.Unity; using YachtDice.Categories; +using YachtDice.Dice; using YachtDice.Economy; using YachtDice.Events; using YachtDice.Game; @@ -9,6 +10,7 @@ using YachtDice.Inventory; using YachtDice.Modifiers.Definition; using YachtDice.Modifiers.Pipeline; using YachtDice.Modifiers.Runtime; +using YachtDice.Player; using YachtDice.Scoring; using YachtDice.Shop; using YachtDice.UI; @@ -19,6 +21,8 @@ namespace YachtDice.DI { [SerializeField] private ModifierCatalog modifierCatalog; [SerializeField] private CategoryCatalog categoryCatalog; + [SerializeField] private DiceCatalog diceCatalog; + [SerializeField] private ShopCatalog shopCatalog; [Header("Scene References")] [SerializeField] private ScoringSystem scoringSystem; @@ -37,6 +41,8 @@ namespace YachtDice.DI // SO catalogs builder.RegisterInstance(modifierCatalog); builder.RegisterInstance(categoryCatalog); + builder.RegisterInstance(diceCatalog); + builder.RegisterInstance(shopCatalog); // Core modifier services builder.Register(Lifetime.Singleton) @@ -44,8 +50,14 @@ namespace YachtDice.DI builder.Register(Lifetime.Singleton); builder.Register(Lifetime.Singleton); - // Domain models + // Player subsystems + builder.Register(Lifetime.Singleton); builder.Register(Lifetime.Singleton); + + // Unified player model + builder.Register(Lifetime.Singleton); + + // Shop builder.Register(Lifetime.Singleton); // Scene MonoBehaviour components diff --git a/Assets/Scripts/Dice/DiceCatalog.cs b/Assets/Scripts/Dice/DiceCatalog.cs new file mode 100644 index 0000000..664ee2e --- /dev/null +++ b/Assets/Scripts/Dice/DiceCatalog.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace YachtDice.Dice +{ + [CreateAssetMenu(fileName = "DiceCatalog", menuName = "YachtDice/Dice/Catalog")] + public class DiceCatalog : ScriptableObject + { + [SerializeField] private List dice = new(); + + public IReadOnlyList All => dice; + + public DieDefinitionSO FindById(string id) + { + for (int i = 0; i < dice.Count; i++) + { + if (dice[i] != null && dice[i].Id == id) + return dice[i]; + } + return null; + } + +#if UNITY_EDITOR + public static DiceCatalog CreateForTest(List defs) + { + var catalog = CreateInstance(); + catalog.dice = defs ?? new List(); + return catalog; + } +#endif + } +} diff --git a/Assets/Scripts/Dice/DieDefinitionSO.cs b/Assets/Scripts/Dice/DieDefinitionSO.cs index 789d755..4598e18 100644 --- a/Assets/Scripts/Dice/DieDefinitionSO.cs +++ b/Assets/Scripts/Dice/DieDefinitionSO.cs @@ -1,4 +1,5 @@ using UnityEngine; +using YachtDice.Shop; namespace YachtDice.Dice { @@ -6,16 +7,23 @@ namespace YachtDice.Dice /// Абстрактное определение типа дайса. /// Наследники описывают конкретные виды (стандартный d6, специальные и т.д.). /// - public abstract class DieDefinitionSO : ScriptableObject + public abstract class DieDefinitionSO : ScriptableObject, IShopItem { [Header("Identity")] [SerializeField] private string id; [SerializeField] private string displayName; + [SerializeField, TextArea] private string description; [SerializeField] private Sprite icon; + [Header("Economy")] + [SerializeField] private int shopPrice; + public string Id => id; public string DisplayName => displayName; + public string Description => description; public Sprite Icon => icon; + public int ShopPrice => shopPrice; + public bool IsRepurchasable => false; /// Количество граней. public abstract int FaceCount { get; } @@ -24,11 +32,14 @@ namespace YachtDice.Dice public abstract int[] GetFaceValues(); #if UNITY_EDITOR - public static T CreateForTest(string id, string displayName = null) where T : DieDefinitionSO + public static T CreateForTest(string id, string displayName = null, + int shopPrice = 0, string description = null) where T : DieDefinitionSO { var so = CreateInstance(); so.id = id; so.displayName = displayName ?? id; + so.description = description ?? id; + so.shopPrice = shopPrice; return so; } #endif diff --git a/Assets/Scripts/Modifiers/Definition/ModifierDefinition.cs b/Assets/Scripts/Modifiers/Definition/ModifierDefinition.cs index 7fb269f..65e13f3 100644 --- a/Assets/Scripts/Modifiers/Definition/ModifierDefinition.cs +++ b/Assets/Scripts/Modifiers/Definition/ModifierDefinition.cs @@ -1,11 +1,12 @@ using System.Collections.Generic; using UnityEngine; using YachtDice.Modifiers.Core; +using YachtDice.Shop; namespace YachtDice.Modifiers.Definition { [CreateAssetMenu(fileName = "NewModifier", menuName = "YachtDice/Modifiers/Definition")] - public class ModifierDefinition : ScriptableObject + public class ModifierDefinition : ScriptableObject, IShopItem { [Header("Identity")] [SerializeField] private string id; @@ -36,6 +37,7 @@ namespace YachtDice.Modifiers.Definition public bool HasLimitedUses => hasLimitedUses; public int MaxUses => maxUses; public int MaxStacks => maxStacks; + public bool IsRepurchasable => hasLimitedUses; public IReadOnlyList Behaviors => behaviors; #if UNITY_EDITOR diff --git a/Assets/Scripts/Persistence/SaveData.cs b/Assets/Scripts/Persistence/SaveData.cs index 8212af5..bea7eb1 100644 --- a/Assets/Scripts/Persistence/SaveData.cs +++ b/Assets/Scripts/Persistence/SaveData.cs @@ -7,8 +7,9 @@ namespace YachtDice.Persistence [Serializable] public sealed class SaveData { - public int Version = 2; + public int Version = 3; public int Currency; public List OwnedModifiers = new(); + public List OwnedDiceIds = new(); } } diff --git a/Assets/Scripts/Player/DiceCollection.cs b/Assets/Scripts/Player/DiceCollection.cs new file mode 100644 index 0000000..393fd78 --- /dev/null +++ b/Assets/Scripts/Player/DiceCollection.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using YachtDice.Dice; + +namespace YachtDice.Player +{ + public class DiceCollection + { + private readonly List ownedDice = new(); + + public event Action OnChanged; + + public IReadOnlyList OwnedDice => ownedDice; + + public void Add(DieDefinitionSO definition) + { + if (definition == null) return; + if (OwnsById(definition.Id)) return; + + ownedDice.Add(definition); + OnChanged?.Invoke(); + } + + public void Remove(DieDefinitionSO definition) + { + if (ownedDice.Remove(definition)) + OnChanged?.Invoke(); + } + + public bool OwnsById(string id) + { + for (int i = 0; i < ownedDice.Count; i++) + { + if (ownedDice[i] != null && ownedDice[i].Id == id) + return true; + } + return false; + } + + public List GetSaveData() + { + var ids = new List(); + for (int i = 0; i < ownedDice.Count; i++) + ids.Add(ownedDice[i].Id); + return ids; + } + + public void LoadSaveData(List diceIds, DiceCatalog catalog) + { + ownedDice.Clear(); + + if (diceIds == null) + { + OnChanged?.Invoke(); + return; + } + + for (int i = 0; i < diceIds.Count; i++) + { + var def = catalog.FindById(diceIds[i]); + if (def != null) + ownedDice.Add(def); + } + + OnChanged?.Invoke(); + } + + public void Clear() + { + ownedDice.Clear(); + OnChanged?.Invoke(); + } + } +} diff --git a/Assets/Scripts/Player/PlayerModel.cs b/Assets/Scripts/Player/PlayerModel.cs new file mode 100644 index 0000000..cb0550f --- /dev/null +++ b/Assets/Scripts/Player/PlayerModel.cs @@ -0,0 +1,29 @@ +using System; +using YachtDice.Economy; +using YachtDice.Inventory; + +namespace YachtDice.Player +{ + public class PlayerModel + { + public CurrencyBank Currency { get; } + public InventoryModel Inventory { get; } + public DiceCollection Dice { get; } + + public event Action OnChanged; + + public PlayerModel( + CurrencyBank currencyBank, + InventoryModel inventory, + DiceCollection diceCollection) + { + Currency = currencyBank; + Inventory = inventory; + Dice = diceCollection; + + currencyBank.OnBalanceChanged += _ => OnChanged?.Invoke(); + inventory.OnInventoryChanged += () => OnChanged?.Invoke(); + diceCollection.OnChanged += () => OnChanged?.Invoke(); + } + } +} diff --git a/Assets/Scripts/Shop/IShopItem.cs b/Assets/Scripts/Shop/IShopItem.cs new file mode 100644 index 0000000..c50d9eb --- /dev/null +++ b/Assets/Scripts/Shop/IShopItem.cs @@ -0,0 +1,23 @@ +using UnityEngine; + +namespace YachtDice.Shop +{ + /// + /// Any item that can appear in the shop. + /// Implemented by ScriptableObject definitions (ModifierDefinition, DieDefinitionSO). + /// + public interface IShopItem + { + string Id { get; } + string DisplayName { get; } + string Description { get; } + Sprite Icon { get; } + int ShopPrice { get; } + + /// + /// Whether this item can be repurchased after being owned (e.g. consumable modifiers). + /// If false, the shop marks it as "Owned" once purchased. + /// + bool IsRepurchasable { get; } + } +} diff --git a/Assets/Scripts/Shop/ShopCatalog.cs b/Assets/Scripts/Shop/ShopCatalog.cs new file mode 100644 index 0000000..d514e30 --- /dev/null +++ b/Assets/Scripts/Shop/ShopCatalog.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace YachtDice.Shop +{ + [CreateAssetMenu(fileName = "ShopCatalog", menuName = "YachtDice/Shop/Catalog")] + public class ShopCatalog : ScriptableObject + { + [SerializeField] private List items = new(); + + private List cachedItems; + + public IReadOnlyList All + { + get + { + if (cachedItems == null) RebuildCache(); + return cachedItems; + } + } + + public IShopItem FindById(string id) + { + var all = All; + for (int i = 0; i < all.Count; i++) + { + if (all[i] != null && all[i].Id == id) + return all[i]; + } + return null; + } + + private void RebuildCache() + { + cachedItems = new List(); + for (int i = 0; i < items.Count; i++) + { + if (items[i] is IShopItem shopItem) + cachedItems.Add(shopItem); + } + } + + private void OnValidate() => cachedItems = null; + } +} diff --git a/Assets/Scripts/Shop/ShopController.cs b/Assets/Scripts/Shop/ShopController.cs index 0e7654f..6dec997 100644 --- a/Assets/Scripts/Shop/ShopController.cs +++ b/Assets/Scripts/Shop/ShopController.cs @@ -1,7 +1,6 @@ using UnityEngine; using VContainer; using YachtDice.Economy; -using YachtDice.Modifiers.Definition; namespace YachtDice.Shop { @@ -9,14 +8,14 @@ namespace YachtDice.Shop { [SerializeField] private ShopView shopView; - private ModifierCatalog catalog; + private ShopCatalog catalog; private CurrencyBank currencyBank; private ShopModel model; - public ModifierCatalog Catalog => catalog; + public ShopCatalog Catalog => catalog; [Inject] - public void Construct(ModifierCatalog catalog, CurrencyBank currencyBank, ShopModel model) + public void Construct(ShopCatalog catalog, CurrencyBank currencyBank, ShopModel model) { this.catalog = catalog; this.currencyBank = currencyBank; @@ -55,9 +54,9 @@ namespace YachtDice.Shop shopView.Show(); } - private void HandleBuyClicked(ModifierDefinition def) + private void HandleBuyClicked(IShopItem item) { - model.TryPurchase(def); + model.TryPurchase(item); } private void HandleCurrencyChanged(int newBalance) @@ -66,7 +65,7 @@ namespace YachtDice.Shop shopView.RefreshStates(catalog.All, model); } - private void HandleItemPurchased(ModifierDefinition def) + private void HandleItemPurchased(IShopItem item) { shopView.RefreshStates(catalog.All, model); } diff --git a/Assets/Scripts/Shop/ShopItemView.cs b/Assets/Scripts/Shop/ShopItemView.cs index 0c1eb1a..df978f0 100644 --- a/Assets/Scripts/Shop/ShopItemView.cs +++ b/Assets/Scripts/Shop/ShopItemView.cs @@ -1,13 +1,14 @@ using System; using TMPro; using UnityEngine; +using UnityEngine.EventSystems; using UnityEngine.UI; using YachtDice.Modifiers.Core; using YachtDice.Modifiers.Definition; namespace YachtDice.Shop { - public class ShopItemView : MonoBehaviour + public class ShopItemView : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler { [SerializeField] private Image iconImage; [SerializeField] private TMP_Text nameText; @@ -24,9 +25,11 @@ namespace YachtDice.Shop [SerializeField] private Color rareColor = new(0.4f, 0.6f, 1f); [SerializeField] private Color epicColor = new(0.8f, 0.4f, 1f); - private ModifierDefinition data; + private IShopItem data; - public event Action OnBuyClicked; + public event Action OnBuyClicked; + public event Action OnHoverEnter; + public event Action OnHoverExit; private void Awake() { @@ -34,9 +37,9 @@ namespace YachtDice.Shop buyButton.onClick.AddListener(() => OnBuyClicked?.Invoke(data)); } - public void Setup(ModifierDefinition modifierDef, ShopItemState state) + public void Setup(IShopItem item, ShopItemState state) { - data = modifierDef; + data = item; if (nameText != null) nameText.text = data.DisplayName; if (descriptionText != null) descriptionText.text = data.Description; @@ -45,8 +48,16 @@ namespace YachtDice.Shop if (rarityText != null) { - rarityText.text = data.Rarity.ToString(); - rarityText.color = GetRarityColor(data.Rarity); + if (data is ModifierDefinition mod) + { + rarityText.gameObject.SetActive(true); + rarityText.text = mod.Rarity.ToString(); + rarityText.color = GetRarityColor(mod.Rarity); + } + else + { + rarityText.gameObject.SetActive(false); + } } SetState(state); @@ -77,6 +88,16 @@ namespace YachtDice.Shop } } + public void OnPointerEnter(PointerEventData eventData) + { + OnHoverEnter?.Invoke(data, transform as RectTransform); + } + + public void OnPointerExit(PointerEventData eventData) + { + OnHoverExit?.Invoke(); + } + private Color GetRarityColor(ModifierRarity rarity) { return rarity switch diff --git a/Assets/Scripts/Shop/ShopModel.cs b/Assets/Scripts/Shop/ShopModel.cs index 27a08c0..4334b1d 100644 --- a/Assets/Scripts/Shop/ShopModel.cs +++ b/Assets/Scripts/Shop/ShopModel.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using YachtDice.Dice; using YachtDice.Economy; using YachtDice.Inventory; using YachtDice.Modifiers.Definition; +using YachtDice.Player; namespace YachtDice.Shop { @@ -10,54 +12,65 @@ namespace YachtDice.Shop { private readonly CurrencyBank currencyBank; private readonly InventoryModel inventoryModel; + private readonly DiceCollection diceCollection; private readonly HashSet purchasedPermanentIds = new(); - public event Action OnItemPurchased; + public event Action OnItemPurchased; - public ShopModel(CurrencyBank currencyBank, InventoryModel inventoryModel) + public ShopModel(CurrencyBank currencyBank, InventoryModel inventoryModel, DiceCollection diceCollection) { this.currencyBank = currencyBank; this.inventoryModel = inventoryModel; + this.diceCollection = diceCollection; } - public bool CanPurchase(ModifierDefinition modifier) + public bool CanPurchase(IShopItem item) { - if (modifier == null) return false; - if (!currencyBank.CanAfford(modifier.ShopPrice)) return false; + if (item == null) return false; + if (!currencyBank.CanAfford(item.ShopPrice)) return false; - if (!modifier.HasLimitedUses && purchasedPermanentIds.Contains(modifier.Id)) + if (!item.IsRepurchasable && purchasedPermanentIds.Contains(item.Id)) return false; return true; } - public bool TryPurchase(ModifierDefinition modifier) + public bool TryPurchase(IShopItem item) { - if (!CanPurchase(modifier)) return false; + if (!CanPurchase(item)) return false; - if (!currencyBank.Spend(modifier.ShopPrice)) return false; + if (!currencyBank.Spend(item.ShopPrice)) return false; - if (!modifier.HasLimitedUses) - purchasedPermanentIds.Add(modifier.Id); + if (!item.IsRepurchasable) + purchasedPermanentIds.Add(item.Id); - inventoryModel.AddModifier(modifier); - OnItemPurchased?.Invoke(modifier); + switch (item) + { + case ModifierDefinition modifier: + inventoryModel.AddModifier(modifier); + break; + case DieDefinitionSO die: + diceCollection.Add(die); + break; + } + + OnItemPurchased?.Invoke(item); return true; } - public bool IsPermanentOwned(string modifierId) => purchasedPermanentIds.Contains(modifierId); + public bool IsPermanentOwned(string itemId) => purchasedPermanentIds.Contains(itemId); - public ShopItemState GetItemState(ModifierDefinition modifier) + public ShopItemState GetItemState(IShopItem item) { - if (modifier == null) return ShopItemState.TooExpensive; + if (item == null) return ShopItemState.TooExpensive; - if (!modifier.HasLimitedUses && purchasedPermanentIds.Contains(modifier.Id)) + if (!item.IsRepurchasable && purchasedPermanentIds.Contains(item.Id)) return ShopItemState.Owned; - if (!currencyBank.CanAfford(modifier.ShopPrice)) + if (!currencyBank.CanAfford(item.ShopPrice)) return ShopItemState.TooExpensive; - return modifier.HasLimitedUses + return item.IsRepurchasable ? ShopItemState.RepurchaseAvailable : ShopItemState.Available; } diff --git a/Assets/Scripts/Shop/ShopTooltipView.cs b/Assets/Scripts/Shop/ShopTooltipView.cs new file mode 100644 index 0000000..e288f86 --- /dev/null +++ b/Assets/Scripts/Shop/ShopTooltipView.cs @@ -0,0 +1,37 @@ +using TMPro; +using UnityEngine; + +namespace YachtDice.Shop +{ + public class ShopTooltipView : MonoBehaviour + { + [SerializeField] private TMP_Text titleText; + [SerializeField] private TMP_Text descriptionText; + [SerializeField] private TMP_Text priceText; + [SerializeField] private RectTransform panelRect; + [SerializeField] private Vector2 offset = new(10f, -10f); + + public void Show(IShopItem item, RectTransform anchor) + { + if (titleText != null) titleText.text = item.DisplayName; + if (descriptionText != null) descriptionText.text = item.Description; + if (priceText != null) priceText.text = $"Price: {item.ShopPrice}"; + + PositionNear(anchor); + gameObject.SetActive(true); + } + + public void Hide() + { + gameObject.SetActive(false); + } + + private void PositionNear(RectTransform anchor) + { + if (panelRect == null || anchor == null) return; + + Vector3 worldPos = anchor.position; + panelRect.position = worldPos + (Vector3)offset; + } + } +} diff --git a/Assets/Scripts/Shop/ShopView.cs b/Assets/Scripts/Shop/ShopView.cs index 639f490..586b447 100644 --- a/Assets/Scripts/Shop/ShopView.cs +++ b/Assets/Scripts/Shop/ShopView.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.UI; -using YachtDice.Modifiers.Definition; namespace YachtDice.Shop { @@ -13,10 +12,11 @@ namespace YachtDice.Shop [SerializeField] private ShopItemView itemPrefab; [SerializeField] private TMP_Text currencyText; [SerializeField] private Button closeButton; + [SerializeField] private ShopTooltipView tooltipView; private readonly List spawnedItems = new(); - public event Action OnBuyClicked; + public event Action OnBuyClicked; private void Awake() { @@ -34,7 +34,7 @@ namespace YachtDice.Shop public void Hide() => gameObject.SetActive(false); public bool IsVisible => gameObject.activeSelf; - public void Populate(IReadOnlyList catalog, ShopModel model) + public void Populate(IReadOnlyList catalog, ShopModel model) { ClearItems(); @@ -47,11 +47,13 @@ namespace YachtDice.Shop var state = model.GetItemState(def); item.Setup(def, state); item.OnBuyClicked += HandleBuy; + item.OnHoverEnter += HandleHoverEnter; + item.OnHoverExit += HandleHoverExit; spawnedItems.Add(item); } } - public void RefreshStates(IReadOnlyList catalog, ShopModel model) + public void RefreshStates(IReadOnlyList catalog, ShopModel model) { for (int i = 0; i < spawnedItems.Count && i < catalog.Count; i++) { @@ -71,11 +73,25 @@ namespace YachtDice.Shop for (int i = 0; i < spawnedItems.Count; i++) { spawnedItems[i].OnBuyClicked -= HandleBuy; + spawnedItems[i].OnHoverEnter -= HandleHoverEnter; + spawnedItems[i].OnHoverExit -= HandleHoverExit; Destroy(spawnedItems[i].gameObject); } spawnedItems.Clear(); } - private void HandleBuy(ModifierDefinition def) => OnBuyClicked?.Invoke(def); + private void HandleBuy(IShopItem item) => OnBuyClicked?.Invoke(item); + + private void HandleHoverEnter(IShopItem item, RectTransform anchor) + { + if (tooltipView != null) + tooltipView.Show(item, anchor); + } + + private void HandleHoverExit() + { + if (tooltipView != null) + tooltipView.Hide(); + } } } diff --git a/Assets/Scripts/Tests/Editor/DiceCollectionTests.cs b/Assets/Scripts/Tests/Editor/DiceCollectionTests.cs new file mode 100644 index 0000000..a085512 --- /dev/null +++ b/Assets/Scripts/Tests/Editor/DiceCollectionTests.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using NUnit.Framework; +using YachtDice.Dice; +using YachtDice.Player; + +namespace YachtDice.Tests +{ + public sealed class DiceCollectionTests + { + private DiceCollection collection; + + [SetUp] + public void SetUp() + { + collection = new DiceCollection(); + } + + [Test] + public void Add_IncreasesCount() + { + var die = StandardDieSO.CreateStandardD6ForTest(); + + collection.Add(die); + + Assert.AreEqual(1, collection.OwnedDice.Count); + } + + [Test] + public void Add_DuplicateId_Ignored() + { + var die = StandardDieSO.CreateStandardD6ForTest(); + + collection.Add(die); + collection.Add(die); + + Assert.AreEqual(1, collection.OwnedDice.Count); + } + + [Test] + public void Add_Null_Ignored() + { + collection.Add(null); + + Assert.AreEqual(0, collection.OwnedDice.Count); + } + + [Test] + public void OwnsById_ReturnsTrueWhenOwned() + { + var die = StandardDieSO.CreateStandardD6ForTest(); + + collection.Add(die); + + Assert.IsTrue(collection.OwnsById("standard_d6")); + } + + [Test] + public void OwnsById_ReturnsFalseWhenNotOwned() + { + Assert.IsFalse(collection.OwnsById("standard_d6")); + } + + [Test] + public void Remove_DecreasesCount() + { + var die = StandardDieSO.CreateStandardD6ForTest(); + + collection.Add(die); + collection.Remove(die); + + Assert.AreEqual(0, collection.OwnedDice.Count); + } + + [Test] + public void GetSaveData_ReturnsIds() + { + var die = StandardDieSO.CreateStandardD6ForTest(); + + collection.Add(die); + + var ids = collection.GetSaveData(); + Assert.AreEqual(1, ids.Count); + Assert.AreEqual("standard_d6", ids[0]); + } + + [Test] + public void LoadSaveData_RestoresDice() + { + var die = StandardDieSO.CreateStandardD6ForTest(); + var catalog = DiceCatalog.CreateForTest(new List { die }); + var ids = new List { "standard_d6" }; + + collection.LoadSaveData(ids, catalog); + + Assert.AreEqual(1, collection.OwnedDice.Count); + Assert.AreEqual("standard_d6", collection.OwnedDice[0].Id); + } + + [Test] + public void LoadSaveData_SkipsMissingIds() + { + var catalog = DiceCatalog.CreateForTest(new List()); + var ids = new List { "nonexistent" }; + + collection.LoadSaveData(ids, catalog); + + Assert.AreEqual(0, collection.OwnedDice.Count); + } + + [Test] + public void Clear_RemovesAll() + { + var die = StandardDieSO.CreateStandardD6ForTest(); + + collection.Add(die); + collection.Clear(); + + Assert.AreEqual(0, collection.OwnedDice.Count); + } + + [Test] + public void Add_FiresOnChanged() + { + bool fired = false; + collection.OnChanged += () => fired = true; + + var die = StandardDieSO.CreateStandardD6ForTest(); + collection.Add(die); + + Assert.IsTrue(fired); + } + } +} diff --git a/Assets/Scripts/Tests/Editor/SaveSystemTests.cs b/Assets/Scripts/Tests/Editor/SaveSystemTests.cs index ecfd1a3..12a2478 100644 --- a/Assets/Scripts/Tests/Editor/SaveSystemTests.cs +++ b/Assets/Scripts/Tests/Editor/SaveSystemTests.cs @@ -89,5 +89,22 @@ namespace YachtDice.Tests Assert.IsNotNull(loaded); Assert.AreEqual(0, loaded.Currency); } + + [Test] + public void SaveAndLoad_RoundTrip_PreservesDiceIds() + { + var data = new SaveData + { + Currency = 100, + OwnedDiceIds = new List { "standard_d6", "chaos_d6" } + }; + + SaveSystem.Save(data); + var loaded = SaveSystem.Load(); + + Assert.AreEqual(2, loaded.OwnedDiceIds.Count); + Assert.AreEqual("standard_d6", loaded.OwnedDiceIds[0]); + Assert.AreEqual("chaos_d6", loaded.OwnedDiceIds[1]); + } } } diff --git a/Assets/Scripts/Tests/Editor/ShopModelTests.cs b/Assets/Scripts/Tests/Editor/ShopModelTests.cs index 411d424..838f139 100644 --- a/Assets/Scripts/Tests/Editor/ShopModelTests.cs +++ b/Assets/Scripts/Tests/Editor/ShopModelTests.cs @@ -1,7 +1,9 @@ using NUnit.Framework; using UnityEngine; +using YachtDice.Dice; using YachtDice.Economy; using YachtDice.Inventory; +using YachtDice.Player; using YachtDice.Shop; using YachtDice.Modifiers.Definition; using YachtDice.Modifiers.Runtime; @@ -13,6 +15,7 @@ namespace YachtDice.Tests private CurrencyBank bank; private ModifierRegistry registry; private InventoryModel inventory; + private DiceCollection diceCollection; private ShopModel shop; [SetUp] @@ -24,7 +27,8 @@ namespace YachtDice.Tests registry = new ModifierRegistry(5); inventory = new InventoryModel(registry); - shop = new ShopModel(bank, inventory); + diceCollection = new DiceCollection(); + shop = new ShopModel(bank, inventory, diceCollection); } [TearDown] @@ -97,8 +101,8 @@ namespace YachtDice.Tests [Test] public void TryPurchase_FiresPurchaseEvent() { - ModifierDefinition purchased = null; - shop.OnItemPurchased += def => purchased = def; + IShopItem purchased = null; + shop.OnItemPurchased += item => purchased = item; var mod = CreateDef("test", shopPrice: 100); @@ -134,5 +138,40 @@ namespace YachtDice.Tests Assert.AreEqual(ShopItemState.Owned, shop.GetItemState(mod)); } + + [Test] + public void TryPurchase_DieItem_AddsToDiceCollection() + { + var die = DieDefinitionSO.CreateForTest("test_die", shopPrice: 100); + + bool result = shop.TryPurchase(die); + + Assert.IsTrue(result); + Assert.AreEqual(400, bank.Balance); + Assert.AreEqual(1, diceCollection.OwnedDice.Count); + } + + [Test] + public void TryPurchase_DieItem_CannotBeBoughtTwice() + { + var die = DieDefinitionSO.CreateForTest("unique_die", shopPrice: 100); + + shop.TryPurchase(die); + bool secondResult = shop.TryPurchase(die); + + Assert.IsFalse(secondResult); + Assert.AreEqual(400, bank.Balance); + Assert.AreEqual(1, diceCollection.OwnedDice.Count); + } + + [Test] + public void GetItemState_Die_Owned_AfterPurchase() + { + var die = DieDefinitionSO.CreateForTest("die1", shopPrice: 50); + + shop.TryPurchase(die); + + Assert.AreEqual(ShopItemState.Owned, shop.GetItemState(die)); + } } } diff --git a/Assets/Scripts/UI/GameController.cs b/Assets/Scripts/UI/GameController.cs index 1f0b316..9e033ed 100644 --- a/Assets/Scripts/UI/GameController.cs +++ b/Assets/Scripts/UI/GameController.cs @@ -9,6 +9,7 @@ using YachtDice.Economy; using YachtDice.Shop; using YachtDice.Inventory; using YachtDice.Persistence; +using YachtDice.Player; using YachtDice.Modifiers.Definition; using YachtDice.Modifiers.Runtime; @@ -36,7 +37,9 @@ namespace YachtDice.UI private ModifierRegistry modifierRegistry; private CategoryCatalog categoryCatalog; private ModifierCatalog modifierCatalog; + private DiceCatalog diceCatalog; private ShopModel shopModel; + private PlayerModel playerModel; [Inject] public void Construct( @@ -49,7 +52,9 @@ namespace YachtDice.UI ModifierRegistry modifierRegistry, CategoryCatalog categoryCatalog, ModifierCatalog modifierCatalog, - ShopModel shopModel) + DiceCatalog diceCatalog, + ShopModel shopModel, + PlayerModel playerModel) { this.gameManager = gameManager; this.scoringSystem = scoringSystem; @@ -60,7 +65,9 @@ namespace YachtDice.UI this.modifierRegistry = modifierRegistry; this.categoryCatalog = categoryCatalog; this.modifierCatalog = modifierCatalog; + this.diceCatalog = diceCatalog; this.shopModel = shopModel; + this.playerModel = playerModel; } // ── Lifecycle ────────────────────────────────────────────── @@ -82,9 +89,9 @@ namespace YachtDice.UI gameInfoView.OnShopClicked += HandleShopClicked; gameInfoView.OnInventoryClicked += HandleInventoryClicked; - // Currency & Modifiers + // Currency & Player state currencyBank.OnBalanceChanged += HandleCurrencyChanged; - modifierRegistry.OnChanged += HandleInventoryChangedForSave; + playerModel.OnChanged += HandlePlayerChangedForSave; // Initialize scoreCardView.Initialize(categoryCatalog); @@ -112,8 +119,8 @@ namespace YachtDice.UI currencyBank.OnBalanceChanged -= HandleCurrencyChanged; - if (modifierRegistry != null) - modifierRegistry.OnChanged -= HandleInventoryChangedForSave; + if (playerModel != null) + playerModel.OnChanged -= HandlePlayerChangedForSave; } // ── Save / Load ───────────────────────────────────────── @@ -157,13 +164,25 @@ namespace YachtDice.UI modifierRegistry.LoadSaveData(entries, modifierCatalog); shopModel.LoadPurchasedPermanentIds(permanentIds); } + + if (diceCatalog != null && save.OwnedDiceIds != null && save.OwnedDiceIds.Count > 0) + { + playerModel.Dice.LoadSaveData(save.OwnedDiceIds, diceCatalog); + + var dicePermIds = new HashSet(save.OwnedDiceIds); + var existingIds = shopModel.GetPurchasedPermanentIds(); + foreach (var id in dicePermIds) + existingIds.Add(id); + shopModel.LoadPurchasedPermanentIds(existingIds); + } } private void PerformSave() { var save = new SaveData { - Currency = currencyBank.Balance + Currency = currencyBank.Balance, + OwnedDiceIds = playerModel.Dice.GetSaveData(), }; var entries = modifierRegistry.GetSaveData(); @@ -274,10 +293,9 @@ namespace YachtDice.UI private void HandleCurrencyChanged(int newBalance) { gameInfoView.SetCurrencyText(newBalance); - PerformSave(); } - private void HandleInventoryChangedForSave() + private void HandlePlayerChangedForSave() { PerformSave(); }