Условия — переиспользуемые проверки с единым контрактом. Условие отвечает на вопрос и ничего не меняет: «открыта ли цель», «хватает ли кубков», «выполнен ли набор правил».
| Тип | Назначение |
|---|---|
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 — как и везде, недонастроенное не запирает. В логе
ошибка, игра не встала. Жалоба на одно имя пишется один раз: условие спрашивают
на каждый кадр, и без этого лог залило бы тысячами одинаковых строк.
Раньше игра ещё не собрана, а ассеты уже могут спрашивать. Повторная регистрация
заменяет прежнее правило — так его переопределяет сцена, которой нужно своё. Правило
от имени сцены нужно снимать (Unregister): иначе замыкание держит её объекты,
а ответ считается по тому, чего уже нет.
Пустое условие разрешает, а не запрещает. Пустой список, пустая ссылка, невыбранный ресурс — всё это считается выполненным.
Причина в цене ошибки. Забытая настройка при «разрешает» означает, что объект доступен чуть раньше задуманного — это заметят и поправят. При «запрещает» объект оказывается заперт навсегда, без ошибки в логе, и выглядит это как сломанная игра.
На каждый удар, на каждый кадр. Поэтому ответ обязан быть дешёвым и не менять состояние игры: условие, которое что-то делает по дороге, ломает всех, кто спрашивает его «просто посмотреть».
Правило по ресурсу по той же причине не запоминает баланс при старте, а спрашивает его каждый раз: накопить нужное могут прямо по ходу дела, и цель должна открыться сразу.
У 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).