ItemsSystem

Назначение

Модель предмета с набором свойств, формируемым при настройке, а не фиксированным в коде.

  • Предмет — многоэкземплярная запись БД с полиморфным составом свойств.
  • Классы свойств объявляются вне пакета; пакет о конкретных свойствах не знает.
  • Доступ к свойству идёт по интерфейсу назначения за O(1).
  • Настроечные данные и игровое состояние разделены: правки баланса доходят до уже существующих у игрока предметов.
  • Одно событие OnChanged даёт потребителям реактивность без полинга.
  • Состав меняется в рантайме и переживает сохранение.

Вне ответственности: инвентарь, витрина, стаки, масса, объём, количество. Пакет описывает предмет и его свойства — не их размещение и не их суммирование.

Зависимости

Пакет собирается при включённом тумблере itemsSdk в SdkSettings (define-символ USING_VORTEX_ITEMS).

Сборка: ru.vortex.sdk.itemssystem. Требуется Odin Inspector.

Архитектура

Единица Роль
ItemPreset Ассет-настройка: состав свойств, ключ категории. Единственное место авторинга
ItemModel Экземпляр предмета. Два словаря свойств, событие OnChanged, разрешённая категория
ItemProperty База свойства. Тип хранения в составе
IItemProperty Опорный маркер интерфейсов назначения. Тип запроса — производные от него
ItemCategory Расширяемое перечисление категорий. Доменные значения объявляются вне пакета, в самом пакете есть только Unknown
ItemsBus Статический контроллер: построение предмета, изменение состава свойств

Два контейнера свойств

Свойства предмета лежат в двух непубличных словарях.

Контейнер Ключ Сохраняется Назначение
Состав конкретный класс свойства да слияние при загрузке, каждое свойство ровно один раз
Индекс интерфейс назначения нет поиск за O(1), пересобирается при построении и изменении состава

Из такого хранения следует главный инвариант: не более одного свойства на интерфейс назначения. Он структурен — два свойства на одном интерфейсе физически не помещаются в индекс, конфликт вылезает при вставке.

Цена — память. На 10 000 предметов подсистема занимает порядка 16–20 МБ. Размен сознательный: на фоне сборки, где текстуры измеряются гигабайтами, это доли процента бюджета.

Реактивность

У предмета одно событие — OnChanged, поднимается при изменении значения свойства или состава свойств. Кто меняет значение (контроллер количества, прочности, тающего веса), после мутации зовёт через свойство защищённый ItemProperty.NotifyChanged(owner) — тот дёргает ItemModel.NotifyChanged() (internal). Так изменение доходит до потребителя без полинга и без глобального счётчика: инвентарь подписывается на OnChanged своих предметов и переизлучает его как собственное событие.

Производные величины пересчитываются по требованию — потребитель, читающий суммарную массу, всегда видит текущее значение, включая распад веса. Кеш вводится только там, где профиль покажет проблему.

Контракт

Вход. Идентификатор пресета, опционально — сохранённое состояние.

Гарантии.

Инвариант
Inv-1 Не более одного свойства на интерфейс назначения в пределах предмета
Inv-2 Состав меняется только через шину
Inv-3 Настроечные данные не сохраняются и берутся заново при каждом построении

Ограничения.

  • Сигнал OnChanged при изменении значения свойства — ответственность владеющего контроллера (зовёт NotifyChanged(owner) после мутации). Пакет этого не гарантирует и проверить не может.
  • Событие OnItemCreated срабатывает на каждое построение. При загрузке крупной коллекции это тысячи срабатываний подряд — обработчики обязаны быть дешёвыми.
  • Исключение в обработчике события не изолируется и обрывает загрузку. Это предпочтительнее тихо полусобранного предмета.
  • Идентичность класса свойства в сохранении определяется полным именем типа со сборкой. Переименование или перенос класса свойства — ломающее изменение сохранений.

Разделение настроечных и сохраняемых данных

Умолчание сериализации — «сохраняется». Забытая пометка исключения на настроечном поле приведёт к тому, что значение уедет в сохранение, перекроет настройку при загрузке и патч баланса молча перестанет доходить до старых предметов. Ошибка невидима и всплывает поздно.

Поля свойства делятся на две категории:

Категория Пример Пометка
Настроечные базовая масса, максимальная прочность [NotPOCO]
Игровое состояние текущая прочность без пометки

Приватный сеттер поле из сохранения не исключает: отбор смотрит на публичность геттера и наличие сеттера любого уровня. Это два независимых механизма.

Свойство, которое предполагается добавлять в рантайме, настроечных полей иметь не должно: у него нет пресета-источника, и при воссоздании из сохранения такие поля придут нулями.

Использование

1. Объявить интерфейс назначения

public interface IMassProperty : IItemProperty
{
    float Mass { get; }
}

Наследование от IItemProperty обязательно: по нему строится индекс, и через него интерфейс получает пометку сериализуемости — размечать классы свойств не нужно.

2. Объявить свойство

[Serializable]
public class FixedMass : ItemProperty, IMassProperty
{
    [SerializeField] private float baseMass;
    [SerializeField] private float bonus;

    [NotPOCO] public float BaseMass { get => baseMass; private set => baseMass = value; }

    public float Bonus { get => bonus; set => bonus = value; }

    public float Mass => BaseMass + Bonus;
}

[Serializable] нужен Unity для [SerializeReference].

Инспектор работает с полями, сохранение — со свойствами. Поэтому пара «сериализуемое поле + свойство над ним»: поле даёт настройку в пресете, свойство определяет судьбу значения в сохранении. BaseMass приходит из настройки и помечен исключением; Bonus — игровое состояние и сохраняется. Mass сеттера не имеет и в сохранение не попадает.

[NotPOCO] допустим только на свойстве — на поле он не скомпилируется.

3. Объявить категории

public partial class ItemCategory
{
    public static readonly ItemCategory Weapon     = new(nameof(Weapon), 100);
    public static readonly ItemCategory Consumable = new(nameof(Consumable), 110);
}

4. Настроить пресет

Create → Database → Item. В инспекторе задаются состав свойств и категория. Тип записи закрепляется как многоэкземплярный автоматически.

5. Построить предмет

var item = ItemsBus.Create(presetGuid);                 // новый
var restored = ItemsBus.Create(presetGuid, saveData);   // из сохранения

Порядок обязателен и собран внутри шины: форма из пресета → наложение состояния → индекс → событие. Владелец берёт экземпляр по идентификатору пресета и только потом передаёт сохранённое состояние.

6. Читать свойство

var mass = item.GetProperty<IMassProperty>();
if (mass != null)
    total += mass.Mass;

7. Реагировать на изменение предмета

item.OnChanged += it => Refresh(it);   // значение свойства или состав изменились

Производную величину считают по требованию — она всегда актуальна:

public float Total(IReadOnlyList<ItemModel> items)
{
    var total = 0f;
    foreach (var item in items)
        if (item.GetProperty<IMassProperty>() is { } mass)
            total += mass.Mass;
    return total;
}

Контроллер, изменивший значение свойства, поднимает сигнал через защищённый ItemProperty.NotifyChanged(owner) — так изменение доходит до подписчиков OnChanged без полинга.

8. Менять состав

ItemsBus.AddProperty(item, new Enchantment());
ItemsBus.RemoveProperty<IEnchantment>(item);

9. Досборка после построения

ItemsBus.OnItemCreated += item =>
{
    if (item.Category == ItemCategory.Weapon && !item.HasProperty<IDurability>())
        ItemsBus.AddProperty(item, new Durability());
};

Событие срабатывает и при создании из пресета, и при восстановлении из сохранения, поэтому доменная досборка пишется один раз.

API

ItemsBus

Член Описание
Create(presetGuid, saveData = null) Построить предмет (с событием)
BuildProbe(presetGuid, saveData = null) Тихая сборка для осмотра: без события
AddProperty(item, property) Добавить свойство. false при конфликте
RemoveProperty<T>(item) Удалить свойство по назначению или классу
RemoveProperty(item, property) Удалить конкретное свойство
OnItemCreated Предмет построен и согласован

ItemModel

Член Описание
GetProperty<T>() Свойство по интерфейсу назначения или по классу. null при отсутствии
HasProperty<T>() Наличие свойства
AllProperties Все свойства для перебора
Category Разрешённая категория. Никогда не null: незаданный или неразрешимый ключ даёт ItemCategory.Unknown
OnChanged Значение свойства или состав изменились
GetDataForSave() / LoadFromSaveData() Сохранение и наложение состояния

ItemProperty

Член Описание
NotifyChanged(owner) protected — наследник зовёт после изменения своего значения; поднимает owner.OnChanged

Редакторные инструменты

Валидация состава в инспекторе пресета — сообщение об ошибке над списком свойств. Ловятся:

  • пустой слот в списке;
  • повтор класса свойства;
  • занятое назначение с указанием обоих претендентов;
  • свойство без единого интерфейса назначения — найти его невозможно, оно инертно.

Результат кешируется на секунду, чтобы проверка не деградировала отрисовку инспектора.

Граничные случаи

Ситуация Поведение
Неразрешимый идентификатор пресета, без сохранения Пустая болванка: идентификатор сохранён, состав пуст, имени и иконки нет, категория Unknown. Любой запрос свойства даёт null. Решение о судьбе предмета принимает держатель коллекции
Неразрешимый идентификатор пресета, с сохранением Состав восстанавливается из сохранения целиком, вплоть до конкретных классов свойств: данные игрока не теряются из-за пропавшего пресета. Настроечных данных нет — ни имени, ни иконки, категория Unknown, — а настроечные поля свойств придут нулями
Запрос отсутствующего свойства null, без исключения
Конфликт назначений при построении Свойство-нарушитель отклоняется целиком — и из индекса, и из состава. Ошибка в лог
Конфликт при добавлении в рантайме Операция отклоняется до любой мутации, предмет не остаётся полуизменённым. Ошибка в лог, false
Свойство есть в пресете, нет в сохранении Экземпляр из пресета в исходном состоянии — так доезжают свойства, добавленные патчем
Свойство есть в сохранении, нет в пресете Воссоздаётся из сохранения. Покрывает и добавленное в рантайме, и убранное патчем: различить их нельзя
Свойство убрано патчем Остаётся у старых предметов «мёртвым пассажиром». Вычистка — работа подписчика OnItemCreated, владеющего этим свойством
Категория не задана Category возвращает ItemCategory.Unknown молча
Неразрешимый ключ категории Category возвращает ItemCategory.Unknown и один раз пишет ошибку в лог
Свойство без интерфейсов назначения В индекс не попадает, доступно только запросом по конкретному классу. Подсвечивается в редакторе
Класс свойства переименован или перенесён Ошибка «тип не найден» в логе при загрузке. Ломающее изменение сохранений, таблиц миграции нет