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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user