[Refactor] Replace hardcoded categories with data-driven SO system and abstract dice

- Add abstract dice system (IDie interface, DieDefinitionSO, StandardDieSO, DieInstance)
  to support future custom dice types while keeping backward compat via int[] DiceValues
- Replace YachtCategory enum and CategoryScorer switch with CategoryDefinitionSO hierarchy:
  SumOfValueCategorySO, NOfAKindCategorySO, FullHouseCategorySO, StraightCategorySO, SumAllCategorySO
- Add CategoryCatalogSO for ordered category collections and DiceCheckUtility for shared logic
- Refactor ScoringSystem, Views, GameManager, GameController to use SO references
- Update CategoryCondition modifier to use SO reference instead of enum
- Update all editor tests to use SO-based categories and DieInstance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 11:46:50 +07:00
parent 6a48d68f75
commit 0f9b162061
31 changed files with 845 additions and 298 deletions
@@ -0,0 +1,48 @@
using System.Collections.Generic;
using UnityEngine;
namespace YachtDice.Categories
{
/// <summary>
/// Каталог всех доступных категорий.
/// Порядок определяет порядок отображения в UI.
/// Аналог ModifierCatalogSO.
/// </summary>
[CreateAssetMenu(fileName = "CategoryCatalog", menuName = "YachtDice/Categories/Catalog")]
public class CategoryCatalogSO : ScriptableObject
{
[SerializeField] private List<CategoryDefinitionSO> categories = new();
public IReadOnlyList<CategoryDefinitionSO> All => categories;
public int Count => categories.Count;
public CategoryDefinitionSO FindById(string id)
{
for (int i = 0; i < categories.Count; i++)
{
if (categories[i] != null && categories[i].Id == id)
return categories[i];
}
return null;
}
public int IndexOf(CategoryDefinitionSO def)
{
for (int i = 0; i < categories.Count; i++)
{
if (categories[i] == def)
return i;
}
return -1;
}
#if UNITY_EDITOR
public static CategoryCatalogSO CreateForTest(List<CategoryDefinitionSO> defs)
{
var catalog = CreateInstance<CategoryCatalogSO>();
catalog.categories = defs ?? new List<CategoryDefinitionSO>();
return catalog;
}
#endif
}
}
@@ -0,0 +1,42 @@
using System.Collections.Generic;
using UnityEngine;
using YachtDice.Dice;
namespace YachtDice.Categories
{
/// <summary>
/// Абстрактное определение категории для скоринга.
/// Каждая категория знает как вычислить очки по набору дайсов.
/// </summary>
public abstract class CategoryDefinitionSO : ScriptableObject
{
[Header("Identity")]
[SerializeField] private string id;
[SerializeField] private string displayName;
[SerializeField, TextArea] private string description;
[SerializeField] private Sprite icon;
[Header("Section")]
[SerializeField] private bool isUpperSection;
public string Id => id;
public string DisplayName => displayName;
public string Description => description;
public Sprite Icon => icon;
public bool IsUpperSection => isUpperSection;
/// <summary>
/// Вычисляет очки для данного набора дайсов.
/// </summary>
public abstract int Calculate(IReadOnlyList<IDie> dice);
#if UNITY_EDITOR
public void SetTestData(string testId, string testDisplayName, bool upperSection = false)
{
id = testId;
displayName = testDisplayName;
isUpperSection = upperSection;
}
#endif
}
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using UnityEngine;
using YachtDice.Dice;
namespace YachtDice.Categories
{
/// <summary>
/// Категория Фулл-хаус: 3 одинаковых + 2 одинаковых.
/// При совпадении возвращает фиксированное число очков.
/// </summary>
[CreateAssetMenu(fileName = "FullHouseCategory", menuName = "YachtDice/Categories/Full House")]
public class FullHouseCategorySO : CategoryDefinitionSO
{
[Header("Scoring")]
[Tooltip("Фиксированное число очков за фулл-хаус")]
[SerializeField] private int fixedScore = 25;
public override int Calculate(IReadOnlyList<IDie> dice)
{
int[] values = DiceCheckUtility.ExtractValues(dice);
return DiceCheckUtility.IsFullHouse(values) ? fixedScore : 0;
}
#if UNITY_EDITOR
public static FullHouseCategorySO CreateForTest(string id, string displayName, int score = 25)
{
var so = CreateInstance<FullHouseCategorySO>();
so.SetTestData(id, displayName);
so.fixedScore = score;
return so;
}
#endif
}
}
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using UnityEngine;
using YachtDice.Dice;
namespace YachtDice.Categories
{
/// <summary>
/// Категория N-одинаковых: проверяет наличие N дайсов с одинаковым значением.
/// При успехе возвращает сумму всех дайсов или фиксированное число очков.
/// Используется для Тройки (3, сумма), Каре (4, сумма), Яхты (5, fixed=50).
/// </summary>
[CreateAssetMenu(fileName = "NOfAKindCategory", menuName = "YachtDice/Categories/N Of A Kind")]
public class NOfAKindCategorySO : CategoryDefinitionSO
{
[Header("Scoring")]
[Tooltip("Сколько одинаковых дайсов требуется")]
[SerializeField, Range(2, 6)] private int requiredCount = 3;
[Tooltip("Использовать фиксированное число очков вместо суммы")]
[SerializeField] private bool useFixedScore;
[Tooltip("Фиксированное число очков (если useFixedScore = true)")]
[SerializeField] private int fixedScore;
public override int Calculate(IReadOnlyList<IDie> dice)
{
int[] values = DiceCheckUtility.ExtractValues(dice);
if (!DiceCheckUtility.NOfAKind(values, requiredCount))
return 0;
return useFixedScore ? fixedScore : DiceCheckUtility.Sum(values);
}
#if UNITY_EDITOR
public static NOfAKindCategorySO CreateForTest(string id, string displayName, int count, bool fixedScoreMode = false, int score = 0)
{
var so = CreateInstance<NOfAKindCategorySO>();
so.SetTestData(id, displayName);
so.requiredCount = count;
so.useFixedScore = fixedScoreMode;
so.fixedScore = score;
return so;
}
#endif
}
}
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using UnityEngine;
using YachtDice.Dice;
namespace YachtDice.Categories
{
/// <summary>
/// Категория Стрит: последовательность заданной длины.
/// При совпадении возвращает фиксированное число очков.
/// Малый стрит: runLength=4, fixedScore=30. Большой стрит: runLength=5, fixedScore=40.
/// </summary>
[CreateAssetMenu(fileName = "StraightCategory", menuName = "YachtDice/Categories/Straight")]
public class StraightCategorySO : CategoryDefinitionSO
{
[Header("Scoring")]
[Tooltip("Требуемая длина последовательности")]
[SerializeField, Range(3, 6)] private int runLength = 4;
[Tooltip("Фиксированное число очков")]
[SerializeField] private int fixedScore = 30;
public override int Calculate(IReadOnlyList<IDie> dice)
{
int[] values = DiceCheckUtility.ExtractValues(dice);
return DiceCheckUtility.HasStraightRun(values, runLength) ? fixedScore : 0;
}
#if UNITY_EDITOR
public static StraightCategorySO CreateForTest(string id, string displayName, int run, int score)
{
var so = CreateInstance<StraightCategorySO>();
so.SetTestData(id, displayName);
so.runLength = run;
so.fixedScore = score;
return so;
}
#endif
}
}
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using UnityEngine;
using YachtDice.Dice;
namespace YachtDice.Categories
{
/// <summary>
/// Категория «Шанс»: суммирует все дайсы без условий.
/// </summary>
[CreateAssetMenu(fileName = "SumAllCategory", menuName = "YachtDice/Categories/Sum All (Chance)")]
public class SumAllCategorySO : CategoryDefinitionSO
{
public override int Calculate(IReadOnlyList<IDie> dice)
{
int sum = 0;
for (int i = 0; i < dice.Count; i++)
sum += dice[i].Value;
return sum;
}
#if UNITY_EDITOR
public static SumAllCategorySO CreateForTest(string id, string displayName)
{
var so = CreateInstance<SumAllCategorySO>();
so.SetTestData(id, displayName);
return so;
}
#endif
}
}
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using UnityEngine;
using YachtDice.Dice;
namespace YachtDice.Categories
{
/// <summary>
/// Категория верхней секции: суммирует все дайсы с заданным значением.
/// Используется для Единиц (1), Двоек (2), ... Шестёрок (6).
/// </summary>
[CreateAssetMenu(fileName = "SumOfValueCategory", menuName = "YachtDice/Categories/Sum Of Value")]
public class SumOfValueCategorySO : CategoryDefinitionSO
{
[Header("Scoring")]
[Tooltip("Значение грани для суммирования (1-6)")]
[SerializeField, Range(1, 6)] private int targetValue = 1;
public int TargetValue => targetValue;
public override int Calculate(IReadOnlyList<IDie> dice)
{
int sum = 0;
for (int i = 0; i < dice.Count; i++)
if (dice[i].Value == targetValue) sum += targetValue;
return sum;
}
#if UNITY_EDITOR
public static SumOfValueCategorySO CreateForTest(string id, string displayName, int target)
{
var so = CreateInstance<SumOfValueCategorySO>();
so.SetTestData(id, displayName, upperSection: true);
so.targetValue = target;
return so;
}
#endif
}
}
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using YachtDice.Dice;
namespace YachtDice.Categories
{
/// <summary>
/// Статические хелперы для проверки комбинаций дайсов.
/// Перенесены из CategoryScorer для переиспользования в конкретных SO-категориях.
/// </summary>
public static class DiceCheckUtility
{
/// <summary>Извлекает массив значений из абстрактных дайсов.</summary>
public static int[] ExtractValues(IReadOnlyList<IDie> dice)
{
int[] values = new int[dice.Count];
for (int i = 0; i < dice.Count; i++)
values[i] = dice[i].Value;
return values;
}
/// <summary>Сумма дайсов, показывающих конкретное значение.</summary>
public static int SumOfValue(int[] values, int target)
{
int sum = 0;
for (int i = 0; i < values.Length; i++)
if (values[i] == target) sum += target;
return sum;
}
/// <summary>Сумма всех дайсов.</summary>
public static int Sum(int[] values)
{
int sum = 0;
for (int i = 0; i < values.Length; i++) sum += values[i];
return sum;
}
/// <summary>Есть ли N или более одинаковых значений.</summary>
public static bool NOfAKind(int[] values, int n)
{
int[] counts = new int[7];
for (int i = 0; i < values.Length; i++) counts[values[i]]++;
for (int v = 1; v <= 6; v++)
if (counts[v] >= n) return true;
return false;
}
/// <summary>Проверяет фулл-хаус (3 + 2 одинаковых).</summary>
public static bool IsFullHouse(int[] values)
{
int[] counts = new int[7];
for (int i = 0; i < values.Length; i++) counts[values[i]]++;
bool hasTwo = false, hasThree = false;
for (int v = 1; v <= 6; v++)
{
if (counts[v] == 2) hasTwo = true;
if (counts[v] == 3) hasThree = true;
}
return hasTwo && hasThree;
}
/// <summary>Есть ли последовательность заданной длины.</summary>
public static bool HasStraightRun(int[] values, int runLength)
{
bool[] present = new bool[7];
for (int i = 0; i < values.Length; i++) present[values[i]] = true;
int consecutive = 0;
for (int v = 1; v <= 6; v++)
{
consecutive = present[v] ? consecutive + 1 : 0;
if (consecutive >= runLength) return true;
}
return false;
}
}
}