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 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 21:04:34 +07:00
parent 85d639aa70
commit fcceb0ce45
18 changed files with 576 additions and 54 deletions
+13 -1
View File
@@ -2,6 +2,7 @@ using UnityEngine;
using VContainer; using VContainer;
using VContainer.Unity; using VContainer.Unity;
using YachtDice.Categories; using YachtDice.Categories;
using YachtDice.Dice;
using YachtDice.Economy; using YachtDice.Economy;
using YachtDice.Events; using YachtDice.Events;
using YachtDice.Game; using YachtDice.Game;
@@ -9,6 +10,7 @@ using YachtDice.Inventory;
using YachtDice.Modifiers.Definition; using YachtDice.Modifiers.Definition;
using YachtDice.Modifiers.Pipeline; using YachtDice.Modifiers.Pipeline;
using YachtDice.Modifiers.Runtime; using YachtDice.Modifiers.Runtime;
using YachtDice.Player;
using YachtDice.Scoring; using YachtDice.Scoring;
using YachtDice.Shop; using YachtDice.Shop;
using YachtDice.UI; using YachtDice.UI;
@@ -19,6 +21,8 @@ namespace YachtDice.DI
{ {
[SerializeField] private ModifierCatalog modifierCatalog; [SerializeField] private ModifierCatalog modifierCatalog;
[SerializeField] private CategoryCatalog categoryCatalog; [SerializeField] private CategoryCatalog categoryCatalog;
[SerializeField] private DiceCatalog diceCatalog;
[SerializeField] private ShopCatalog shopCatalog;
[Header("Scene References")] [Header("Scene References")]
[SerializeField] private ScoringSystem scoringSystem; [SerializeField] private ScoringSystem scoringSystem;
@@ -37,6 +41,8 @@ namespace YachtDice.DI
// SO catalogs // SO catalogs
builder.RegisterInstance(modifierCatalog); builder.RegisterInstance(modifierCatalog);
builder.RegisterInstance(categoryCatalog); builder.RegisterInstance(categoryCatalog);
builder.RegisterInstance(diceCatalog);
builder.RegisterInstance(shopCatalog);
// Core modifier services // Core modifier services
builder.Register<ModifierRegistry>(Lifetime.Singleton) builder.Register<ModifierRegistry>(Lifetime.Singleton)
@@ -44,8 +50,14 @@ namespace YachtDice.DI
builder.Register<ModifierPipeline>(Lifetime.Singleton); builder.Register<ModifierPipeline>(Lifetime.Singleton);
builder.Register<GameEventBus>(Lifetime.Singleton); builder.Register<GameEventBus>(Lifetime.Singleton);
// Domain models // Player subsystems
builder.Register<DiceCollection>(Lifetime.Singleton);
builder.Register<InventoryModel>(Lifetime.Singleton); builder.Register<InventoryModel>(Lifetime.Singleton);
// Unified player model
builder.Register<PlayerModel>(Lifetime.Singleton);
// Shop
builder.Register<ShopModel>(Lifetime.Singleton); builder.Register<ShopModel>(Lifetime.Singleton);
// Scene MonoBehaviour components // Scene MonoBehaviour components
+32
View File
@@ -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<DieDefinitionSO> dice = new();
public IReadOnlyList<DieDefinitionSO> 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<DieDefinitionSO> defs)
{
var catalog = CreateInstance<DiceCatalog>();
catalog.dice = defs ?? new List<DieDefinitionSO>();
return catalog;
}
#endif
}
}
+13 -2
View File
@@ -1,4 +1,5 @@
using UnityEngine; using UnityEngine;
using YachtDice.Shop;
namespace YachtDice.Dice namespace YachtDice.Dice
{ {
@@ -6,16 +7,23 @@ namespace YachtDice.Dice
/// Абстрактное определение типа дайса. /// Абстрактное определение типа дайса.
/// Наследники описывают конкретные виды (стандартный d6, специальные и т.д.). /// Наследники описывают конкретные виды (стандартный d6, специальные и т.д.).
/// </summary> /// </summary>
public abstract class DieDefinitionSO : ScriptableObject public abstract class DieDefinitionSO : ScriptableObject, IShopItem
{ {
[Header("Identity")] [Header("Identity")]
[SerializeField] private string id; [SerializeField] private string id;
[SerializeField] private string displayName; [SerializeField] private string displayName;
[SerializeField, TextArea] private string description;
[SerializeField] private Sprite icon; [SerializeField] private Sprite icon;
[Header("Economy")]
[SerializeField] private int shopPrice;
public string Id => id; public string Id => id;
public string DisplayName => displayName; public string DisplayName => displayName;
public string Description => description;
public Sprite Icon => icon; public Sprite Icon => icon;
public int ShopPrice => shopPrice;
public bool IsRepurchasable => false;
/// <summary>Количество граней.</summary> /// <summary>Количество граней.</summary>
public abstract int FaceCount { get; } public abstract int FaceCount { get; }
@@ -24,11 +32,14 @@ namespace YachtDice.Dice
public abstract int[] GetFaceValues(); public abstract int[] GetFaceValues();
#if UNITY_EDITOR #if UNITY_EDITOR
public static T CreateForTest<T>(string id, string displayName = null) where T : DieDefinitionSO public static T CreateForTest<T>(string id, string displayName = null,
int shopPrice = 0, string description = null) where T : DieDefinitionSO
{ {
var so = CreateInstance<T>(); var so = CreateInstance<T>();
so.id = id; so.id = id;
so.displayName = displayName ?? id; so.displayName = displayName ?? id;
so.description = description ?? id;
so.shopPrice = shopPrice;
return so; return so;
} }
#endif #endif
@@ -1,11 +1,12 @@
using System.Collections.Generic; using System.Collections.Generic;
using UnityEngine; using UnityEngine;
using YachtDice.Modifiers.Core; using YachtDice.Modifiers.Core;
using YachtDice.Shop;
namespace YachtDice.Modifiers.Definition namespace YachtDice.Modifiers.Definition
{ {
[CreateAssetMenu(fileName = "NewModifier", menuName = "YachtDice/Modifiers/Definition")] [CreateAssetMenu(fileName = "NewModifier", menuName = "YachtDice/Modifiers/Definition")]
public class ModifierDefinition : ScriptableObject public class ModifierDefinition : ScriptableObject, IShopItem
{ {
[Header("Identity")] [Header("Identity")]
[SerializeField] private string id; [SerializeField] private string id;
@@ -36,6 +37,7 @@ namespace YachtDice.Modifiers.Definition
public bool HasLimitedUses => hasLimitedUses; public bool HasLimitedUses => hasLimitedUses;
public int MaxUses => maxUses; public int MaxUses => maxUses;
public int MaxStacks => maxStacks; public int MaxStacks => maxStacks;
public bool IsRepurchasable => hasLimitedUses;
public IReadOnlyList<ModifierBehavior> Behaviors => behaviors; public IReadOnlyList<ModifierBehavior> Behaviors => behaviors;
#if UNITY_EDITOR #if UNITY_EDITOR
+2 -1
View File
@@ -7,8 +7,9 @@ namespace YachtDice.Persistence
[Serializable] [Serializable]
public sealed class SaveData public sealed class SaveData
{ {
public int Version = 2; public int Version = 3;
public int Currency; public int Currency;
public List<ModifierSaveEntry> OwnedModifiers = new(); public List<ModifierSaveEntry> OwnedModifiers = new();
public List<string> OwnedDiceIds = new();
} }
} }
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using YachtDice.Dice;
namespace YachtDice.Player
{
public class DiceCollection
{
private readonly List<DieDefinitionSO> ownedDice = new();
public event Action OnChanged;
public IReadOnlyList<DieDefinitionSO> 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<string> GetSaveData()
{
var ids = new List<string>();
for (int i = 0; i < ownedDice.Count; i++)
ids.Add(ownedDice[i].Id);
return ids;
}
public void LoadSaveData(List<string> 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();
}
}
}
+29
View File
@@ -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();
}
}
}
+23
View File
@@ -0,0 +1,23 @@
using UnityEngine;
namespace YachtDice.Shop
{
/// <summary>
/// Any item that can appear in the shop.
/// Implemented by ScriptableObject definitions (ModifierDefinition, DieDefinitionSO).
/// </summary>
public interface IShopItem
{
string Id { get; }
string DisplayName { get; }
string Description { get; }
Sprite Icon { get; }
int ShopPrice { get; }
/// <summary>
/// Whether this item can be repurchased after being owned (e.g. consumable modifiers).
/// If false, the shop marks it as "Owned" once purchased.
/// </summary>
bool IsRepurchasable { get; }
}
}
+45
View File
@@ -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<ScriptableObject> items = new();
private List<IShopItem> cachedItems;
public IReadOnlyList<IShopItem> 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<IShopItem>();
for (int i = 0; i < items.Count; i++)
{
if (items[i] is IShopItem shopItem)
cachedItems.Add(shopItem);
}
}
private void OnValidate() => cachedItems = null;
}
}
+6 -7
View File
@@ -1,7 +1,6 @@
using UnityEngine; using UnityEngine;
using VContainer; using VContainer;
using YachtDice.Economy; using YachtDice.Economy;
using YachtDice.Modifiers.Definition;
namespace YachtDice.Shop namespace YachtDice.Shop
{ {
@@ -9,14 +8,14 @@ namespace YachtDice.Shop
{ {
[SerializeField] private ShopView shopView; [SerializeField] private ShopView shopView;
private ModifierCatalog catalog; private ShopCatalog catalog;
private CurrencyBank currencyBank; private CurrencyBank currencyBank;
private ShopModel model; private ShopModel model;
public ModifierCatalog Catalog => catalog; public ShopCatalog Catalog => catalog;
[Inject] [Inject]
public void Construct(ModifierCatalog catalog, CurrencyBank currencyBank, ShopModel model) public void Construct(ShopCatalog catalog, CurrencyBank currencyBank, ShopModel model)
{ {
this.catalog = catalog; this.catalog = catalog;
this.currencyBank = currencyBank; this.currencyBank = currencyBank;
@@ -55,9 +54,9 @@ namespace YachtDice.Shop
shopView.Show(); shopView.Show();
} }
private void HandleBuyClicked(ModifierDefinition def) private void HandleBuyClicked(IShopItem item)
{ {
model.TryPurchase(def); model.TryPurchase(item);
} }
private void HandleCurrencyChanged(int newBalance) private void HandleCurrencyChanged(int newBalance)
@@ -66,7 +65,7 @@ namespace YachtDice.Shop
shopView.RefreshStates(catalog.All, model); shopView.RefreshStates(catalog.All, model);
} }
private void HandleItemPurchased(ModifierDefinition def) private void HandleItemPurchased(IShopItem item)
{ {
shopView.RefreshStates(catalog.All, model); shopView.RefreshStates(catalog.All, model);
} }
+28 -7
View File
@@ -1,13 +1,14 @@
using System; using System;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI; using UnityEngine.UI;
using YachtDice.Modifiers.Core; using YachtDice.Modifiers.Core;
using YachtDice.Modifiers.Definition; using YachtDice.Modifiers.Definition;
namespace YachtDice.Shop namespace YachtDice.Shop
{ {
public class ShopItemView : MonoBehaviour public class ShopItemView : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{ {
[SerializeField] private Image iconImage; [SerializeField] private Image iconImage;
[SerializeField] private TMP_Text nameText; [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 rareColor = new(0.4f, 0.6f, 1f);
[SerializeField] private Color epicColor = new(0.8f, 0.4f, 1f); [SerializeField] private Color epicColor = new(0.8f, 0.4f, 1f);
private ModifierDefinition data; private IShopItem data;
public event Action<ModifierDefinition> OnBuyClicked; public event Action<IShopItem> OnBuyClicked;
public event Action<IShopItem, RectTransform> OnHoverEnter;
public event Action OnHoverExit;
private void Awake() private void Awake()
{ {
@@ -34,9 +37,9 @@ namespace YachtDice.Shop
buyButton.onClick.AddListener(() => OnBuyClicked?.Invoke(data)); 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 (nameText != null) nameText.text = data.DisplayName;
if (descriptionText != null) descriptionText.text = data.Description; if (descriptionText != null) descriptionText.text = data.Description;
@@ -45,8 +48,16 @@ namespace YachtDice.Shop
if (rarityText != null) if (rarityText != null)
{ {
rarityText.text = data.Rarity.ToString(); if (data is ModifierDefinition mod)
rarityText.color = GetRarityColor(data.Rarity); {
rarityText.gameObject.SetActive(true);
rarityText.text = mod.Rarity.ToString();
rarityText.color = GetRarityColor(mod.Rarity);
}
else
{
rarityText.gameObject.SetActive(false);
}
} }
SetState(state); 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) private Color GetRarityColor(ModifierRarity rarity)
{ {
return rarity switch return rarity switch
+32 -19
View File
@@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using YachtDice.Dice;
using YachtDice.Economy; using YachtDice.Economy;
using YachtDice.Inventory; using YachtDice.Inventory;
using YachtDice.Modifiers.Definition; using YachtDice.Modifiers.Definition;
using YachtDice.Player;
namespace YachtDice.Shop namespace YachtDice.Shop
{ {
@@ -10,54 +12,65 @@ namespace YachtDice.Shop
{ {
private readonly CurrencyBank currencyBank; private readonly CurrencyBank currencyBank;
private readonly InventoryModel inventoryModel; private readonly InventoryModel inventoryModel;
private readonly DiceCollection diceCollection;
private readonly HashSet<string> purchasedPermanentIds = new(); private readonly HashSet<string> purchasedPermanentIds = new();
public event Action<ModifierDefinition> OnItemPurchased; public event Action<IShopItem> OnItemPurchased;
public ShopModel(CurrencyBank currencyBank, InventoryModel inventoryModel) public ShopModel(CurrencyBank currencyBank, InventoryModel inventoryModel, DiceCollection diceCollection)
{ {
this.currencyBank = currencyBank; this.currencyBank = currencyBank;
this.inventoryModel = inventoryModel; this.inventoryModel = inventoryModel;
this.diceCollection = diceCollection;
} }
public bool CanPurchase(ModifierDefinition modifier) public bool CanPurchase(IShopItem item)
{ {
if (modifier == null) return false; if (item == null) return false;
if (!currencyBank.CanAfford(modifier.ShopPrice)) 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 false;
return true; 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) if (!item.IsRepurchasable)
purchasedPermanentIds.Add(modifier.Id); purchasedPermanentIds.Add(item.Id);
inventoryModel.AddModifier(modifier); switch (item)
OnItemPurchased?.Invoke(modifier); {
case ModifierDefinition modifier:
inventoryModel.AddModifier(modifier);
break;
case DieDefinitionSO die:
diceCollection.Add(die);
break;
}
OnItemPurchased?.Invoke(item);
return true; 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; return ShopItemState.Owned;
if (!currencyBank.CanAfford(modifier.ShopPrice)) if (!currencyBank.CanAfford(item.ShopPrice))
return ShopItemState.TooExpensive; return ShopItemState.TooExpensive;
return modifier.HasLimitedUses return item.IsRepurchasable
? ShopItemState.RepurchaseAvailable ? ShopItemState.RepurchaseAvailable
: ShopItemState.Available; : ShopItemState.Available;
} }
+37
View File
@@ -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;
}
}
}
+21 -5
View File
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using YachtDice.Modifiers.Definition;
namespace YachtDice.Shop namespace YachtDice.Shop
{ {
@@ -13,10 +12,11 @@ namespace YachtDice.Shop
[SerializeField] private ShopItemView itemPrefab; [SerializeField] private ShopItemView itemPrefab;
[SerializeField] private TMP_Text currencyText; [SerializeField] private TMP_Text currencyText;
[SerializeField] private Button closeButton; [SerializeField] private Button closeButton;
[SerializeField] private ShopTooltipView tooltipView;
private readonly List<ShopItemView> spawnedItems = new(); private readonly List<ShopItemView> spawnedItems = new();
public event Action<ModifierDefinition> OnBuyClicked; public event Action<IShopItem> OnBuyClicked;
private void Awake() private void Awake()
{ {
@@ -34,7 +34,7 @@ namespace YachtDice.Shop
public void Hide() => gameObject.SetActive(false); public void Hide() => gameObject.SetActive(false);
public bool IsVisible => gameObject.activeSelf; public bool IsVisible => gameObject.activeSelf;
public void Populate(IReadOnlyList<ModifierDefinition> catalog, ShopModel model) public void Populate(IReadOnlyList<IShopItem> catalog, ShopModel model)
{ {
ClearItems(); ClearItems();
@@ -47,11 +47,13 @@ namespace YachtDice.Shop
var state = model.GetItemState(def); var state = model.GetItemState(def);
item.Setup(def, state); item.Setup(def, state);
item.OnBuyClicked += HandleBuy; item.OnBuyClicked += HandleBuy;
item.OnHoverEnter += HandleHoverEnter;
item.OnHoverExit += HandleHoverExit;
spawnedItems.Add(item); spawnedItems.Add(item);
} }
} }
public void RefreshStates(IReadOnlyList<ModifierDefinition> catalog, ShopModel model) public void RefreshStates(IReadOnlyList<IShopItem> catalog, ShopModel model)
{ {
for (int i = 0; i < spawnedItems.Count && i < catalog.Count; i++) 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++) for (int i = 0; i < spawnedItems.Count; i++)
{ {
spawnedItems[i].OnBuyClicked -= HandleBuy; spawnedItems[i].OnBuyClicked -= HandleBuy;
spawnedItems[i].OnHoverEnter -= HandleHoverEnter;
spawnedItems[i].OnHoverExit -= HandleHoverExit;
Destroy(spawnedItems[i].gameObject); Destroy(spawnedItems[i].gameObject);
} }
spawnedItems.Clear(); 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();
}
} }
} }
@@ -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<DieDefinitionSO> { die });
var ids = new List<string> { "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<DieDefinitionSO>());
var ids = new List<string> { "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);
}
}
}
@@ -89,5 +89,22 @@ namespace YachtDice.Tests
Assert.IsNotNull(loaded); Assert.IsNotNull(loaded);
Assert.AreEqual(0, loaded.Currency); Assert.AreEqual(0, loaded.Currency);
} }
[Test]
public void SaveAndLoad_RoundTrip_PreservesDiceIds()
{
var data = new SaveData
{
Currency = 100,
OwnedDiceIds = new List<string> { "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]);
}
} }
} }
+42 -3
View File
@@ -1,7 +1,9 @@
using NUnit.Framework; using NUnit.Framework;
using UnityEngine; using UnityEngine;
using YachtDice.Dice;
using YachtDice.Economy; using YachtDice.Economy;
using YachtDice.Inventory; using YachtDice.Inventory;
using YachtDice.Player;
using YachtDice.Shop; using YachtDice.Shop;
using YachtDice.Modifiers.Definition; using YachtDice.Modifiers.Definition;
using YachtDice.Modifiers.Runtime; using YachtDice.Modifiers.Runtime;
@@ -13,6 +15,7 @@ namespace YachtDice.Tests
private CurrencyBank bank; private CurrencyBank bank;
private ModifierRegistry registry; private ModifierRegistry registry;
private InventoryModel inventory; private InventoryModel inventory;
private DiceCollection diceCollection;
private ShopModel shop; private ShopModel shop;
[SetUp] [SetUp]
@@ -24,7 +27,8 @@ namespace YachtDice.Tests
registry = new ModifierRegistry(5); registry = new ModifierRegistry(5);
inventory = new InventoryModel(registry); inventory = new InventoryModel(registry);
shop = new ShopModel(bank, inventory); diceCollection = new DiceCollection();
shop = new ShopModel(bank, inventory, diceCollection);
} }
[TearDown] [TearDown]
@@ -97,8 +101,8 @@ namespace YachtDice.Tests
[Test] [Test]
public void TryPurchase_FiresPurchaseEvent() public void TryPurchase_FiresPurchaseEvent()
{ {
ModifierDefinition purchased = null; IShopItem purchased = null;
shop.OnItemPurchased += def => purchased = def; shop.OnItemPurchased += item => purchased = item;
var mod = CreateDef("test", shopPrice: 100); var mod = CreateDef("test", shopPrice: 100);
@@ -134,5 +138,40 @@ namespace YachtDice.Tests
Assert.AreEqual(ShopItemState.Owned, shop.GetItemState(mod)); Assert.AreEqual(ShopItemState.Owned, shop.GetItemState(mod));
} }
[Test]
public void TryPurchase_DieItem_AddsToDiceCollection()
{
var die = DieDefinitionSO.CreateForTest<StandardDieSO>("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<StandardDieSO>("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<StandardDieSO>("die1", shopPrice: 50);
shop.TryPurchase(die);
Assert.AreEqual(ShopItemState.Owned, shop.GetItemState(die));
}
} }
} }
+26 -8
View File
@@ -9,6 +9,7 @@ using YachtDice.Economy;
using YachtDice.Shop; using YachtDice.Shop;
using YachtDice.Inventory; using YachtDice.Inventory;
using YachtDice.Persistence; using YachtDice.Persistence;
using YachtDice.Player;
using YachtDice.Modifiers.Definition; using YachtDice.Modifiers.Definition;
using YachtDice.Modifiers.Runtime; using YachtDice.Modifiers.Runtime;
@@ -36,7 +37,9 @@ namespace YachtDice.UI
private ModifierRegistry modifierRegistry; private ModifierRegistry modifierRegistry;
private CategoryCatalog categoryCatalog; private CategoryCatalog categoryCatalog;
private ModifierCatalog modifierCatalog; private ModifierCatalog modifierCatalog;
private DiceCatalog diceCatalog;
private ShopModel shopModel; private ShopModel shopModel;
private PlayerModel playerModel;
[Inject] [Inject]
public void Construct( public void Construct(
@@ -49,7 +52,9 @@ namespace YachtDice.UI
ModifierRegistry modifierRegistry, ModifierRegistry modifierRegistry,
CategoryCatalog categoryCatalog, CategoryCatalog categoryCatalog,
ModifierCatalog modifierCatalog, ModifierCatalog modifierCatalog,
ShopModel shopModel) DiceCatalog diceCatalog,
ShopModel shopModel,
PlayerModel playerModel)
{ {
this.gameManager = gameManager; this.gameManager = gameManager;
this.scoringSystem = scoringSystem; this.scoringSystem = scoringSystem;
@@ -60,7 +65,9 @@ namespace YachtDice.UI
this.modifierRegistry = modifierRegistry; this.modifierRegistry = modifierRegistry;
this.categoryCatalog = categoryCatalog; this.categoryCatalog = categoryCatalog;
this.modifierCatalog = modifierCatalog; this.modifierCatalog = modifierCatalog;
this.diceCatalog = diceCatalog;
this.shopModel = shopModel; this.shopModel = shopModel;
this.playerModel = playerModel;
} }
// ── Lifecycle ────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────
@@ -82,9 +89,9 @@ namespace YachtDice.UI
gameInfoView.OnShopClicked += HandleShopClicked; gameInfoView.OnShopClicked += HandleShopClicked;
gameInfoView.OnInventoryClicked += HandleInventoryClicked; gameInfoView.OnInventoryClicked += HandleInventoryClicked;
// Currency & Modifiers // Currency & Player state
currencyBank.OnBalanceChanged += HandleCurrencyChanged; currencyBank.OnBalanceChanged += HandleCurrencyChanged;
modifierRegistry.OnChanged += HandleInventoryChangedForSave; playerModel.OnChanged += HandlePlayerChangedForSave;
// Initialize // Initialize
scoreCardView.Initialize(categoryCatalog); scoreCardView.Initialize(categoryCatalog);
@@ -112,8 +119,8 @@ namespace YachtDice.UI
currencyBank.OnBalanceChanged -= HandleCurrencyChanged; currencyBank.OnBalanceChanged -= HandleCurrencyChanged;
if (modifierRegistry != null) if (playerModel != null)
modifierRegistry.OnChanged -= HandleInventoryChangedForSave; playerModel.OnChanged -= HandlePlayerChangedForSave;
} }
// ── Save / Load ───────────────────────────────────────── // ── Save / Load ─────────────────────────────────────────
@@ -157,13 +164,25 @@ namespace YachtDice.UI
modifierRegistry.LoadSaveData(entries, modifierCatalog); modifierRegistry.LoadSaveData(entries, modifierCatalog);
shopModel.LoadPurchasedPermanentIds(permanentIds); shopModel.LoadPurchasedPermanentIds(permanentIds);
} }
if (diceCatalog != null && save.OwnedDiceIds != null && save.OwnedDiceIds.Count > 0)
{
playerModel.Dice.LoadSaveData(save.OwnedDiceIds, diceCatalog);
var dicePermIds = new HashSet<string>(save.OwnedDiceIds);
var existingIds = shopModel.GetPurchasedPermanentIds();
foreach (var id in dicePermIds)
existingIds.Add(id);
shopModel.LoadPurchasedPermanentIds(existingIds);
}
} }
private void PerformSave() private void PerformSave()
{ {
var save = new SaveData var save = new SaveData
{ {
Currency = currencyBank.Balance Currency = currencyBank.Balance,
OwnedDiceIds = playerModel.Dice.GetSaveData(),
}; };
var entries = modifierRegistry.GetSaveData(); var entries = modifierRegistry.GetSaveData();
@@ -274,10 +293,9 @@ namespace YachtDice.UI
private void HandleCurrencyChanged(int newBalance) private void HandleCurrencyChanged(int newBalance)
{ {
gameInfoView.SetCurrencyText(newBalance); gameInfoView.SetCurrencyText(newBalance);
PerformSave();
} }
private void HandleInventoryChangedForSave() private void HandlePlayerChangedForSave()
{ {
PerformSave(); PerformSave();
} }