Skip to content

Latest commit

 

History

History
289 lines (211 loc) · 19.5 KB

File metadata and controls

289 lines (211 loc) · 19.5 KB

Conditions

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

Основные типы

Тип Назначение
ICondition Контракт с методом Evaluate(ConditionContextBase context) и Evaluate() без контекста
ConditionContextBase Обстоятельства проверки: для кого и при каких данных
ConditionContextEmpty Контекст без обстоятельств — для правил состояния мира
ConditionActorContext Контекст с участником: инициатор или атакующий
ConditionBase Основа условий-ассетов
ResourceCondition Ассет: накопленный ресурс сравнивается с числом
ConditionCollection Ассет-набор из других условий-ассетов
AllCondition Встроенное: выполнено, когда выполнены все вложенные
AnyCondition Встроенное: выполнено, когда выполнено хотя бы одно
NotCondition Переворачивает вложенное условие
AssetCondition Мостик: подставляет условие-ассет туда, где ждут встроенное
ResourceInlineCondition Встроенная форма того же правила
RegisteredCondition Выбирает правило, объявленное кодом, по имени
ConditionRegistry Реестр правил кода: имя → ответ
ConditionEnumerations Набор имён правил; игра дополняет его partial-частью
ConditionComparison Как сравнивать: >=, >, ==, !=, <=, <
ConditionMatch Сколько вложенных условий должно выполниться: все или любое
IConditionProvider Владелец отдаёт своё условие наружу — для показа игроку
ConditionDescription Подпись условия частями: перевод, аргументы, иконка
ConditionDescriptions Собирает подпись по условию; сюда же регистрируют свои
ConditionLabels Тексты требований, свой на каждое сравнение

Как это выглядит у владельца

[SerializeReference, ReferenceSelector]
[Tooltip("Когда по этой цели можно бить. Пусто - всегда.")]
private ICondition condition;

Дизайнер открывает объект, выбирает в выпадашке реализацию и настраивает её прямо там. Ассет под каждый порог заводить не нужно.

Два способа хранения

Те же, что у действий, и выбираются по тому же признаку:

Способ Когда Как объявляется Набор
встроенное условие уникально для этого объекта [SerializeReference, ReferenceSelector] ICondition AllCondition / AnyCondition
ассет одна настройка нужна в разных местах наследник ConditionBase ConditionCollection

Ассет в слот SerializeReference напрямую не кладётся — это ссылка на объект Unity, а не встроенный объект. Для таких случаев есть AssetCondition: встроенное условие, которое спрашивает ассет. Через него общее правило попадает и в список AllCondition, и в любой другой слот.

Контекст проверки

Evaluate(context) получает обстоятельства проверки — наследника ConditionContextBase. Отдельный класс, а не список параметров: потребителям нужны разные данные, и параметр в сигнатуре пришлось бы добавлять во все реализации разом, а новый контекст добавляется, никого не трогая.

Контекст Кто передаёт
ConditionContextEmpty.Instance правила состояния мира; его подставляет Evaluate()
ConditionActorContext хуки срабатывания и урона — с инициатором или атакующим

Участник лежит в основе — context.Actor, у пустого контекста он null. Правилу про участника так всё равно, откуда пришла проверка — от удара или от кнопки.

Условие, которому нужны особые данные, приводит контекст к своему типу. Не тот тип — это «данных нет», а не ошибка: отвечает такое условие так же, как на пустой слот.

Составные условия передают тот же контекст каждому вложенному правилу.

Одно правило — две формы

У правила с настройками бывает обе формы, и различаются они именем:

Форма Класс Где живёт
ассет ResourceCondition файлом, ссылаются откуда угодно
встроенная ResourceInlineCondition в слоте владельца

Приставка Inline у встроенного, чистое имя у ассета — так же устроены действия: OpenURLAction против AddResourceInlineAction.

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

Логика при этом не раздваивается: расчёт лежит статическими методами у ассета (ResourceCondition.Evaluate, GetMissing, GetBalance), встроенная форма их зовёт. Сравнение и работа с кошельком написаны один раз и разъехаться между формами не могут.

Сериализация у форм разная, и это цена такого устройства: поля продублированы в объявлении, хотя расчёт общий. Держать их флажком в одном классе нельзя — ассет и встроенный объект хранятся в Unity по-разному.

Trophies10.asset     ResourceCondition   кубки >= 10
Trophies100.asset    ResourceCondition   кубки >= 100
Trophies1000.asset   ResourceCondition   кубки >= 1000

Цели ссылаются на эти файлы через AssetCondition, и порог правится в одном месте сразу для всех, кто на него смотрит.

Композиция

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

В инспекторе владельца — AllCondition, AnyCondition, NotCondition. Это обычные встроенные условия, поэтому вкладываются друг в друга:

AllCondition
├── ResourceInlineCondition  кубки >= 100
└── NotCondition
    └── AssetCondition  «идёт обучение»

Ассетом — ConditionCollection: именованный набор из других условий-ассетов с переключателем «все» или «любое». «Открыто во втором мире» — это и уровень, и пройденный туториал, и купленный ключ. Собрав их один раз, дальше ссылаются на набор, а не перечисляют составляющие в каждом месте.

Наборы вкладываются в наборы: набор — это тоже ConditionBase. Ссылку на самого себя он пропускает, иначе проверка ушла бы в бесконечную рекурсию.

Без композиции каждый потребитель писал бы свой цикл по списку: слот у владельца один, а правил бывает несколько.

Сравнение у ресурса

Правило по ресурсу не привязано к «набрал столько-то» — сравнение выбирается (в обеих формах):

Comparison Читается как
GreaterOrEqual (по умолчанию) накоплено столько же или больше
Greater строго больше
Equal ровно столько
NotEqual не столько
LessOrEqual столько же или меньше
Less строго меньше

Обычное правило открытия — GreaterOrEqual. Обратные нужны подсказкам и предупреждениям: подсказка висит, пока кубков Less 5; сообщение «кончились ключи» — это Equal 0.

Ноль поэтому и не отсекается коротким путём: «ресурса нет вовсе» — законное условие, а не признак недонастроенного слота. Незаданным считается только пустая ссылка на ресурс.

Missing — сколько не хватает до выполнения. Осмысленно там, где ресурс копят (Greater, GreaterOrEqual); у остальных накопление к выполнению не ведёт, и число всегда ноль.

Правила, объявленные кодом

У части правил настраивать нечего: «туториал пройден», «идёт бой с боссом», «первый запуск». Классом такое было бы файлом с нулём полей. Для них есть реестр:

// при готовности SDK
ConditionRegistry.Instance.Register(
    ConditionEnumerations.TutorialFinished,
    () => ProjectPropertiesManager.Instance.GetBool("tutorial.finished"));

Правило, которому нужны обстоятельства проверки, регистрируют функцией с контекстом:

ConditionRegistry.Instance.Register(MyConditions.CanUseButton,
    context => context.Actor != null && context.Actor.GetComponent<PlayerBase>() != null);

В инспекторе такое правило выбирает RegisteredCondition — обычное встроенное условие, поэтому оно так же вкладывается в списки и наборы.

Имена лежат в ConditionEnumerations: набор пустой, игра дополняет его partial-частью, как и остальные Enumeration в проекте. Набор, а не голая строка, ради выпадашки: строку опечатают, и ассет молча перестанет находить правило.

Что сюда не надо

Правило с числом — «сто кубков». У него есть, что настраивать, и место этому в инспекторе (ResourceCondition), а не в коде. Уехав в реестр, порог перестанет быть делом дизайнера.

Незнакомое имя говорит вслух

Единственное место в системе, где о недонастроенном сообщают. В остальных случаях пустая настройка значит «правила нет», а здесь имя выбрано, но правила за ним не оказалось: это опечатка или несостоявшаяся регистрация.

Ответ при этом всё равно true — как и везде, недонастроенное не запирает. В логе ошибка, игра не встала. Жалоба на одно имя пишется один раз: условие спрашивают на каждый кадр, и без этого лог залило бы тысячами одинаковых строк.

Регистрировать на готовности SDK

Раньше игра ещё не собрана, а ассеты уже могут спрашивать. Повторная регистрация заменяет прежнее правило — так его переопределяет сцена, которой нужно своё. Правило от имени сцены нужно снимать (Unregister): иначе замыкание держит её объекты, а ответ считается по тому, чего уже нет.

Правило про недонастроенное

Пустое условие разрешает, а не запрещает. Пустой список, пустая ссылка, невыбранный ресурс — всё это считается выполненным.

Причина в цене ошибки. Забытая настройка при «разрешает» означает, что объект доступен чуть раньше задуманного — это заметят и поправят. При «запрещает» объект оказывается заперт навсегда, без ошибки в логе, и выглядит это как сломанная игра.

Evaluate зовут часто

На каждый удар, на каждый кадр. Поэтому ответ обязан быть дешёвым и не менять состояние игры: условие, которое что-то делает по дороге, ломает всех, кто спрашивает его «просто посмотреть».

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

Почему не ICanExecute

У IAction уже есть CanExecute(), и сядь условия на тот же интерфейс — выпадашка ReferenceSelector в слоте условия начала бы предлагать действия. Выбранное там начисление ресурса срабатывало бы прямо при проверке.

По смыслу тоже разное: действие совершает поступок, условие только спрашивает. Система действий (Core/@Actions) остаётся как была — условия живут рядом, а не внутри неё.

Ресурс спрашивается через кошелёк

Правило по ресурсу ходит в WalletService, а не напрямую в ResourceManager: тем же способом спрашивает магазин, и двух ответов на один вопрос в проекте быть не должно.

Своё число у условия есть и в готовом виде — Missing: сколько ещё не хватает. Интерфейсу нужен не отказ, а «нужно ещё сорок».

Условие на экране

Условие само по себе отвечает «да» или «нет». Игроку этого мало: запертую цель нужно объяснить — чего и сколько не хватает.

IConditionProvider — владелец условия отдаёт его наружу:

public class DoorLock : PRMonoBehaviour, IConditionProvider
{
    [SerializeReference, ReferenceSelector] private ICondition condition;

    public ICondition Condition => condition;
}

Так подпись над целью спрашивает правило у компонента, не зная, что это за компонент. Владелец при этом остаётся собой: он решает, пускать ли удар или срабатывание, а не рисует интерфейс.

В проекте его реализуют условия урона и срабатывания — DamageCondition и TriggerCondition из private-слоя.

ConditionDescriptions.Get(condition) собирает подпись — ConditionDescription: перевод, аргументы функцией и иконка. Готовой строки там нет намеренно: подпись висит на экране, язык могут сменить прямо при ней, а у сокращённого числа переводится разряд. Для конкретного участника подпись выбирают через ConditionDescriptions.Get(condition, context).

Из коробки описываются правила по ресурсу — обе формы, ассет и встроенная. Подпись берётся из ConditionLabels и зависит от сравнения: «нужно набрать» и «должно остаться не больше» — разные требования, одним знаком они не покрываются.

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

Своё условие объявляет описание само:

ConditionDescriptions.Register<BossDefeatedCondition>(
    condition => new ConditionDescription(BossLabels.Defeat, icon: condition.BossIcon));

Незнакомое условие остаётся без подписи, и табличка над целью не создаётся: пустая рамка хуже, чем её отсутствие. Об этом пишется в лог.

Сам показ — дело интерфейса: ConditionPlateInstaller в приватном слое (Components/ConditionPlate).