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 и один раз пишет ошибку в лог |
| Свойство без интерфейсов назначения | В индекс не попадает, доступно только запросом по конкретному классу. Подсвечивается в редакторе |
| Класс свойства переименован или перенесён | Ошибка «тип не найден» в логе при загрузке. Ломающее изменение сохранений, таблиц миграции нет |