[Add] Universal modifier system, shop, inventory & persistence

Replace hardcoded BonusForOnes/MultiplierForSixes with data-driven
modifier system supporting 2 scopes (SelectedCategory, AnyCategoryClosed),
4 effect types, durability modes (Permanent, LimitedUses), and
configurable targets via ScriptableObject (ModifierData).

- Modifier domain: ModifierEnums, ModifierTarget, ModifierData,
  ModifierRuntime, ModifierEffect (dict-based strategy), ModifierPipeline
  (4-pass: cat-additive → cat-multiplicative → final-additive → final-multiplicative)
- ScoringSystem: replaced old modifier list with ModifierPipeline integration,
  added OnCategoryConfirmed event
- Shop MVC: ShopCatalog (SO), ShopModel, ShopView, ShopItemView, ShopController
- Inventory MVC: InventoryModel (activate/deactivate/sell/durability),
  InventoryView, InventorySlotView, InventoryController
- CurrencyBank: editor-adjustable balance with events
- Persistence: SaveData + SaveSystem (Newtonsoft JSON + PlayerPrefs)
- Editor: ModifierAssetCreator menu item to generate 6 example modifiers + catalog
- Tests: 6 test classes covering effects, pipeline, scoring, shop, inventory, save
- GameController: wired shop/inventory/save lifecycle
- GameInfoView: added currency display, shop/inventory toggle buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 06:40:33 +07:00
parent 4f8db3158f
commit ba626acb9b
33 changed files with 2123 additions and 86 deletions
+123
View File
@@ -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<ShopCatalog>();
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<ModifierData>();
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
+44
View File
@@ -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<int> 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);
}
}
@@ -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<ModifierData> 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);
}
}
+127
View File
@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
public sealed class InventoryModel
{
private readonly List<ModifierRuntime> ownedModifiers = new();
private int maxActiveSlots;
public IReadOnlyList<ModifierRuntime> OwnedModifiers => ownedModifiers;
public int MaxActiveSlots => maxActiveSlots;
public event Action OnInventoryChanged;
public event Action<List<ModifierData>> 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<ModifierData> GetActiveModifierData()
{
var result = new List<ModifierData>();
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<ModifierRuntime> loaded)
{
ownedModifiers.Clear();
if (loaded != null)
ownedModifiers.AddRange(loaded);
OnActiveModifiersChanged?.Invoke(GetActiveModifierData());
OnInventoryChanged?.Invoke();
}
public List<ModifierRuntime> GetAllForSave() => new(ownedModifiers);
}
@@ -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<ModifierRuntime> OnActivateClicked;
public event Action<ModifierRuntime> OnDeactivateClicked;
public event Action<ModifierRuntime> 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;
}
}
+74
View File
@@ -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<InventorySlotView> spawnedSlots = new();
public event Action<ModifierRuntime> OnActivateClicked;
public event Action<ModifierRuntime> OnDeactivateClicked;
public event Action<ModifierRuntime> 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<ModifierRuntime> 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);
}
-18
View File
@@ -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;
}
}
+88
View File
@@ -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<ModifierData>();
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
}
@@ -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<ModifierEffectType, ModifierHandler> 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;
}
}
+27
View File
@@ -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
}
@@ -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<ModifierData> 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;
}
}
@@ -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
};
}
}
@@ -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;
}
@@ -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;
}
}
-19
View File
@@ -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);
}
+18
View File
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
[Serializable]
public sealed class SaveData
{
public int Version = 1;
public int Currency;
public List<ModifierSaveEntry> OwnedModifiers = new();
}
[Serializable]
public sealed class ModifierSaveEntry
{
public string ModifierId;
public bool IsActive;
public int RemainingUses;
}
+42
View File
@@ -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<SaveData>(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);
}
}
+14 -33
View File
@@ -4,14 +4,13 @@ using UnityEngine;
public sealed class ScoringSystem : MonoBehaviour
{
[Header("Modifiers")]
[SerializeField] private List<ScoreModifier> activeModifiers = new();
public event Action<YachtCategory, int> OnCategoryScored;
public event Action<int> OnAllCategoriesScored;
public event Action<YachtCategory, ScoreResult> OnCategoryConfirmed;
private readonly Dictionary<YachtCategory, int> scorecard = new();
private readonly HashSet<YachtCategory> usedCategories = new();
private List<ModifierData> 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<ModifierData> modifiers)
{
if (modifier != null && !activeModifiers.Contains(modifier))
activeModifiers.Add(modifier);
activeModifierData = modifiers ?? new List<ModifierData>();
}
public void RemoveModifier(ScoreModifier modifier)
{
activeModifiers.Remove(modifier);
}
public IReadOnlyList<ScoreModifier> ActiveModifiers => activeModifiers;
public IReadOnlyList<ModifierData> 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();
+20
View File
@@ -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<ModifierData> availableModifiers = new();
public IReadOnlyList<ModifierData> 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;
}
}
+55
View File
@@ -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);
}
}
+87
View File
@@ -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<ModifierData> 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
};
}
}
+78
View File
@@ -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<string> purchasedPermanentIds = new();
public event Action<ModifierData> 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<string> ids)
{
purchasedPermanentIds.Clear();
if (ids != null)
foreach (var id in ids) purchasedPermanentIds.Add(id);
}
public HashSet<string> GetPurchasedPermanentIds() => new(purchasedPermanentIds);
}
public enum ShopItemState
{
Available,
TooExpensive,
Owned,
RepurchaseAvailable
}
+77
View File
@@ -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<ShopItemView> spawnedItems = new();
public event Action<ModifierData> 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<ModifierData> 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<ModifierData> 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);
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<ModifierData> { 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<ModifierData> { 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<ModifierData> { 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<ModifierData> { 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<ModifierData> { 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<ModifierData> { 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<ModifierData>();
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);
}
}
@@ -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<ModifierSaveEntry>
{
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);
}
}
@@ -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<ScoringSystem>();
}
[TearDown]
public void TearDown()
{
foreach (var go in Object.FindObjectsByType<ScoringSystem>(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<ModifierData> { 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<ModifierData> { 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.InvalidOperationException>(() =>
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);
}
}
@@ -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<CurrencyBank>();
bank.SetBalance(500);
inventory = new InventoryModel(5);
shop = new ShopModel(bank, inventory);
}
[TearDown]
public void TearDown()
{
foreach (var go in Object.FindObjectsByType<CurrencyBank>(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));
}
}
@@ -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
}
+150
View File
@@ -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<ModifierRuntime>();
var permanentIds = new HashSet<string>();
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<ShopView>(true);
if (shopView != null)
{
if (shopView.IsVisible) shopView.Hide();
else shopView.Show();
}
}
}
private void HandleInventoryClicked()
{
if (inventoryController != null)
{
var inventoryView = inventoryController.GetComponentInChildren<InventoryView>(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)
+25
View File
@@ -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();
}
}
+18
View File
@@ -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
}