Карамова Дарья

Проект «Применение паттернов проектирования и IoC-контейнера при разработке в Unity» курса «Архитектура и шаблоны проектирования»

GitHub: https://github.com/kardamoony/asteroids

Играть: https://kardamoony.github.io/asteroids

Пара слов о целях проекта

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

В данной статье я постаралась рассказать о своём проекте, не слишком вдаваясь в специфические для Unity и игровой разработки детали, чтобы вам было комфортно её читать, даже если вы ни разу в жизни не открывали Unity Editor.

Основными целями работы стало применение на практике концепций, изученных на курсе “Архитектура и шаблоны проектирования”: принципов SOLID и GRASP, паттернов GoF, приёмов снижения связанности кода, а также генерации кода.

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

Сложнее было подобрать идею для игры, воплощение которой позволит мне достичь поставленных в проекте целей, уложившись при этом в отведённый срок. В итоге мой выбор пал на игру Asteroids 1987 для Atari 7800 по причине её визуальной простоты, понятных и привычных игровых механик, с которыми интересно работать. А ещё я просто люблю игры про космос 🙂

Какой был план работ

Я поставила для себя следующие задачи:

  • Создание минималистичного IoC (Inversion of Control) контейнера. Хочу отметить, что это была скорее синтетическая, учебная задача, и я ни в коем случае не призываю обязательно писать IoC-контейнер с нуля в реальных проектах, ведь существуют неплохие готовые решения, способные сэкономить вам силы и время. Написание  полноценного IoC с инъекцией зависимостей является сложной задачей, достойной рассмотрения в отдельном проекте. Моя реализация не рассчитана на испытание продакшен-кодом.
  • Написание сервисных систем игры, например: системы управления ресурсами игры, системы хранения и получения игровых параметров, системы инициализации геймплея.
  • Написание игровых систем: спавна игрока, астероидов и снарядов, движения, поворота, стрельбы, разрушаемости объектов.
  • Работа над визуальной частью: анимации, шейдеры, подбор ассетов. Эту информацию, как игроспецифическую, я оставлю за рамками данного текста.

Какие технологии я использовала

Как я уже писала выше, мой основной рабочий инструмент — игровой движок Unity (https://unity.com)

Проект «Применение паттернов проектирования и IoC-контейнера при разработке в Unity» курса «Архитектура и шаблоны проектирования»

В текущей итерации Unity использует язык C#, поэтому весь представленный в статье код написан именно на этом языке.

И всё 🙂 Не уверена, что стоит упоминать в списке инструментов Git, так как это стандарт и даже формальное требование к проекту.

Благодарность за бесплатные ассеты для проекта я адресую ресурсу https://opengameart.org и следующим авторам:

Что и как мне удалось реализовать

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

Проект «Применение паттернов проектирования и IoC-контейнера при разработке в Unity» курса «Архитектура и шаблоны проектирования»

Каждый модуль — это отдельная сборка (Assembly Definition) со своими зависимостями. Unity позволяет делить код проекта на такие сборки и ограничивать их доступ к другим частям кода. Если вдруг вы всегда хотели об этом узнать, но боялись спросить — вот здесь можно почитать подробнее.

Помимо более понятных связей, это даёт приятный бонус ускорения компиляции отдельного модуля при внесении изменений!

  1. Модуль Initialization. Отвечает за инициализацию и деинициализацию систем и сущностей, а также создание зависимостей и управление ими. Этот модуль имеет наибольшее количество связей с другими модулями.
  2. Модуль Simulation. Здесь содержится вся бизнес-логика проекта, живут игровые системы и сущности после того, как их создал модуль инициализации.
  3. Модуль Presentation. Его задача — управлять отображением симуляции. Именно тут физически двигается моделька звездолёта, отображается взрыв при попадании в астероид. Этот модуль имеет связь с модулем Simulation, т. к. ему необходимо знать об отображаемых объектах.
  4. Модуль Core. Здесь находятся сервисы и утилиты, не привязанные к бизнес-логике проекта (а значит, их удобно будет переиспользовать в другом проекте). Например, управление ресурсами игры и система игровых параметров.
  5. Модуль UI. Здесь название говорит само за себя — этот модуль содержит логику пользовательского интерфейса игры.
  6. Модуль IoC. Содержит логику, относящуюся к IoС-контейнеру.

Далее я расскажу подробнее про каждый из модулей и о том, какие шаблоны проектирования я применила в различных случаях. Я не буду рассказывать, как реализовать тот или иной шаблон, за этой информацией вам стоит обратиться к специализированным источникам, например, к классической книге “Design Patterns: Elements of Reusable Object-Oriented Software” от “банды четырёх”. В тексте будут упоминаться следующие шаблоны:

  • ECS (Entity Component System)
  • Strategy
  • Chain of Responsibility
  • Factory

IoC-контейнер

После того, как общие очертания архитектуры игры были определены, я приступила к разработке IoC-контейнера в самой базовой его реализации. Мне показалось разумным разделить задачи контейнера на два базовых интерфейса — IDependencyContainer (задача — регистрация и удаление зависимостей) и IDependencyResolver (задача — разрешение зависимостей). Хотя мой класс минималистичного контейнера реализует оба этих интерфейса, и всего-навсего хранит хеш-таблицы с конструкторами и инстансами (для объектов, которые должны существовать в единственном экземпляре) и находит нужный конструктор/инстанс, ориентируясь на тип объекта, это обеспечивает гибкость при реализации более сложной логики.

public interface IDependencyContainer
{
  void Register<T>(Func<object[], object> constructor);
  void Unregister(Type type);
  void RegisterInstance<T>(T instance);
}
public interface IDependencyResolver
{
  T Resolve<T>(params object[] args);
}

Инициализация

Для реализации инициализации игровых режимов я выбрала шаблон GoF “Стратегия” (Strategy). Классы стратегий реализуют один интерфейс IInitializationStrategy, что позволяет соблюсти принцип LSP в классе-инициализаторе. В игре я реализовала два режима: режим “мета”, который инициализируется после загрузки игры и режим симуляции, который стартует, когда игрок нажимает на кнопку “Start”. Также классы стратегий отвечают за деинициализацию, которая происходит при переходе из одного режима в другой.

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

public interface IInitializationStrategy
{
  void Initialize();
  void Deinitialize();
}

Этот интерфейс я не стала разделять на стратегию инициализации и деинициализации из-за того, что эти части логики по сути своей слишком тесно взаимосвязаны.

Для инициализации игровых сущностей же я выбрала другой шаблон — “Цепочка обязанностей” (Chain of Responsibility), и в следующем разделе я расскажу, почему.

Для создания объектов я вполне ожидаемо применила шаблон “Фабрика” (“Abstract Factory”). Фабрика сущностей (EntityFactory) предоставляет игровые сущности (игрок, астероиды, взрывы, снаряды), а фабрика UI (UIFactory) — элементы UI (экраны и виджеты).

Симуляция и презентация

Для реализации бизнес-логики игры я выбрала вариацию специфического для игр шаблона Entity Component System (ECS). Его суть заключается в логическом разделении игровых объектов на несколько основных типов — на сущности (entities), хранящие состояния, компоненты (components), которые определяют сущности, и системы (systems), которые оперируют сущностями, изменяя их состояния. Почитать подробнее о ECS можно, например, тут.

И тут уместно будет рассказать, почему я выбрала “цепочку обязанностей” как шаблон для инициализации игровых сущностей. Давайте рассмотрим игровую сущность, составленную из нескольких компонентов — астероид.

Астероид может: 

  • спавниться на игровом поле (SpawnableComponent);
  • лететь вперёд с постоянной скоростью (ConstantMovementComponent);
  • регистрировать столкновения с игроком (CollisionComponent);
  • получать урон от столкновения с игроком/снарядом и уничтожаться при падении здоровья до 0 (DestructableComponent);
  • и т. д.

Благодаря цепочке обязанностей, при создании объекта нам совершенно не нужно знать, что это за сущность — мы просто отдадим его во власть первого звена цепи и он будет инициализирован и передан в нужные игровые системы одним из последующих звеньев. Это избавляет от масштабных изменений уже существующего кода при добавлении новых типов сущностей и новых систем!

Некоторые игровые системы используют уже упомянутый шаблон “Стратегия”. К примеру, в игре есть два типа движения — константное движение астероида (астероид летит в каком-либо направлении с постоянной скоростью) и реактивное движение игрока (игрок движется вперёд с растущей до некоторого предела скоростью при нажатии кнопки “вверх”, при нажатии кнопки “вниз” скорость игрока резко падает, если же кнопки “вверх” или “вниз” не нажаты, то скорость игрока падает плавно. При этом звездолёт игрока не умеет летать “задним ходом”). Именно здесь нам приходят на выручку разные стратегии движения!

UI

Много рассказывать про устройство UI системы нет смысла, ведь это самая скучная, хоть и необходимая, часть. Поэтому здесь хочу лишь упомянуть, что в проекте для имплементации элементов UI применяется вариация хорошо знакомого многим разработчикам MVVM (Model-View-ViewModel) паттерна.

Генерация кода

Практически в каждом игровом проекте возникает потребность как-то находить ассеты, загружать их, когда они нужны, и выгружать, когда надобность в них отпадает. Unity предоставляет нам инструмент менеджмента ассетов — Addressables System. Мы можем сравнительно быстро загрузить ассет по заранее созданному “адресу”, ассоцированному с конкретным ассетом. Этот адрес представлен в виде строки, однако, обращаться за ассетом по строке не очень удобно. Кто из нас не опечатывался в строках? Поэтому для более удобного менеджмента адресов я применила генерацию перечислений (enums). Все адреса-строки, хранящиеся в карте ассетов конвертируются в константы перечисления, после чего опечатки становятся невозможными, ведь IntelliSence не устаёт и у него не бывает личных проблем 🙂 

Итоги

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

Для дальнейшего развития проекта можно выделить следующие варианты: 

  • добавление режима для нескольких игроков;
  • добавление новых типов оружия игрока;
  • добавление новых типов двигателей игрока;
  • спавн “маленьких” астероидов на месте разрушенных крупных астероидов;
  • добавление бонусов и бустеров (ускорение, замедление времени, щит и прочее);
  • ввод имени игрока и сохранение его рекорда на “доске почёта”;
  • и множество других возможных улучшений!

Спасибо, что дочитали, а поиграть в то, что у меня получилось, можно тут 🙂

P. S. Интересуют паттерны проектирования? Добро пожаловать на специализированный курс в Otus!