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
+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 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);
}
+28 -7
View File
@@ -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
+32 -19
View File
@@ -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;
}
+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 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();
}
}
}