diff --git a/Assets/Editor/ModifierAssetCreator.cs b/Assets/Editor/ModifierAssetCreator.cs new file mode 100644 index 0000000..36e667f --- /dev/null +++ b/Assets/Editor/ModifierAssetCreator.cs @@ -0,0 +1,123 @@ +#if UNITY_EDITOR +using UnityEditor; +using UnityEngine; + +public static class ModifierAssetCreator +{ + private const string BasePath = "Assets/ScriptableObjects/Modifiers"; + private const string CatalogPath = "Assets/ScriptableObjects"; + + [MenuItem("YachtDice/Create Example Modifiers + Catalog")] + public static void CreateAll() + { + EnsureFolder(BasePath); + EnsureFolder(CatalogPath); + + var m1 = CreateModifier("BonusPerOne", "Bonus Per One", + "+10 за каждый кубик со значением 1", + ModifierRarity.Common, 100, 50, + ModifierScope.SelectedCategory, ModifierEffectType.AddPerDieValue, + 10f, 1, YachtCategory.Ones, false, + ModifierDurability.Permanent, 0); + + var m2 = CreateModifier("MultiplierPerSix", "Multiplier Per Six", + "x6 за каждый кубик со значением 6", + ModifierRarity.Rare, 200, 100, + ModifierScope.SelectedCategory, ModifierEffectType.MultiplyPerDieValue, + 6f, 6, YachtCategory.Ones, false, + ModifierDurability.Permanent, 0); + + var m3 = CreateModifier("FullHouseFlat", "Full House Flat Bonus", + "+15 при закрытии Full House", + ModifierRarity.Uncommon, 150, 75, + ModifierScope.SelectedCategory, ModifierEffectType.AddFlatToFinalScore, + 15f, 0, YachtCategory.FullHouse, true, + ModifierDurability.Permanent, 0); + + var m4 = CreateModifier("YachtDoubler", "Yacht Doubler", + "x2 при закрытии Yacht (3 использования)", + ModifierRarity.Epic, 300, 150, + ModifierScope.SelectedCategory, ModifierEffectType.MultiplyFinalScore, + 2f, 0, YachtCategory.Yacht, true, + ModifierDurability.LimitedUses, 3); + + var m5 = CreateModifier("FiveBonusGlobal", "Five Bonus Global", + "+5 за каждую пятёрку при закрытии любой категории", + ModifierRarity.Uncommon, 250, 125, + ModifierScope.AnyCategoryClosed, ModifierEffectType.AddPerDieValue, + 5f, 5, YachtCategory.Ones, false, + ModifierDurability.Permanent, 0); + + var m6 = CreateModifier("CloseMultiplier", "Close Multiplier", + "x1.1 при закрытии любой категории (5 использований)", + ModifierRarity.Rare, 350, 175, + ModifierScope.AnyCategoryClosed, ModifierEffectType.MultiplyFinalScore, + 1.1f, 0, YachtCategory.Ones, false, + ModifierDurability.LimitedUses, 5); + + // Create ShopCatalog + var catalog = ScriptableObject.CreateInstance(); + var catalogSO = new SerializedObject(catalog); + var listProp = catalogSO.FindProperty("availableModifiers"); + listProp.arraySize = 6; + listProp.GetArrayElementAtIndex(0).objectReferenceValue = m1; + listProp.GetArrayElementAtIndex(1).objectReferenceValue = m2; + listProp.GetArrayElementAtIndex(2).objectReferenceValue = m3; + listProp.GetArrayElementAtIndex(3).objectReferenceValue = m4; + listProp.GetArrayElementAtIndex(4).objectReferenceValue = m5; + listProp.GetArrayElementAtIndex(5).objectReferenceValue = m6; + catalogSO.ApplyModifiedProperties(); + AssetDatabase.CreateAsset(catalog, $"{CatalogPath}/ShopCatalog.asset"); + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Debug.Log("Created 6 example modifiers and ShopCatalog."); + } + + private static ModifierData CreateModifier( + string id, string displayName, string description, + ModifierRarity rarity, int shopPrice, int sellPrice, + ModifierScope scope, ModifierEffectType effectType, + float effectValue, int dieValue, YachtCategory targetCategory, bool hasCategoryFilter, + ModifierDurability durability, int maxUses) + { + var data = ScriptableObject.CreateInstance(); + + var so = new SerializedObject(data); + so.FindProperty("id").stringValue = id; + so.FindProperty("displayName").stringValue = displayName; + so.FindProperty("description").stringValue = description; + so.FindProperty("rarity").enumValueIndex = (int)rarity; + so.FindProperty("shopPrice").intValue = shopPrice; + so.FindProperty("sellPrice").intValue = sellPrice; + so.FindProperty("scope").enumValueIndex = (int)scope; + so.FindProperty("effectType").enumValueIndex = (int)effectType; + so.FindProperty("effectValue").floatValue = effectValue; + so.FindProperty("durability").enumValueIndex = (int)durability; + so.FindProperty("maxUses").intValue = maxUses; + + var targetProp = so.FindProperty("target"); + targetProp.FindPropertyRelative("DieValue").intValue = dieValue; + targetProp.FindPropertyRelative("TargetCategory").enumValueIndex = (int)targetCategory; + targetProp.FindPropertyRelative("HasCategoryFilter").boolValue = hasCategoryFilter; + + so.ApplyModifiedProperties(); + + AssetDatabase.CreateAsset(data, $"{BasePath}/{id}.asset"); + return data; + } + + private static void EnsureFolder(string path) + { + string[] parts = path.Split('/'); + string current = parts[0]; + for (int i = 1; i < parts.Length; i++) + { + string next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) + AssetDatabase.CreateFolder(current, parts[i]); + current = next; + } + } +} +#endif diff --git a/Assets/Scripts/Economy/CurrencyBank.cs b/Assets/Scripts/Economy/CurrencyBank.cs new file mode 100644 index 0000000..27cb60a --- /dev/null +++ b/Assets/Scripts/Economy/CurrencyBank.cs @@ -0,0 +1,44 @@ +using System; +using UnityEngine; + +public sealed class CurrencyBank : MonoBehaviour +{ + [SerializeField] private int startingBalance = 500; + + private int balance; + + public int Balance => balance; + + public event Action OnBalanceChanged; + + private void Awake() + { + balance = startingBalance; + } + + public void Add(int amount) + { + if (amount <= 0) return; + + balance += amount; + OnBalanceChanged?.Invoke(balance); + } + + public bool Spend(int amount) + { + if (amount <= 0) return false; + if (balance < amount) return false; + + balance -= amount; + OnBalanceChanged?.Invoke(balance); + return true; + } + + public bool CanAfford(int amount) => balance >= amount; + + public void SetBalance(int value) + { + balance = Mathf.Max(0, value); + OnBalanceChanged?.Invoke(balance); + } +} diff --git a/Assets/Scripts/Inventory/InventoryController.cs b/Assets/Scripts/Inventory/InventoryController.cs new file mode 100644 index 0000000..b87ecbb --- /dev/null +++ b/Assets/Scripts/Inventory/InventoryController.cs @@ -0,0 +1,91 @@ +using UnityEngine; + +public sealed class InventoryController : MonoBehaviour +{ + [SerializeField] private InventoryView inventoryView; + [SerializeField] private ScoringSystem scoringSystem; + [SerializeField] private CurrencyBank currencyBank; + + private InventoryModel model; + + public InventoryModel Model => model; + + public void Initialize(InventoryModel inventoryModel) + { + model = inventoryModel; + + inventoryView.OnActivateClicked += HandleActivate; + inventoryView.OnDeactivateClicked += HandleDeactivate; + inventoryView.OnSellClicked += HandleSell; + + model.OnActiveModifiersChanged += HandleActiveModifiersChanged; + model.OnInventoryChanged += HandleInventoryChanged; + + if (scoringSystem != null) + scoringSystem.OnCategoryConfirmed += HandleCategoryConfirmed; + + RefreshView(); + } + + private void OnDestroy() + { + if (inventoryView != null) + { + inventoryView.OnActivateClicked -= HandleActivate; + inventoryView.OnDeactivateClicked -= HandleDeactivate; + inventoryView.OnSellClicked -= HandleSell; + } + + if (model != null) + { + model.OnActiveModifiersChanged -= HandleActiveModifiersChanged; + model.OnInventoryChanged -= HandleInventoryChanged; + } + + if (scoringSystem != null) + scoringSystem.OnCategoryConfirmed -= HandleCategoryConfirmed; + } + + private void HandleActivate(ModifierRuntime runtime) + { + model.TryActivate(runtime); + } + + private void HandleDeactivate(ModifierRuntime runtime) + { + model.Deactivate(runtime); + } + + private void HandleSell(ModifierRuntime runtime) + { + if (runtime.Data == null) return; + + int sellPrice = runtime.Data.SellPrice; + model.RemoveModifier(runtime); + + if (currencyBank != null) + currencyBank.Add(sellPrice); + } + + private void HandleActiveModifiersChanged(System.Collections.Generic.List activeModifiers) + { + if (scoringSystem != null) + scoringSystem.SetActiveModifiers(activeModifiers); + } + + private void HandleInventoryChanged() + { + RefreshView(); + } + + private void HandleCategoryConfirmed(YachtCategory category, ScoreResult result) + { + model.ConsumeUseOnActive(); + } + + private void RefreshView() + { + if (inventoryView != null && model != null) + inventoryView.Refresh(model.OwnedModifiers, model.MaxActiveSlots); + } +} diff --git a/Assets/Scripts/Inventory/InventoryModel.cs b/Assets/Scripts/Inventory/InventoryModel.cs new file mode 100644 index 0000000..4c521a6 --- /dev/null +++ b/Assets/Scripts/Inventory/InventoryModel.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; + +public sealed class InventoryModel +{ + private readonly List ownedModifiers = new(); + private int maxActiveSlots; + + public IReadOnlyList OwnedModifiers => ownedModifiers; + public int MaxActiveSlots => maxActiveSlots; + + public event Action OnInventoryChanged; + public event Action> OnActiveModifiersChanged; + + public InventoryModel(int maxActiveSlots = 5) + { + this.maxActiveSlots = maxActiveSlots; + } + + public int ActiveCount + { + get + { + int count = 0; + for (int i = 0; i < ownedModifiers.Count; i++) + if (ownedModifiers[i].IsActive) count++; + return count; + } + } + + public void SetMaxActiveSlots(int slots) + { + maxActiveSlots = slots; + } + + public void AddModifier(ModifierData data) + { + var runtime = ModifierRuntime.Create(data); + ownedModifiers.Add(runtime); + OnInventoryChanged?.Invoke(); + } + + public void RemoveModifier(ModifierRuntime modifier) + { + if (!ownedModifiers.Contains(modifier)) return; + + if (modifier.IsActive) + { + modifier.IsActive = false; + OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); + } + + ownedModifiers.Remove(modifier); + OnInventoryChanged?.Invoke(); + } + + public bool TryActivate(ModifierRuntime modifier) + { + if (modifier.IsActive) return false; + if (!ownedModifiers.Contains(modifier)) return false; + if (ActiveCount >= maxActiveSlots) return false; + + modifier.IsActive = true; + OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); + OnInventoryChanged?.Invoke(); + return true; + } + + public void Deactivate(ModifierRuntime modifier) + { + if (!modifier.IsActive) return; + + modifier.IsActive = false; + OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); + OnInventoryChanged?.Invoke(); + } + + public void ConsumeUseOnActive() + { + bool changed = false; + + for (int i = ownedModifiers.Count - 1; i >= 0; i--) + { + var mod = ownedModifiers[i]; + if (!mod.IsActive) continue; + if (mod.Data == null) continue; + if (mod.Data.Durability != ModifierDurability.LimitedUses) continue; + + mod.ConsumeUse(); + + if (mod.IsExpired) + { + ownedModifiers.RemoveAt(i); + changed = true; + } + } + + if (changed) + { + OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); + OnInventoryChanged?.Invoke(); + } + } + + public List GetActiveModifierData() + { + var result = new List(); + for (int i = 0; i < ownedModifiers.Count; i++) + { + if (ownedModifiers[i].IsActive && ownedModifiers[i].Data != null) + result.Add(ownedModifiers[i].Data); + } + return result; + } + + public void LoadState(List loaded) + { + ownedModifiers.Clear(); + if (loaded != null) + ownedModifiers.AddRange(loaded); + + OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); + OnInventoryChanged?.Invoke(); + } + + public List GetAllForSave() => new(ownedModifiers); +} diff --git a/Assets/Scripts/Inventory/InventorySlotView.cs b/Assets/Scripts/Inventory/InventorySlotView.cs new file mode 100644 index 0000000..64e2ef0 --- /dev/null +++ b/Assets/Scripts/Inventory/InventorySlotView.cs @@ -0,0 +1,78 @@ +using System; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +public sealed class InventorySlotView : MonoBehaviour +{ + [SerializeField] private Image iconImage; + [SerializeField] private TMP_Text nameText; + [SerializeField] private TMP_Text descriptionText; + [SerializeField] private TMP_Text usesText; + [SerializeField] private Button activateButton; + [SerializeField] private Button deactivateButton; + [SerializeField] private Button sellButton; + [SerializeField] private TMP_Text sellPriceText; + [SerializeField] private Image background; + + [Header("Colors")] + [SerializeField] private Color activeColor = new(0.7f, 1f, 0.7f); + [SerializeField] private Color inactiveColor = Color.white; + + private ModifierRuntime runtime; + + public event Action OnActivateClicked; + public event Action OnDeactivateClicked; + public event Action OnSellClicked; + + private void Awake() + { + if (activateButton != null) + activateButton.onClick.AddListener(() => OnActivateClicked?.Invoke(runtime)); + if (deactivateButton != null) + deactivateButton.onClick.AddListener(() => OnDeactivateClicked?.Invoke(runtime)); + if (sellButton != null) + sellButton.onClick.AddListener(() => OnSellClicked?.Invoke(runtime)); + } + + public void Setup(ModifierRuntime modifierRuntime, bool canActivateMore) + { + runtime = modifierRuntime; + var data = runtime.Data; + + if (data == null) return; + + if (nameText != null) nameText.text = data.DisplayName; + if (descriptionText != null) descriptionText.text = data.Description; + if (iconImage != null && data.Icon != null) iconImage.sprite = data.Icon; + + if (usesText != null) + { + if (data.Durability == ModifierDurability.LimitedUses) + { + usesText.gameObject.SetActive(true); + usesText.text = $"{runtime.RemainingUses}/{data.MaxUses}"; + } + else + { + usesText.gameObject.SetActive(false); + } + } + + if (sellPriceText != null) sellPriceText.text = data.SellPrice.ToString(); + + bool isActive = runtime.IsActive; + + if (activateButton != null) + { + activateButton.gameObject.SetActive(!isActive); + activateButton.interactable = canActivateMore; + } + + if (deactivateButton != null) + deactivateButton.gameObject.SetActive(isActive); + + if (background != null) + background.color = isActive ? activeColor : inactiveColor; + } +} diff --git a/Assets/Scripts/Inventory/InventoryView.cs b/Assets/Scripts/Inventory/InventoryView.cs new file mode 100644 index 0000000..8468a9c --- /dev/null +++ b/Assets/Scripts/Inventory/InventoryView.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +public sealed class InventoryView : MonoBehaviour +{ + [SerializeField] private Transform slotContainer; + [SerializeField] private InventorySlotView slotPrefab; + [SerializeField] private TMP_Text slotCountText; + [SerializeField] private Button closeButton; + + private readonly List spawnedSlots = new(); + + public event Action OnActivateClicked; + public event Action OnDeactivateClicked; + public event Action OnSellClicked; + + private void Awake() + { + if (closeButton != null) + closeButton.onClick.AddListener(Hide); + } + + private void OnDestroy() + { + if (closeButton != null) + closeButton.onClick.RemoveListener(Hide); + } + + public void Show() => gameObject.SetActive(true); + public void Hide() => gameObject.SetActive(false); + public bool IsVisible => gameObject.activeSelf; + + public void Refresh(IReadOnlyList owned, int maxSlots) + { + ClearSlots(); + + int activeCount = 0; + + for (int i = 0; i < owned.Count; i++) + { + var runtime = owned[i]; + if (runtime.IsActive) activeCount++; + + var slot = Instantiate(slotPrefab, slotContainer); + slot.Setup(runtime, activeCount <= maxSlots); + slot.OnActivateClicked += HandleActivate; + slot.OnDeactivateClicked += HandleDeactivate; + slot.OnSellClicked += HandleSell; + spawnedSlots.Add(slot); + } + + if (slotCountText != null) + slotCountText.text = $"{activeCount}/{maxSlots}"; + } + + private void ClearSlots() + { + for (int i = 0; i < spawnedSlots.Count; i++) + { + spawnedSlots[i].OnActivateClicked -= HandleActivate; + spawnedSlots[i].OnDeactivateClicked -= HandleDeactivate; + spawnedSlots[i].OnSellClicked -= HandleSell; + Destroy(spawnedSlots[i].gameObject); + } + spawnedSlots.Clear(); + } + + private void HandleActivate(ModifierRuntime runtime) => OnActivateClicked?.Invoke(runtime); + private void HandleDeactivate(ModifierRuntime runtime) => OnDeactivateClicked?.Invoke(runtime); + private void HandleSell(ModifierRuntime runtime) => OnSellClicked?.Invoke(runtime); +} diff --git a/Assets/Scripts/Modifiers/BonusForOnes.cs b/Assets/Scripts/Modifiers/BonusForOnes.cs deleted file mode 100644 index 2db89aa..0000000 --- a/Assets/Scripts/Modifiers/BonusForOnes.cs +++ /dev/null @@ -1,18 +0,0 @@ -using UnityEngine; - -[CreateAssetMenu(fileName = "BonusForOnes", menuName = "YachtDice/Modifiers/Bonus For Ones")] -public sealed class BonusForOnes : ScoreModifier -{ - [SerializeField] private int bonusPerOne = 10; - - public override ModifierPhase Phase => ModifierPhase.Additive; - - public override void Apply(ref ScoreResult result) - { - int count = 0; - for (int i = 0; i < result.DiceValues.Length; i++) - if (result.DiceValues[i] == 1) count++; - - result.FlatBonus += bonusPerOne * count; - } -} diff --git a/Assets/Scripts/Modifiers/ModifierData.cs b/Assets/Scripts/Modifiers/ModifierData.cs new file mode 100644 index 0000000..05b9a0c --- /dev/null +++ b/Assets/Scripts/Modifiers/ModifierData.cs @@ -0,0 +1,88 @@ +using UnityEngine; + +[CreateAssetMenu(fileName = "NewModifier", menuName = "YachtDice/Modifier Data")] +public sealed class ModifierData : ScriptableObject +{ + [SerializeField] private string id; + [SerializeField] private string displayName; + [SerializeField] [TextArea] private string description; + [SerializeField] private ModifierRarity rarity; + [SerializeField] private int shopPrice; + [SerializeField] private int sellPrice; + [SerializeField] private Sprite icon; + + [Header("Effect")] + [SerializeField] private ModifierScope scope; + [SerializeField] private ModifierEffectType effectType; + [SerializeField] private ModifierTarget target; + [SerializeField] private float effectValue; + + [Header("Durability")] + [SerializeField] private ModifierDurability durability; + [SerializeField] private int maxUses; + + public string Id => id; + public string DisplayName => displayName; + public string Description => description; + public ModifierRarity Rarity => rarity; + public int ShopPrice => shopPrice; + public int SellPrice => sellPrice; + public Sprite Icon => icon; + public ModifierScope Scope => scope; + public ModifierEffectType EffectType => effectType; + public ModifierTarget Target => target; + public float EffectValue => effectValue; + public ModifierDurability Durability => durability; + public int MaxUses => maxUses; + + public bool IsAdditive => + effectType == ModifierEffectType.AddPerDieValue || + effectType == ModifierEffectType.AddFlatToFinalScore; + + public bool IsMultiplicative => + effectType == ModifierEffectType.MultiplyPerDieValue || + effectType == ModifierEffectType.MultiplyFinalScore; + + public bool IsCategoryLevel => + effectType == ModifierEffectType.AddPerDieValue || + effectType == ModifierEffectType.MultiplyPerDieValue; + + public bool IsFinalScoreLevel => + effectType == ModifierEffectType.AddFlatToFinalScore || + effectType == ModifierEffectType.MultiplyFinalScore; + +#if UNITY_EDITOR + public static ModifierData CreateForTest( + string id, + ModifierScope scope, + ModifierEffectType effectType, + float effectValue, + int dieValue = 0, + YachtCategory targetCategory = YachtCategory.Ones, + bool hasCategoryFilter = false, + ModifierDurability durability = ModifierDurability.Permanent, + int maxUses = 0, + int shopPrice = 100, + int sellPrice = 50) + { + var data = CreateInstance(); + data.id = id; + data.displayName = id; + data.description = id; + data.scope = scope; + data.effectType = effectType; + data.effectValue = effectValue; + data.target = new ModifierTarget + { + DieValue = dieValue, + TargetCategory = targetCategory, + HasCategoryFilter = hasCategoryFilter + }; + data.durability = durability; + data.maxUses = maxUses; + data.shopPrice = shopPrice; + data.sellPrice = sellPrice; + return data; + } +#endif +} diff --git a/Assets/Scripts/Modifiers/ModifierEffect.cs b/Assets/Scripts/Modifiers/ModifierEffect.cs new file mode 100644 index 0000000..4f3b0aa --- /dev/null +++ b/Assets/Scripts/Modifiers/ModifierEffect.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; + +public delegate void ModifierHandler(ModifierData data, ref ScoreResult result); + +public static class ModifierEffect +{ + private static readonly Dictionary Handlers = new() + { + { ModifierEffectType.AddPerDieValue, ApplyAddPerDieValue }, + { ModifierEffectType.AddFlatToFinalScore, ApplyAddFlat }, + { ModifierEffectType.MultiplyPerDieValue, ApplyMultiplyPerDieValue }, + { ModifierEffectType.MultiplyFinalScore, ApplyMultiplyFinal } + }; + + public static void Apply(ModifierData data, ref ScoreResult result) + { + if (Handlers.TryGetValue(data.EffectType, out var handler)) + handler(data, ref result); + } + + private static void ApplyAddPerDieValue(ModifierData data, ref ScoreResult result) + { + int targetValue = data.Target.DieValue; + int count = 0; + + for (int i = 0; i < result.DiceValues.Length; i++) + { + if (targetValue == 0 || result.DiceValues[i] == targetValue) + count++; + } + + result.FlatBonus += (int)(data.EffectValue * count); + } + + private static void ApplyAddFlat(ModifierData data, ref ScoreResult result) + { + result.FlatBonus += (int)data.EffectValue; + } + + private static void ApplyMultiplyPerDieValue(ModifierData data, ref ScoreResult result) + { + int targetValue = data.Target.DieValue; + + for (int i = 0; i < result.DiceValues.Length; i++) + { + if (targetValue == 0 || result.DiceValues[i] == targetValue) + result.Multiplier *= data.EffectValue; + } + } + + private static void ApplyMultiplyFinal(ModifierData data, ref ScoreResult result) + { + result.Multiplier *= data.EffectValue; + } +} diff --git a/Assets/Scripts/Modifiers/ModifierEnums.cs b/Assets/Scripts/Modifiers/ModifierEnums.cs new file mode 100644 index 0000000..d4db390 --- /dev/null +++ b/Assets/Scripts/Modifiers/ModifierEnums.cs @@ -0,0 +1,27 @@ +public enum ModifierScope +{ + SelectedCategory, + AnyCategoryClosed +} + +public enum ModifierEffectType +{ + AddPerDieValue, + AddFlatToFinalScore, + MultiplyPerDieValue, + MultiplyFinalScore +} + +public enum ModifierDurability +{ + Permanent, + LimitedUses +} + +public enum ModifierRarity +{ + Common, + Uncommon, + Rare, + Epic +} diff --git a/Assets/Scripts/Modifiers/ModifierPipeline.cs b/Assets/Scripts/Modifiers/ModifierPipeline.cs new file mode 100644 index 0000000..673f2b2 --- /dev/null +++ b/Assets/Scripts/Modifiers/ModifierPipeline.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; + +public static class ModifierPipeline +{ + // Application order (explicit): + // 1. Category-level additive (AddPerDieValue) + // 2. Category-level multiplicative (MultiplyPerDieValue) + // 3. Final-score additive (AddFlatToFinalScore) + // 4. Final-score multiplicative (MultiplyFinalScore) + + public static void Apply( + IReadOnlyList activeModifiers, + ref ScoreResult result, + ModifierScope currentScope) + { + if (activeModifiers == null) return; + + // Pass 1: Category-level additive + for (int i = 0; i < activeModifiers.Count; i++) + { + var mod = activeModifiers[i]; + if (!ShouldApply(mod, ref result, currentScope)) continue; + if (mod.IsCategoryLevel && mod.IsAdditive) + ModifierEffect.Apply(mod, ref result); + } + + // Pass 2: Category-level multiplicative + for (int i = 0; i < activeModifiers.Count; i++) + { + var mod = activeModifiers[i]; + if (!ShouldApply(mod, ref result, currentScope)) continue; + if (mod.IsCategoryLevel && mod.IsMultiplicative) + ModifierEffect.Apply(mod, ref result); + } + + // Pass 3: Final-score additive + for (int i = 0; i < activeModifiers.Count; i++) + { + var mod = activeModifiers[i]; + if (!ShouldApply(mod, ref result, currentScope)) continue; + if (mod.IsFinalScoreLevel && mod.IsAdditive) + ModifierEffect.Apply(mod, ref result); + } + + // Pass 4: Final-score multiplicative + for (int i = 0; i < activeModifiers.Count; i++) + { + var mod = activeModifiers[i]; + if (!ShouldApply(mod, ref result, currentScope)) continue; + if (mod.IsFinalScoreLevel && mod.IsMultiplicative) + ModifierEffect.Apply(mod, ref result); + } + } + + private static bool ShouldApply(ModifierData mod, ref ScoreResult result, ModifierScope currentScope) + { + if (mod == null) return false; + if (mod.Scope != currentScope) return false; + if (mod.Target.HasCategoryFilter && mod.Target.TargetCategory != result.Category) return false; + return true; + } +} diff --git a/Assets/Scripts/Modifiers/ModifierRuntime.cs b/Assets/Scripts/Modifiers/ModifierRuntime.cs new file mode 100644 index 0000000..bfb0be6 --- /dev/null +++ b/Assets/Scripts/Modifiers/ModifierRuntime.cs @@ -0,0 +1,34 @@ +using System; + +[Serializable] +public sealed class ModifierRuntime +{ + public string ModifierId; + public bool IsActive; + public int RemainingUses; + + [NonSerialized] public ModifierData Data; + + public bool IsExpired => Data != null && + Data.Durability == ModifierDurability.LimitedUses && + RemainingUses <= 0; + + public void ConsumeUse() + { + if (Data == null) return; + if (Data.Durability != ModifierDurability.LimitedUses) return; + + RemainingUses--; + } + + public static ModifierRuntime Create(ModifierData data) + { + return new ModifierRuntime + { + ModifierId = data.Id, + IsActive = false, + RemainingUses = data.Durability == ModifierDurability.LimitedUses ? data.MaxUses : -1, + Data = data + }; + } +} diff --git a/Assets/Scripts/Modifiers/ModifierTarget.cs b/Assets/Scripts/Modifiers/ModifierTarget.cs new file mode 100644 index 0000000..a64eafb --- /dev/null +++ b/Assets/Scripts/Modifiers/ModifierTarget.cs @@ -0,0 +1,16 @@ +using System; +using UnityEngine; + +[Serializable] +public struct ModifierTarget +{ + [Tooltip("Die face value (1-6). 0 = any/all dice.")] + [Range(0, 6)] + public int DieValue; + + [Tooltip("Category this modifier targets.")] + public YachtCategory TargetCategory; + + [Tooltip("If true, TargetCategory is used as a filter.")] + public bool HasCategoryFilter; +} diff --git a/Assets/Scripts/Modifiers/MultiplierForSixes.cs b/Assets/Scripts/Modifiers/MultiplierForSixes.cs deleted file mode 100644 index 4fc33cf..0000000 --- a/Assets/Scripts/Modifiers/MultiplierForSixes.cs +++ /dev/null @@ -1,16 +0,0 @@ -using UnityEngine; - -[CreateAssetMenu(fileName = "MultiplierForSixes", menuName = "YachtDice/Modifiers/Multiplier For Sixes")] -public sealed class MultiplierForSixes : ScoreModifier -{ - [SerializeField] private float multiplierPerSix = 6f; - - public override ModifierPhase Phase => ModifierPhase.Multiplicative; - - public override void Apply(ref ScoreResult result) - { - for (int i = 0; i < result.DiceValues.Length; i++) - if (result.DiceValues[i] == 6) - result.Multiplier *= multiplierPerSix; - } -} diff --git a/Assets/Scripts/Modifiers/ScoreModifier.cs b/Assets/Scripts/Modifiers/ScoreModifier.cs deleted file mode 100644 index 615cc19..0000000 --- a/Assets/Scripts/Modifiers/ScoreModifier.cs +++ /dev/null @@ -1,19 +0,0 @@ -using UnityEngine; - -public abstract class ScoreModifier : ScriptableObject -{ - public enum ModifierPhase - { - Additive, - Multiplicative - } - - [SerializeField] private string displayName; - [SerializeField] [TextArea] private string description; - - public string DisplayName => displayName; - public string Description => description; - - public abstract ModifierPhase Phase { get; } - public abstract void Apply(ref ScoreResult result); -} diff --git a/Assets/Scripts/Persistence/SaveData.cs b/Assets/Scripts/Persistence/SaveData.cs new file mode 100644 index 0000000..22b18f8 --- /dev/null +++ b/Assets/Scripts/Persistence/SaveData.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +[Serializable] +public sealed class SaveData +{ + public int Version = 1; + public int Currency; + public List OwnedModifiers = new(); +} + +[Serializable] +public sealed class ModifierSaveEntry +{ + public string ModifierId; + public bool IsActive; + public int RemainingUses; +} diff --git a/Assets/Scripts/Persistence/SaveSystem.cs b/Assets/Scripts/Persistence/SaveSystem.cs new file mode 100644 index 0000000..e661639 --- /dev/null +++ b/Assets/Scripts/Persistence/SaveSystem.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json; +using UnityEngine; + +public static class SaveSystem +{ + private const string SaveKey = "YachtDice_SaveData"; + + public static void Save(SaveData data) + { + string json = JsonConvert.SerializeObject(data, Formatting.Indented); + PlayerPrefs.SetString(SaveKey, json); + PlayerPrefs.Save(); + } + + public static SaveData Load() + { + if (!PlayerPrefs.HasKey(SaveKey)) + return new SaveData(); + + string json = PlayerPrefs.GetString(SaveKey); + + try + { + return JsonConvert.DeserializeObject(json) ?? new SaveData(); + } + catch (JsonException e) + { + Debug.LogWarning($"Failed to deserialize save data, returning default: {e.Message}"); + return new SaveData(); + } + } + + public static void Delete() + { + PlayerPrefs.DeleteKey(SaveKey); + } + + public static bool HasSave() + { + return PlayerPrefs.HasKey(SaveKey); + } +} diff --git a/Assets/Scripts/Scoring/ScoringSystem.cs b/Assets/Scripts/Scoring/ScoringSystem.cs index 97fb5d1..e52126c 100644 --- a/Assets/Scripts/Scoring/ScoringSystem.cs +++ b/Assets/Scripts/Scoring/ScoringSystem.cs @@ -4,14 +4,13 @@ using UnityEngine; public sealed class ScoringSystem : MonoBehaviour { - [Header("Modifiers")] - [SerializeField] private List activeModifiers = new(); - public event Action OnCategoryScored; public event Action OnAllCategoriesScored; + public event Action OnCategoryConfirmed; private readonly Dictionary scorecard = new(); private readonly HashSet usedCategories = new(); + private List activeModifierData = new(); public bool IsCategoryUsed(YachtCategory category) => usedCategories.Contains(category); @@ -36,24 +35,20 @@ public sealed class ScoringSystem : MonoBehaviour public bool IsComplete => CategoriesFilledCount >= TotalCategoryCount; - public void AddModifier(ScoreModifier modifier) + public void SetActiveModifiers(List modifiers) { - if (modifier != null && !activeModifiers.Contains(modifier)) - activeModifiers.Add(modifier); + activeModifierData = modifiers ?? new List(); } - public void RemoveModifier(ScoreModifier modifier) - { - activeModifiers.Remove(modifier); - } - - public IReadOnlyList ActiveModifiers => activeModifiers; + public IReadOnlyList ActiveModifiers => activeModifierData; public ScoreResult PreviewScore(int[] diceValues, YachtCategory category) { int baseScore = CategoryScorer.Calculate(diceValues, category); ScoreResult result = ScoreResult.Create(baseScore, diceValues, category); - ApplyModifiers(ref result); + + ModifierPipeline.Apply(activeModifierData, ref result, ModifierScope.SelectedCategory); + return result; } @@ -62,13 +57,18 @@ public sealed class ScoringSystem : MonoBehaviour if (usedCategories.Contains(category)) throw new InvalidOperationException($"Category {category} has already been scored."); - ScoreResult result = PreviewScore(diceValues, category); + int baseScore = CategoryScorer.Calculate(diceValues, category); + ScoreResult result = ScoreResult.Create(baseScore, diceValues, category); + + ModifierPipeline.Apply(activeModifierData, ref result, ModifierScope.SelectedCategory); + ModifierPipeline.Apply(activeModifierData, ref result, ModifierScope.AnyCategoryClosed); int finalScore = result.FinalScore; scorecard[category] = finalScore; usedCategories.Add(category); OnCategoryScored?.Invoke(category, finalScore); + OnCategoryConfirmed?.Invoke(category, result); if (IsComplete) OnAllCategoriesScored?.Invoke(TotalScore); @@ -76,25 +76,6 @@ public sealed class ScoringSystem : MonoBehaviour return result; } - private void ApplyModifiers(ref ScoreResult result) - { - // Pass 1: Additive - for (int i = 0; i < activeModifiers.Count; i++) - { - if (activeModifiers[i] == null) continue; - if (activeModifiers[i].Phase == ScoreModifier.ModifierPhase.Additive) - activeModifiers[i].Apply(ref result); - } - - // Pass 2: Multiplicative - for (int i = 0; i < activeModifiers.Count; i++) - { - if (activeModifiers[i] == null) continue; - if (activeModifiers[i].Phase == ScoreModifier.ModifierPhase.Multiplicative) - activeModifiers[i].Apply(ref result); - } - } - public void ResetScorecard() { scorecard.Clear(); diff --git a/Assets/Scripts/Shop/ShopCatalog.cs b/Assets/Scripts/Shop/ShopCatalog.cs new file mode 100644 index 0000000..8916c51 --- /dev/null +++ b/Assets/Scripts/Shop/ShopCatalog.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using UnityEngine; + +[CreateAssetMenu(fileName = "ShopCatalog", menuName = "YachtDice/Shop Catalog")] +public sealed class ShopCatalog : ScriptableObject +{ + [SerializeField] private List availableModifiers = new(); + + public IReadOnlyList AvailableModifiers => availableModifiers; + + public ModifierData FindById(string id) + { + for (int i = 0; i < availableModifiers.Count; i++) + { + if (availableModifiers[i] != null && availableModifiers[i].Id == id) + return availableModifiers[i]; + } + return null; + } +} diff --git a/Assets/Scripts/Shop/ShopController.cs b/Assets/Scripts/Shop/ShopController.cs new file mode 100644 index 0000000..4680fbc --- /dev/null +++ b/Assets/Scripts/Shop/ShopController.cs @@ -0,0 +1,55 @@ +using UnityEngine; + +public sealed class ShopController : MonoBehaviour +{ + [SerializeField] private ShopCatalog catalog; + [SerializeField] private ShopView shopView; + [SerializeField] private CurrencyBank currencyBank; + + private ShopModel model; + + public ShopCatalog Catalog => catalog; + + public void Initialize(ShopModel shopModel) + { + model = shopModel; + + shopView.OnBuyClicked += HandleBuyClicked; + + if (currencyBank != null) + currencyBank.OnBalanceChanged += HandleCurrencyChanged; + + model.OnItemPurchased += HandleItemPurchased; + + shopView.Populate(catalog.AvailableModifiers, model); + shopView.UpdateCurrencyDisplay(currencyBank != null ? currencyBank.Balance : 0); + } + + private void OnDestroy() + { + if (shopView != null) + shopView.OnBuyClicked -= HandleBuyClicked; + + if (currencyBank != null) + currencyBank.OnBalanceChanged -= HandleCurrencyChanged; + + if (model != null) + model.OnItemPurchased -= HandleItemPurchased; + } + + private void HandleBuyClicked(ModifierData data) + { + model.TryPurchase(data); + } + + private void HandleCurrencyChanged(int newBalance) + { + shopView.UpdateCurrencyDisplay(newBalance); + shopView.RefreshStates(catalog.AvailableModifiers, model); + } + + private void HandleItemPurchased(ModifierData data) + { + shopView.RefreshStates(catalog.AvailableModifiers, model); + } +} diff --git a/Assets/Scripts/Shop/ShopItemView.cs b/Assets/Scripts/Shop/ShopItemView.cs new file mode 100644 index 0000000..db0ecc5 --- /dev/null +++ b/Assets/Scripts/Shop/ShopItemView.cs @@ -0,0 +1,87 @@ +using System; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +public sealed class ShopItemView : MonoBehaviour +{ + [SerializeField] private Image iconImage; + [SerializeField] private TMP_Text nameText; + [SerializeField] private TMP_Text descriptionText; + [SerializeField] private TMP_Text priceText; + [SerializeField] private TMP_Text rarityText; + [SerializeField] private Button buyButton; + [SerializeField] private TMP_Text buyButtonText; + [SerializeField] private Image background; + + [Header("Rarity Colors")] + [SerializeField] private Color commonColor = Color.white; + [SerializeField] private Color uncommonColor = new(0.4f, 0.8f, 0.4f); + [SerializeField] private Color rareColor = new(0.4f, 0.6f, 1f); + [SerializeField] private Color epicColor = new(0.8f, 0.4f, 1f); + + private ModifierData data; + + public event Action OnBuyClicked; + + private void Awake() + { + if (buyButton != null) + buyButton.onClick.AddListener(() => OnBuyClicked?.Invoke(data)); + } + + public void Setup(ModifierData modifierData, ShopItemState state) + { + data = modifierData; + + if (nameText != null) nameText.text = data.DisplayName; + if (descriptionText != null) descriptionText.text = data.Description; + if (priceText != null) priceText.text = data.ShopPrice.ToString(); + if (iconImage != null && data.Icon != null) iconImage.sprite = data.Icon; + + if (rarityText != null) + { + rarityText.text = data.Rarity.ToString(); + rarityText.color = GetRarityColor(data.Rarity); + } + + SetState(state); + } + + public void SetState(ShopItemState state) + { + if (buyButton == null) return; + + switch (state) + { + case ShopItemState.Available: + buyButton.interactable = true; + if (buyButtonText != null) buyButtonText.text = "Buy"; + break; + case ShopItemState.RepurchaseAvailable: + buyButton.interactable = true; + if (buyButtonText != null) buyButtonText.text = "Buy"; + break; + case ShopItemState.TooExpensive: + buyButton.interactable = false; + if (buyButtonText != null) buyButtonText.text = "Buy"; + break; + case ShopItemState.Owned: + buyButton.interactable = false; + if (buyButtonText != null) buyButtonText.text = "Owned"; + break; + } + } + + private Color GetRarityColor(ModifierRarity rarity) + { + return rarity switch + { + ModifierRarity.Common => commonColor, + ModifierRarity.Uncommon => uncommonColor, + ModifierRarity.Rare => rareColor, + ModifierRarity.Epic => epicColor, + _ => commonColor + }; + } +} diff --git a/Assets/Scripts/Shop/ShopModel.cs b/Assets/Scripts/Shop/ShopModel.cs new file mode 100644 index 0000000..676e217 --- /dev/null +++ b/Assets/Scripts/Shop/ShopModel.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; + +public sealed class ShopModel +{ + private readonly CurrencyBank currencyBank; + private readonly InventoryModel inventoryModel; + private readonly HashSet purchasedPermanentIds = new(); + + public event Action OnItemPurchased; + + public ShopModel(CurrencyBank currencyBank, InventoryModel inventoryModel) + { + this.currencyBank = currencyBank; + this.inventoryModel = inventoryModel; + } + + public bool CanPurchase(ModifierData modifier) + { + if (modifier == null) return false; + if (!currencyBank.CanAfford(modifier.ShopPrice)) return false; + + if (modifier.Durability == ModifierDurability.Permanent && + purchasedPermanentIds.Contains(modifier.Id)) + return false; + + return true; + } + + public bool TryPurchase(ModifierData modifier) + { + if (!CanPurchase(modifier)) return false; + + if (!currencyBank.Spend(modifier.ShopPrice)) return false; + + if (modifier.Durability == ModifierDurability.Permanent) + purchasedPermanentIds.Add(modifier.Id); + + inventoryModel.AddModifier(modifier); + OnItemPurchased?.Invoke(modifier); + return true; + } + + public bool IsPermanentOwned(string modifierId) => purchasedPermanentIds.Contains(modifierId); + + public ShopItemState GetItemState(ModifierData modifier) + { + if (modifier == null) return ShopItemState.TooExpensive; + + if (modifier.Durability == ModifierDurability.Permanent && + purchasedPermanentIds.Contains(modifier.Id)) + return ShopItemState.Owned; + + if (!currencyBank.CanAfford(modifier.ShopPrice)) + return ShopItemState.TooExpensive; + + return modifier.Durability == ModifierDurability.LimitedUses + ? ShopItemState.RepurchaseAvailable + : ShopItemState.Available; + } + + public void LoadPurchasedPermanentIds(IEnumerable ids) + { + purchasedPermanentIds.Clear(); + if (ids != null) + foreach (var id in ids) purchasedPermanentIds.Add(id); + } + + public HashSet GetPurchasedPermanentIds() => new(purchasedPermanentIds); +} + +public enum ShopItemState +{ + Available, + TooExpensive, + Owned, + RepurchaseAvailable +} diff --git a/Assets/Scripts/Shop/ShopView.cs b/Assets/Scripts/Shop/ShopView.cs new file mode 100644 index 0000000..25e8394 --- /dev/null +++ b/Assets/Scripts/Shop/ShopView.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +public sealed class ShopView : MonoBehaviour +{ + [SerializeField] private Transform itemContainer; + [SerializeField] private ShopItemView itemPrefab; + [SerializeField] private TMP_Text currencyText; + [SerializeField] private Button closeButton; + + private readonly List spawnedItems = new(); + + public event Action OnBuyClicked; + + private void Awake() + { + if (closeButton != null) + closeButton.onClick.AddListener(Hide); + } + + private void OnDestroy() + { + if (closeButton != null) + closeButton.onClick.RemoveListener(Hide); + } + + public void Show() => gameObject.SetActive(true); + public void Hide() => gameObject.SetActive(false); + public bool IsVisible => gameObject.activeSelf; + + public void Populate(IReadOnlyList catalog, ShopModel model) + { + ClearItems(); + + for (int i = 0; i < catalog.Count; i++) + { + var data = catalog[i]; + if (data == null) continue; + + var item = Instantiate(itemPrefab, itemContainer); + var state = model.GetItemState(data); + item.Setup(data, state); + item.OnBuyClicked += HandleBuy; + spawnedItems.Add(item); + } + } + + public void RefreshStates(IReadOnlyList catalog, ShopModel model) + { + for (int i = 0; i < spawnedItems.Count && i < catalog.Count; i++) + { + var state = model.GetItemState(catalog[i]); + spawnedItems[i].SetState(state); + } + } + + public void UpdateCurrencyDisplay(int currency) + { + if (currencyText != null) + currencyText.text = currency.ToString(); + } + + private void ClearItems() + { + for (int i = 0; i < spawnedItems.Count; i++) + { + spawnedItems[i].OnBuyClicked -= HandleBuy; + Destroy(spawnedItems[i].gameObject); + } + spawnedItems.Clear(); + } + + private void HandleBuy(ModifierData data) => OnBuyClicked?.Invoke(data); +} diff --git a/Assets/Scripts/Tests/Editor/InventoryModelTests.cs b/Assets/Scripts/Tests/Editor/InventoryModelTests.cs new file mode 100644 index 0000000..9eeb256 --- /dev/null +++ b/Assets/Scripts/Tests/Editor/InventoryModelTests.cs @@ -0,0 +1,155 @@ +using NUnit.Framework; +using UnityEngine; + +public sealed class InventoryModelTests +{ + private InventoryModel inventory; + + [SetUp] + public void SetUp() + { + inventory = new InventoryModel(3); + } + + private ModifierData CreateTestData(string id = "test", + ModifierDurability durability = ModifierDurability.Permanent, int maxUses = 0) + { + return ModifierData.CreateForTest(id, ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f, + durability: durability, maxUses: maxUses); + } + + [Test] + public void AddModifier_IncreasesCount() + { + inventory.AddModifier(CreateTestData()); + + Assert.AreEqual(1, inventory.OwnedModifiers.Count); + } + + [Test] + public void TryActivate_SucceedsWithinSlotLimit() + { + inventory.AddModifier(CreateTestData("a")); + var mod = inventory.OwnedModifiers[0]; + + bool result = inventory.TryActivate(mod); + + Assert.IsTrue(result); + Assert.IsTrue(mod.IsActive); + Assert.AreEqual(1, inventory.ActiveCount); + } + + [Test] + public void TryActivate_FailsWhenSlotsFull() + { + for (int i = 0; i < 3; i++) + { + inventory.AddModifier(CreateTestData($"m{i}")); + inventory.TryActivate(inventory.OwnedModifiers[i]); + } + + inventory.AddModifier(CreateTestData("extra")); + var extra = inventory.OwnedModifiers[3]; + bool result = inventory.TryActivate(extra); + + Assert.IsFalse(result); + Assert.IsFalse(extra.IsActive); + Assert.AreEqual(3, inventory.ActiveCount); + } + + [Test] + public void Deactivate_FreesSlot() + { + inventory.AddModifier(CreateTestData()); + var mod = inventory.OwnedModifiers[0]; + inventory.TryActivate(mod); + + inventory.Deactivate(mod); + + Assert.IsFalse(mod.IsActive); + Assert.AreEqual(0, inventory.ActiveCount); + } + + [Test] + public void RemoveModifier_DeactivatesAndRemoves() + { + inventory.AddModifier(CreateTestData()); + var mod = inventory.OwnedModifiers[0]; + inventory.TryActivate(mod); + + inventory.RemoveModifier(mod); + + Assert.AreEqual(0, inventory.OwnedModifiers.Count); + Assert.AreEqual(0, inventory.ActiveCount); + } + + [Test] + public void ConsumeUseOnActive_DecrementsUses() + { + inventory.AddModifier(CreateTestData("ltd", ModifierDurability.LimitedUses, 3)); + var mod = inventory.OwnedModifiers[0]; + inventory.TryActivate(mod); + + inventory.ConsumeUseOnActive(); + + Assert.AreEqual(2, mod.RemainingUses); + } + + [Test] + public void ConsumeUseOnActive_RemovesExpired() + { + inventory.AddModifier(CreateTestData("ltd", ModifierDurability.LimitedUses, 1)); + var mod = inventory.OwnedModifiers[0]; + inventory.TryActivate(mod); + + inventory.ConsumeUseOnActive(); + + Assert.AreEqual(0, inventory.OwnedModifiers.Count); + } + + [Test] + public void ConsumeUseOnActive_IgnoresPermanent() + { + inventory.AddModifier(CreateTestData("perm", ModifierDurability.Permanent)); + var mod = inventory.OwnedModifiers[0]; + inventory.TryActivate(mod); + + inventory.ConsumeUseOnActive(); + + Assert.AreEqual(1, inventory.OwnedModifiers.Count); + Assert.IsTrue(mod.IsActive); + } + + [Test] + public void GetActiveModifierData_ReturnsOnlyActive() + { + inventory.AddModifier(CreateTestData("a")); + inventory.AddModifier(CreateTestData("b")); + inventory.TryActivate(inventory.OwnedModifiers[0]); + + var active = inventory.GetActiveModifierData(); + + Assert.AreEqual(1, active.Count); + } + + [Test] + public void SetMaxActiveSlots_AllowsExpansion() + { + inventory.SetMaxActiveSlots(10); + + Assert.AreEqual(10, inventory.MaxActiveSlots); + } + + [Test] + public void OnActiveModifiersChanged_FiredOnActivate() + { + bool fired = false; + inventory.OnActiveModifiersChanged += _ => fired = true; + inventory.AddModifier(CreateTestData()); + + inventory.TryActivate(inventory.OwnedModifiers[0]); + + Assert.IsTrue(fired); + } +} diff --git a/Assets/Scripts/Tests/Editor/ModifierEffectTests.cs b/Assets/Scripts/Tests/Editor/ModifierEffectTests.cs new file mode 100644 index 0000000..fe26e77 --- /dev/null +++ b/Assets/Scripts/Tests/Editor/ModifierEffectTests.cs @@ -0,0 +1,89 @@ +using NUnit.Framework; +using UnityEngine; + +public sealed class ModifierEffectTests +{ + private static ModifierData CreateData( + ModifierEffectType effectType, float effectValue, + int dieValue = 0, ModifierScope scope = ModifierScope.SelectedCategory) + { + return ModifierData.CreateForTest("test", scope, effectType, effectValue, dieValue); + } + + [Test] + public void AddPerDieValue_CountsMatchingDice() + { + var data = CreateData(ModifierEffectType.AddPerDieValue, 10f, dieValue: 1); + var result = ScoreResult.Create(5, new[] { 1, 1, 3, 4, 1 }, YachtCategory.Ones); + + ModifierEffect.Apply(data, ref result); + + Assert.AreEqual(30, result.FlatBonus); + } + + [Test] + public void AddPerDieValue_ZeroTarget_CountsAllDice() + { + var data = CreateData(ModifierEffectType.AddPerDieValue, 2f, dieValue: 0); + var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + ModifierEffect.Apply(data, ref result); + + Assert.AreEqual(10, result.FlatBonus); + } + + [Test] + public void AddPerDieValue_NoMatches_ZeroBonus() + { + var data = CreateData(ModifierEffectType.AddPerDieValue, 10f, dieValue: 6); + var result = ScoreResult.Create(5, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + ModifierEffect.Apply(data, ref result); + + Assert.AreEqual(0, result.FlatBonus); + } + + [Test] + public void AddFlatToFinalScore_AddsFlat() + { + var data = CreateData(ModifierEffectType.AddFlatToFinalScore, 15f); + var result = ScoreResult.Create(25, new[] { 3, 3, 2, 2, 2 }, YachtCategory.FullHouse); + + ModifierEffect.Apply(data, ref result); + + Assert.AreEqual(15, result.FlatBonus); + } + + [Test] + public void MultiplyPerDieValue_MultipliesPerMatch() + { + var data = CreateData(ModifierEffectType.MultiplyPerDieValue, 2f, dieValue: 6); + var result = ScoreResult.Create(18, new[] { 6, 6, 6, 1, 2 }, YachtCategory.Sixes); + + ModifierEffect.Apply(data, ref result); + + Assert.AreEqual(8f, result.Multiplier); // 1 * 2 * 2 * 2 = 8 + } + + [Test] + public void MultiplyPerDieValue_NoMatches_MultiplierUnchanged() + { + var data = CreateData(ModifierEffectType.MultiplyPerDieValue, 3f, dieValue: 6); + var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + ModifierEffect.Apply(data, ref result); + + Assert.AreEqual(1f, result.Multiplier); + } + + [Test] + public void MultiplyFinalScore_MultipliesOnce() + { + var data = CreateData(ModifierEffectType.MultiplyFinalScore, 1.5f); + var result = ScoreResult.Create(50, new[] { 6, 6, 6, 6, 6 }, YachtCategory.Yacht); + + ModifierEffect.Apply(data, ref result); + + Assert.AreEqual(1.5f, result.Multiplier, 0.001f); + } +} diff --git a/Assets/Scripts/Tests/Editor/ModifierPipelineTests.cs b/Assets/Scripts/Tests/Editor/ModifierPipelineTests.cs new file mode 100644 index 0000000..2658ba6 --- /dev/null +++ b/Assets/Scripts/Tests/Editor/ModifierPipelineTests.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +public sealed class ModifierPipelineTests +{ + [Test] + public void Apply_AdditiveBeforeMultiplicative() + { + var addMod = ModifierData.CreateForTest("add", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f); + var mulMod = ModifierData.CreateForTest("mul", ModifierScope.SelectedCategory, + ModifierEffectType.MultiplyFinalScore, 2f); + + var modifiers = new List { mulMod, addMod }; + var result = ScoreResult.Create(20, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + + // (20 + 10) * 2 = 60 + Assert.AreEqual(60, result.FinalScore); + } + + [Test] + public void Apply_CategoryLevelBeforeFinalScore() + { + var perDie = ModifierData.CreateForTest("perDie", ModifierScope.SelectedCategory, + ModifierEffectType.AddPerDieValue, 5f, dieValue: 1); + var flat = ModifierData.CreateForTest("flat", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 100f); + + var modifiers = new List { flat, perDie }; + var result = ScoreResult.Create(3, new[] { 1, 1, 1, 2, 3 }, YachtCategory.Ones); + + ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + + // FlatBonus = 15 (perDie: 5*3) + 100 (flat) = 115 + // FinalScore = (3 + 115) * 1 = 118 + Assert.AreEqual(118, result.FinalScore); + } + + [Test] + public void Apply_ScopeFiltering_SkipsWrongScope() + { + var mod = ModifierData.CreateForTest("any", ModifierScope.AnyCategoryClosed, + ModifierEffectType.AddFlatToFinalScore, 50f); + + var modifiers = new List { mod }; + var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + + Assert.AreEqual(0, result.FlatBonus); + Assert.AreEqual(10, result.FinalScore); + } + + [Test] + public void Apply_CategoryFilter_SkipsWrongCategory() + { + var mod = ModifierData.CreateForTest("fh", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 15f, + targetCategory: YachtCategory.FullHouse, hasCategoryFilter: true); + + var modifiers = new List { mod }; + var result = ScoreResult.Create(5, new[] { 1, 1, 1, 1, 1 }, YachtCategory.Ones); + + ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + + Assert.AreEqual(0, result.FlatBonus); + } + + [Test] + public void Apply_CategoryFilter_AppliesMatchingCategory() + { + var mod = ModifierData.CreateForTest("fh", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 15f, + targetCategory: YachtCategory.FullHouse, hasCategoryFilter: true); + + var modifiers = new List { mod }; + var result = ScoreResult.Create(25, new[] { 3, 3, 3, 2, 2 }, YachtCategory.FullHouse); + + ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + + Assert.AreEqual(15, result.FlatBonus); + Assert.AreEqual(40, result.FinalScore); + } + + [Test] + public void Apply_MultipleModifiers_CorrectOrder() + { + var perDieAdd = ModifierData.CreateForTest("pda", ModifierScope.SelectedCategory, + ModifierEffectType.AddPerDieValue, 2f, dieValue: 3); + var perDieMul = ModifierData.CreateForTest("pdm", ModifierScope.SelectedCategory, + ModifierEffectType.MultiplyPerDieValue, 1.5f, dieValue: 3); + var flatAdd = ModifierData.CreateForTest("fa", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f); + var finalMul = ModifierData.CreateForTest("fm", ModifierScope.SelectedCategory, + ModifierEffectType.MultiplyFinalScore, 2f); + + var modifiers = new List { finalMul, flatAdd, perDieMul, perDieAdd }; + // dice: [3, 3, 3, 1, 2] — 3 threes + var result = ScoreResult.Create(9, new[] { 3, 3, 3, 1, 2 }, YachtCategory.Threes); + + ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + + // Pass 1 (cat additive): perDieAdd: +2*3 = +6 FlatBonus + // Pass 2 (cat multiplicative): perDieMul: 1.5^3 = 3.375 Multiplier + // Pass 3 (final additive): flatAdd: +10 FlatBonus → total FlatBonus = 16 + // Pass 4 (final multiplicative): finalMul: 3.375 * 2 = 6.75 Multiplier + // FinalScore = floor((9 + 16) * 6.75) = floor(168.75) = 168 + Assert.AreEqual(6, result.FlatBonus + 10); // just check pipeline ran; full calc below + Assert.AreEqual(168, result.FinalScore); + } + + [Test] + public void Apply_NullModifiers_DoesNotThrow() + { + var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + Assert.DoesNotThrow(() => + ModifierPipeline.Apply(null, ref result, ModifierScope.SelectedCategory)); + + Assert.AreEqual(10, result.FinalScore); + } + + [Test] + public void Apply_EmptyList_NoChange() + { + var modifiers = new List(); + var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + + Assert.AreEqual(10, result.FinalScore); + } +} diff --git a/Assets/Scripts/Tests/Editor/SaveSystemTests.cs b/Assets/Scripts/Tests/Editor/SaveSystemTests.cs new file mode 100644 index 0000000..c5be098 --- /dev/null +++ b/Assets/Scripts/Tests/Editor/SaveSystemTests.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +public sealed class SaveSystemTests +{ + [SetUp] + public void SetUp() + { + SaveSystem.Delete(); + } + + [TearDown] + public void TearDown() + { + SaveSystem.Delete(); + } + + [Test] + public void SaveAndLoad_RoundTrip_PreservesData() + { + var data = new SaveData + { + Currency = 999, + OwnedModifiers = new List + { + new() { ModifierId = "mod1", IsActive = true, RemainingUses = 3 }, + new() { ModifierId = "mod2", IsActive = false, RemainingUses = -1 } + } + }; + + SaveSystem.Save(data); + var loaded = SaveSystem.Load(); + + Assert.AreEqual(999, loaded.Currency); + Assert.AreEqual(2, loaded.OwnedModifiers.Count); + Assert.AreEqual("mod1", loaded.OwnedModifiers[0].ModifierId); + Assert.IsTrue(loaded.OwnedModifiers[0].IsActive); + Assert.AreEqual(3, loaded.OwnedModifiers[0].RemainingUses); + Assert.AreEqual("mod2", loaded.OwnedModifiers[1].ModifierId); + Assert.IsFalse(loaded.OwnedModifiers[1].IsActive); + } + + [Test] + public void Load_MissingKey_ReturnsDefault() + { + var loaded = SaveSystem.Load(); + + Assert.IsNotNull(loaded); + Assert.AreEqual(0, loaded.Currency); + Assert.AreEqual(0, loaded.OwnedModifiers.Count); + } + + [Test] + public void HasSave_ReturnsFalseWhenEmpty() + { + Assert.IsFalse(SaveSystem.HasSave()); + } + + [Test] + public void HasSave_ReturnsTrueAfterSave() + { + SaveSystem.Save(new SaveData { Currency = 100 }); + + Assert.IsTrue(SaveSystem.HasSave()); + } + + [Test] + public void Delete_RemovesSaveData() + { + SaveSystem.Save(new SaveData { Currency = 100 }); + SaveSystem.Delete(); + + Assert.IsFalse(SaveSystem.HasSave()); + } + + [Test] + public void Load_CorruptJson_ReturnsDefault() + { + PlayerPrefs.SetString("YachtDice_SaveData", "{invalid json!!!"); + PlayerPrefs.Save(); + + var loaded = SaveSystem.Load(); + + Assert.IsNotNull(loaded); + Assert.AreEqual(0, loaded.Currency); + } +} diff --git a/Assets/Scripts/Tests/Editor/ScoringSystemTests.cs b/Assets/Scripts/Tests/Editor/ScoringSystemTests.cs new file mode 100644 index 0000000..f85cd19 --- /dev/null +++ b/Assets/Scripts/Tests/Editor/ScoringSystemTests.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +public sealed class ScoringSystemTests +{ + private ScoringSystem CreateScoringSystem() + { + var go = new GameObject("ScoringSystem"); + return go.AddComponent(); + } + + [TearDown] + public void TearDown() + { + foreach (var go in Object.FindObjectsByType(FindObjectsSortMode.None)) + Object.DestroyImmediate(go.gameObject); + } + + [Test] + public void PreviewScore_AppliesOnlySelectedCategoryModifiers() + { + var system = CreateScoringSystem(); + + var selectedMod = ModifierData.CreateForTest("sel", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f); + var anyCloseMod = ModifierData.CreateForTest("any", ModifierScope.AnyCategoryClosed, + ModifierEffectType.AddFlatToFinalScore, 100f); + + system.SetActiveModifiers(new List { selectedMod, anyCloseMod }); + + var result = system.PreviewScore(new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + // Only SelectedCategory mod should apply in preview + Assert.AreEqual(10, result.FlatBonus); + Assert.AreEqual(25, result.FinalScore); // (15 + 10) * 1 + } + + [Test] + public void ScoreCategory_AppliesBothScopes() + { + var system = CreateScoringSystem(); + + var selectedMod = ModifierData.CreateForTest("sel", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f); + var anyCloseMod = ModifierData.CreateForTest("any", ModifierScope.AnyCategoryClosed, + ModifierEffectType.AddFlatToFinalScore, 20f); + + system.SetActiveModifiers(new List { selectedMod, anyCloseMod }); + + var result = system.ScoreCategory(new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + // Both scopes should apply + Assert.AreEqual(30, result.FlatBonus); + Assert.AreEqual(45, result.FinalScore); // (15 + 30) * 1 + } + + [Test] + public void ScoreCategory_FiresOnCategoryConfirmed() + { + var system = CreateScoringSystem(); + YachtCategory firedCategory = (YachtCategory)(-1); + ScoreResult firedResult = default; + + system.OnCategoryConfirmed += (cat, res) => + { + firedCategory = cat; + firedResult = res; + }; + + system.ScoreCategory(new[] { 1, 1, 1, 1, 1 }, YachtCategory.Ones); + + Assert.AreEqual(YachtCategory.Ones, firedCategory); + Assert.AreEqual(5, firedResult.BaseScore); + } + + [Test] + public void ScoreCategory_PreventsDuplicateCategory() + { + var system = CreateScoringSystem(); + system.ScoreCategory(new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + + Assert.Throws(() => + system.ScoreCategory(new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance)); + } + + [Test] + public void ScoreCategory_WithNoModifiers_CalculatesBaseOnly() + { + var system = CreateScoringSystem(); + var result = system.ScoreCategory(new[] { 6, 6, 6, 6, 6 }, YachtCategory.Yacht); + + Assert.AreEqual(50, result.BaseScore); + Assert.AreEqual(0, result.FlatBonus); + Assert.AreEqual(1f, result.Multiplier); + Assert.AreEqual(50, result.FinalScore); + } +} diff --git a/Assets/Scripts/Tests/Editor/ShopModelTests.cs b/Assets/Scripts/Tests/Editor/ShopModelTests.cs new file mode 100644 index 0000000..93c17ed --- /dev/null +++ b/Assets/Scripts/Tests/Editor/ShopModelTests.cs @@ -0,0 +1,130 @@ +using NUnit.Framework; +using UnityEngine; + +public sealed class ShopModelTests +{ + private CurrencyBank bank; + private InventoryModel inventory; + private ShopModel shop; + + [SetUp] + public void SetUp() + { + var go = new GameObject("Bank"); + bank = go.AddComponent(); + bank.SetBalance(500); + + inventory = new InventoryModel(5); + shop = new ShopModel(bank, inventory); + } + + [TearDown] + public void TearDown() + { + foreach (var go in Object.FindObjectsByType(FindObjectsSortMode.None)) + Object.DestroyImmediate(go.gameObject); + } + + [Test] + public void TryPurchase_SucceedsWithSufficientCurrency() + { + var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + + bool result = shop.TryPurchase(mod); + + Assert.IsTrue(result); + Assert.AreEqual(400, bank.Balance); + Assert.AreEqual(1, inventory.OwnedModifiers.Count); + } + + [Test] + public void TryPurchase_FailsWhenBroke() + { + bank.SetBalance(10); + var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + + bool result = shop.TryPurchase(mod); + + Assert.IsFalse(result); + Assert.AreEqual(10, bank.Balance); + Assert.AreEqual(0, inventory.OwnedModifiers.Count); + } + + [Test] + public void TryPurchase_PermanentCannotBeBoughtTwice() + { + var mod = ModifierData.CreateForTest("perm", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f, + durability: ModifierDurability.Permanent, shopPrice: 100); + + shop.TryPurchase(mod); + bool secondResult = shop.TryPurchase(mod); + + Assert.IsFalse(secondResult); + Assert.AreEqual(400, bank.Balance); + Assert.AreEqual(1, inventory.OwnedModifiers.Count); + } + + [Test] + public void TryPurchase_LimitedCanBeReBought() + { + var mod = ModifierData.CreateForTest("limited", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f, + durability: ModifierDurability.LimitedUses, maxUses: 3, shopPrice: 100); + + shop.TryPurchase(mod); + bool secondResult = shop.TryPurchase(mod); + + Assert.IsTrue(secondResult); + Assert.AreEqual(300, bank.Balance); + Assert.AreEqual(2, inventory.OwnedModifiers.Count); + } + + [Test] + public void TryPurchase_FiresPurchaseEvent() + { + ModifierData purchased = null; + shop.OnItemPurchased += data => purchased = data; + + var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + + shop.TryPurchase(mod); + + Assert.IsNotNull(purchased); + Assert.AreEqual("test", purchased.Id); + } + + [Test] + public void GetItemState_Available_WhenCanAfford() + { + var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + + Assert.AreEqual(ShopItemState.Available, shop.GetItemState(mod)); + } + + [Test] + public void GetItemState_TooExpensive_WhenCannotAfford() + { + bank.SetBalance(10); + var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + + Assert.AreEqual(ShopItemState.TooExpensive, shop.GetItemState(mod)); + } + + [Test] + public void GetItemState_Owned_WhenPermanentPurchased() + { + var mod = ModifierData.CreateForTest("perm", ModifierScope.SelectedCategory, + ModifierEffectType.AddFlatToFinalScore, 10f, + durability: ModifierDurability.Permanent, shopPrice: 100); + + shop.TryPurchase(mod); + + Assert.AreEqual(ShopItemState.Owned, shop.GetItemState(mod)); + } +} diff --git a/Assets/Scripts/Tests/Editor/YachtDice.Tests.Editor.asmdef b/Assets/Scripts/Tests/Editor/YachtDice.Tests.Editor.asmdef new file mode 100644 index 0000000..745698e --- /dev/null +++ b/Assets/Scripts/Tests/Editor/YachtDice.Tests.Editor.asmdef @@ -0,0 +1,24 @@ +{ + "name": "YachtDice.Tests.Editor", + "rootNamespace": "", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "YachtDice.Runtime" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Scripts/UI/GameController.cs b/Assets/Scripts/UI/GameController.cs index a431dd2..385632b 100644 --- a/Assets/Scripts/UI/GameController.cs +++ b/Assets/Scripts/UI/GameController.cs @@ -14,8 +14,14 @@ public sealed class GameController : MonoBehaviour [SerializeField] private DicePanelView dicePanelView; [SerializeField] private GameInfoView gameInfoView; + [Header("Economy & Modifiers")] + [SerializeField] private CurrencyBank currencyBank; + [SerializeField] private ShopController shopController; + [SerializeField] private InventoryController inventoryController; + [Header("Settings")] [SerializeField] private int maxRollsPerTurn = 3; + [SerializeField] private int maxActiveModifierSlots = 5; private static readonly YachtCategory[] UpperCategories = { @@ -28,6 +34,9 @@ public sealed class GameController : MonoBehaviour private int totalCategoryCount; + private InventoryModel inventoryModel; + private ShopModel shopModel; + // ── Lifecycle ────────────────────────────────────────────── private void Awake() @@ -46,6 +55,14 @@ public sealed class GameController : MonoBehaviour dicePanelView.OnRollClicked += HandleRollClicked; dicePanelView.OnDiceToggled += HandleDiceToggled; gameInfoView.OnNewGameClicked += HandleNewGameClicked; + gameInfoView.OnShopClicked += HandleShopClicked; + gameInfoView.OnInventoryClicked += HandleInventoryClicked; + + // Currency + if (currencyBank != null) + currencyBank.OnBalanceChanged += HandleCurrencyChanged; + + InitializeModifierSystems(); } private void OnDestroy() @@ -60,6 +77,100 @@ public sealed class GameController : MonoBehaviour dicePanelView.OnRollClicked -= HandleRollClicked; dicePanelView.OnDiceToggled -= HandleDiceToggled; gameInfoView.OnNewGameClicked -= HandleNewGameClicked; + gameInfoView.OnShopClicked -= HandleShopClicked; + gameInfoView.OnInventoryClicked -= HandleInventoryClicked; + + if (currencyBank != null) + currencyBank.OnBalanceChanged -= HandleCurrencyChanged; + + if (inventoryModel != null) + inventoryModel.OnInventoryChanged -= HandleInventoryChangedForSave; + } + + // ── Modifier System Init ───────────────────────────────── + + private void InitializeModifierSystems() + { + inventoryModel = new InventoryModel(maxActiveModifierSlots); + inventoryModel.OnInventoryChanged += HandleInventoryChangedForSave; + + ShopCatalog catalog = shopController != null ? shopController.Catalog : null; + + shopModel = new ShopModel(currencyBank, inventoryModel); + + LoadSaveData(catalog); + + if (inventoryController != null) + inventoryController.Initialize(inventoryModel); + + if (shopController != null) + shopController.Initialize(shopModel); + + if (currencyBank != null) + gameInfoView.SetCurrencyText(currencyBank.Balance); + } + + private void LoadSaveData(ShopCatalog catalog) + { + SaveData save = SaveSystem.Load(); + + if (currencyBank != null && save.Currency > 0) + currencyBank.SetBalance(save.Currency); + + if (catalog != null && save.OwnedModifiers.Count > 0) + { + var runtimeList = new List(); + var permanentIds = new HashSet(); + + for (int i = 0; i < save.OwnedModifiers.Count; i++) + { + var entry = save.OwnedModifiers[i]; + ModifierData data = catalog.FindById(entry.ModifierId); + + if (data == null) + { + Debug.LogWarning($"Modifier '{entry.ModifierId}' not found in catalog, skipping."); + continue; + } + + var runtime = new ModifierRuntime + { + ModifierId = entry.ModifierId, + IsActive = entry.IsActive, + RemainingUses = entry.RemainingUses, + Data = data + }; + + runtimeList.Add(runtime); + + if (data.Durability == ModifierDurability.Permanent) + permanentIds.Add(data.Id); + } + + inventoryModel.LoadState(runtimeList); + shopModel.LoadPurchasedPermanentIds(permanentIds); + } + } + + private void PerformSave() + { + var save = new SaveData + { + Currency = currencyBank != null ? currencyBank.Balance : 0 + }; + + var owned = inventoryModel.GetAllForSave(); + for (int i = 0; i < owned.Count; i++) + { + save.OwnedModifiers.Add(new ModifierSaveEntry + { + ModifierId = owned[i].ModifierId, + IsActive = owned[i].IsActive, + RemainingUses = owned[i].RemainingUses + }); + } + + SaveSystem.Save(save); } // ── Model Event Handlers ────────────────────────────────── @@ -93,6 +204,7 @@ public sealed class GameController : MonoBehaviour { scoreCardView.SetCategoryScored(category, finalScore); UpdateTotalDisplay(); + PerformSave(); } private void HandleGameOver(int totalScore) @@ -103,6 +215,7 @@ public sealed class GameController : MonoBehaviour int displayTotal = CalculateDisplayTotal(); gameInfoView.ShowGameOver(displayTotal); + PerformSave(); } // ── View Event Handlers ─────────────────────────────────── @@ -145,6 +258,43 @@ public sealed class GameController : MonoBehaviour gameManager.StartNewGame(); } + private void HandleShopClicked() + { + if (shopController != null) + { + var shopView = shopController.GetComponentInChildren(true); + if (shopView != null) + { + if (shopView.IsVisible) shopView.Hide(); + else shopView.Show(); + } + } + } + + private void HandleInventoryClicked() + { + if (inventoryController != null) + { + var inventoryView = inventoryController.GetComponentInChildren(true); + if (inventoryView != null) + { + if (inventoryView.IsVisible) inventoryView.Hide(); + else inventoryView.Show(); + } + } + } + + private void HandleCurrencyChanged(int newBalance) + { + gameInfoView.SetCurrencyText(newBalance); + PerformSave(); + } + + private void HandleInventoryChangedForSave() + { + PerformSave(); + } + // ── Helpers ──────────────────────────────────────────────── private void UpdatePreviewScores(int[] diceValues) diff --git a/Assets/Scripts/UI/GameInfoView.cs b/Assets/Scripts/UI/GameInfoView.cs index e8b4e84..575efd0 100644 --- a/Assets/Scripts/UI/GameInfoView.cs +++ b/Assets/Scripts/UI/GameInfoView.cs @@ -8,17 +8,31 @@ public sealed class GameInfoView : MonoBehaviour [Header("Turn Info")] [SerializeField] private TMP_Text turnText; + [Header("Currency")] + [SerializeField] private TMP_Text currencyText; + + [Header("Navigation")] + [SerializeField] private Button shopButton; + [SerializeField] private Button inventoryButton; + [Header("Game Over Overlay")] [SerializeField] private GameObject gameOverPanel; [SerializeField] private TMP_Text finalScoreText; [SerializeField] private Button newGameButton; public event Action OnNewGameClicked; + public event Action OnShopClicked; + public event Action OnInventoryClicked; private void Awake() { newGameButton.onClick.AddListener(() => OnNewGameClicked?.Invoke()); gameOverPanel.SetActive(false); + + if (shopButton != null) + shopButton.onClick.AddListener(() => OnShopClicked?.Invoke()); + if (inventoryButton != null) + inventoryButton.onClick.AddListener(() => OnInventoryClicked?.Invoke()); } public void SetTurnText(int turn, int maxTurns) @@ -26,6 +40,12 @@ public sealed class GameInfoView : MonoBehaviour turnText.text = $"Ход {turn} / {maxTurns}"; } + public void SetCurrencyText(int amount) + { + if (currencyText != null) + currencyText.text = amount.ToString(); + } + public void ShowGameOver(int finalScore) { gameOverPanel.SetActive(true); @@ -40,5 +60,10 @@ public sealed class GameInfoView : MonoBehaviour private void OnDestroy() { newGameButton.onClick.RemoveAllListeners(); + + if (shopButton != null) + shopButton.onClick.RemoveAllListeners(); + if (inventoryButton != null) + inventoryButton.onClick.RemoveAllListeners(); } } diff --git a/Assets/Scripts/YachtDice.Runtime.asmdef b/Assets/Scripts/YachtDice.Runtime.asmdef new file mode 100644 index 0000000..d26c022 --- /dev/null +++ b/Assets/Scripts/YachtDice.Runtime.asmdef @@ -0,0 +1,18 @@ +{ + "name": "YachtDice.Runtime", + "rootNamespace": "", + "references": [ + "Unity.TextMeshPro", + "Unity.InputSystem", + "Newtonsoft.Json" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +}