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:
@@ -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<ModifierRegistry>(Lifetime.Singleton)
|
||||
@@ -44,8 +50,14 @@ namespace YachtDice.DI
|
||||
builder.Register<ModifierPipeline>(Lifetime.Singleton);
|
||||
builder.Register<GameEventBus>(Lifetime.Singleton);
|
||||
|
||||
// Domain models
|
||||
// Player subsystems
|
||||
builder.Register<DiceCollection>(Lifetime.Singleton);
|
||||
builder.Register<InventoryModel>(Lifetime.Singleton);
|
||||
|
||||
// Unified player model
|
||||
builder.Register<PlayerModel>(Lifetime.Singleton);
|
||||
|
||||
// Shop
|
||||
builder.Register<ShopModel>(Lifetime.Singleton);
|
||||
|
||||
// Scene MonoBehaviour components
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using UnityEngine;
|
||||
using YachtDice.Shop;
|
||||
|
||||
namespace YachtDice.Dice
|
||||
{
|
||||
@@ -6,16 +7,23 @@ namespace YachtDice.Dice
|
||||
/// Абстрактное определение типа дайса.
|
||||
/// Наследники описывают конкретные виды (стандартный d6, специальные и т.д.).
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Количество граней.</summary>
|
||||
public abstract int FaceCount { get; }
|
||||
@@ -24,11 +32,14 @@ namespace YachtDice.Dice
|
||||
public abstract int[] GetFaceValues();
|
||||
|
||||
#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>();
|
||||
so.id = id;
|
||||
so.displayName = displayName ?? id;
|
||||
so.description = description ?? id;
|
||||
so.shopPrice = shopPrice;
|
||||
return so;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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<ModifierBehavior> Behaviors => behaviors;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
|
||||
@@ -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<ModifierSaveEntry> OwnedModifiers = new();
|
||||
public List<string> OwnedDiceIds = new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ModifierDefinition> OnBuyClicked;
|
||||
public event Action<IShopItem> OnBuyClicked;
|
||||
public event Action<IShopItem, RectTransform> 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
|
||||
|
||||
@@ -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<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.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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ShopItemView> spawnedItems = new();
|
||||
|
||||
public event Action<ModifierDefinition> OnBuyClicked;
|
||||
public event Action<IShopItem> 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<ModifierDefinition> catalog, ShopModel model)
|
||||
public void Populate(IReadOnlyList<IShopItem> 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<ModifierDefinition> catalog, ShopModel model)
|
||||
public void RefreshStates(IReadOnlyList<IShopItem> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>(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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user