Введение в компонентно-ориентированный подход к программированию

Сам Unity Engine (далее Unity), как и многие другие игровые движки, наиболее приспособлен к компонентно-ориентированному программированию (далее КОП), так как Behavioral Pattern — один из базовых паттернов архитектуры движков, наряду с паттерном «Component» из классификации Decoupling Patterns. Потому именно компонент является базовой единицей для реализации бизнес-логики в Unity. В этой статье я расскажу о том, как применять КОП в Unity.

В целом, КОП можно рассматривать как развитие принципов ООП с устранением проблемного места, известного как хрупкий базовый класс. В свою очередь развитием КОП можно считать Сервис-Ориентированное программирование. Но вернёмся к теме. Стоит сразу отметить, что КОП-КОПом, но никогда нельзя забывать о принципах GRASP, SOLID и других.

Называть компонентом будем класс, унаследованный от MonoBehaviour. Здесь само название базового класса подобрано очень хорошо, даёт понять, что это поведение, и говорит, что исполняется оно платформой .mono. Но весьма удобно считать, что MONO — это одно, *одно* поведение. И первое, на что нужно обращать внимание при разработке компонентов: один компонент — одно поведение. Принцип единственной ответственности из SOLID и высокое сцепление методов внутри класса по GRASP в действии.

Теперь обратим внимание на GRASP. Программирование должно быть на уровне абстракций, а не конкретных реализаций (Low Coupling (Низкая связанность)). И даже, казалось бы, простой геймплей, требует создания UML диаграмм интерфейсов. Зачем? Не излишне ли это, принцип «YAGNI» (You Ain't Gonna Need It) не даёт покоя. Не излишне, когда обоснованно. Кто придумывает игру? Гейм-дизайнеры, вот эти коварные люди гражданской наружности. И не бывает так, чтобы они что-нибудь не изменили. Потому надо быть готовым к изменениям, но ещё чаще нужно быть готовым к расширению игровой логики. При том, все эти изменения будут внесены не сразу, а тогда, когда уже и сами разработчики забудут, почему сделано так, а не иначе. Поэтому UML-диаграммы, хотя бы абстракций, всегда нужно делать: это документация к проекту. UML диаграммы я буду разрабатывать в Visual Studio, чтобы потом сгенерировать по ним код C#.
Итак, приступим к разработке игры; например, создадим core gameplay игры жанра Tower Defense. Существуют разные подходы к шагам разработки, я буду использовать сокращённый вариант.

Первый шаг: постановка задачи


Сколько бы многостраничной документации не было создано по игре, нужно всегда уметь выделить основное. Примерное описание игрового процесса, конечно, лучше разделить на use case:
Игрок должен не дать врагам разрушить Дом. Для это он должен расставить башни, которые будут уничтожать врагов, попавших в радиус действия. Одновременно одна башня может атаковать только одного врага. Когда он погибает или выходит из радиуса действия башни, выбирается следующий из доступных. Враги появляются волнами, с каждой волной их количество увеличивается. Враги движутся из точки появления по дороге до Дома. Подойдя к дому начинают его разрушать. Когда дом разрушен — игра окончена.

Второй шаг: анализ задачи


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

Выделим главные игровые сущности, укажем их свойства и поведение:

1) Башня. Свойства: урон, кулдаун между выстрелами, радиус выбора цели, логика выбора цели.
Поведение: выбор цели и атака цели.
Также для разнообразия геймплея будут разные виды башен: по урону, радиусу поражения и логике выбора цели.
2) Крип. Свойства: HP, урон, кулдаун между атаками, скорость перемещения.
Поведение: Перемещение по маршруту до Дома. Атака дома при подходе к нему вплотную. Когда HP = 0, считается убитым.
Разные виды крипов — например, по HP.
3) Дом. Свойства: HP.
Поведение: Когда HP = 0, игрок проиграл.

Третий шаг: декомпозиция


Я считаю, что правильная декомпозиция задач очень важна. Большую часть времени стоит потратить на проработку абстракций и логической связи между ними.

Начнём с обобщения поведения, чтобы выявить какие должны быть компоненты.

1) В игре есть две сущности, обладающие поведением нанесения урона: башня и крип. Выделим это поведение в компонент, вот его интерфейс:



Стойте-стойте, кто-то скажет: “А как же инкапсуляция? Выходит, что кто угодно может изменить кулдаун и урон?” Нет, только геймдизайнер, настраивая интуитивно-понятным образом свойства компонента. Поэтому у всех свойств будет только get, чтобы отображать информацию в UI, а set не понадобится. Кроме того, Unity не сможет отобразить в инспекторе свойства с указанной конструкцией get/set, и понадобится создавать поля, отмеченные [SerializeField]. И это правильно, другие классы из кода не смогут изменить значение свойства.

Вернёмся к компоненту. Кто-то будет вызывать запуск этого поведения и останавливать его, указывать цель, сменять цель. Но кто?

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

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

Конечно для дома это поведение избыточно: дом никогда не убежит. Ну да ладно. Назовём этот интерфейс будущего компонента ITrigger. С двумя методами Unity: OnTriggerEnter/OnTriggerExit.



Второй логический компонент будет реагировать на эти события и использовать уже сам IDamageDealer для нанесения урона выбранной цели и прекращения нанесения урона.



Но как тогда сделать так, чтобы тот второй компонент по-разному выбирал цель, в зависимости от типа башни и если это вообще крип? Простой вариант — некий универсальный метод SelectTarget (Тип логики выбора цели (тип башни, крип)), в зависимости от типа логики выбора цели выбрать её. Но универсальность не всегда хороша, особенно если дело касается компонентов. Тут вспоминается Interface Segregation Principle: лучше несколько конкретных, чем один универсальный. Поэтому будут разные компоненты для разного поведения выбора цели, объединённые одним интерфейсом ITargetSelector.



KeepSelector: выбирает в качестве цели дом.
SimpleCreepSelector: выбирает первую в списке цель.
WeakCreepSelector: выбирает самую слабую цель чтобы добавить её.

Таким образом легко расширить core-gameplay механику выбора цели (механика основная, так как башни только ей различаются). Однако, можно и другой вариант сделать, с наследованием. Будет базовый компонент TargetSelector с дефолтной логикой для башни. А класс KeepSelector и WeakCreepSelector будут переопределять метод добавления в список целей (чтобы проверить дом это или крип) и метод выбора цели.

3) В игре есть две сущности, обладающие следующим поведением: при получении урона уменьшается количество очков жизни, пока не умрёт.



Почему надо IsDead, когда можно просто проверь условие HP<=0? Даже в текущей реализации в двух местах надо проверять условие, не умер ли крип. Поэтому, следуя простому принципу DRY (Do not repeat yourself), не будем дублировать логику. Так проще будет изменить логику, если добавятся ещё какие-либо условия.

Чтобы в будущем легко расширить метод применения урона, отдадим это поведение другому вспомогательному компоненту:



Так мы можем в будущем комбинировать два компонента, IHittable и IDamageDealer без наследования и переопределения метода применения урона. А также легко расширить поведение, назначив другие компоненты, реализующие IDamageDealer.

4) Одна сущность имеет поведение перемещения по маршруту: IRouteFollower. Свойства: WayPoints[], Speed;

5) Теперь надо выявить компонент, который обработает логику проигрыша и выигрыша: проверять IsDead дома и всех крипов, с учётом номера волны.



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

Четвёртый шаг: реализация


Теперь можно сделать реализацию первого варианта игры. Однако кто-то спросит: “А где же сами классы Tower и Creep?”.
В Unity создадим префабы, настроенные как визуально (башни разных типов, крипы, дом), так и логически — т. е. с добавленными компонентами. Также свойства компонентов должны быть настроены, потому нет необходимости создавать отдельные классы. Но нам понадобится понимать (для реакции в OnTriggerEnter/OnTriggerExit), что есть крип, а что дом. Для этой цели в Unity есть теги. Некоторых расстраивает, что только один тег может быть у одного игрового объекта, но это нормально: не стоит делать универсальных объектов.

Начнём реализацию с генерации кода UML диаграмм, получив скелет-пустышку. После чего создадим реализацию поведения компонентов. Не забывайте о том, что лучше использовать абстракции, а не конкретные реализации, хотя это и увеличит связность. Пример моей реализации можно посмотреть тут: https://github.com/sountex/COPTD

Итак, сделали. А как теперь расширить геймплей — допустим, добавив экономику? За убийство крипта теперь надо получать деньги, а их тратить на постройку башен. Не спешите добавлять новые свойства в класс, реализующий одно из поведений крипа. Вспомните принцип единственной ответственности класса и выделите это новое поведение в новый компонент. Создав новый компонент, в поведение которого будет входить хранение данных о количестве приносимых за убийство денег, мы сделаем более хорошее решение, пригодное для повторного использования. Немного о повторном использовании. Например, в RTS — там также есть юниты которые и наносят урон и получают его, строения. Благодаря компонентно-ориентированному подходу и абстракции, все созданные компоненты легко использовать в играх другого жанра.

Следующий шаг: тестирование


Для тестирования поведения игрового объекта как набора компонентов, наиболее удобно использовать BDD (Behavior-driven development). А для тестирования отдельно компонентов Unit Test. Но это уже отдельная тема.
Метки:
  • +13
  • 26,4k
  • 3
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 3
  • –1
    неплохо.
    • –1
      А расскажите подробнее по поводу BDD и TDD в Unity.
      • 0
        Спасибо, почерпнул некоторые методики.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.