Расширяемая и удобная в сопровождении архитектура игр на Unity

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Будущих студентов курса "Unity Game Developer. Professional" приглашаем посетить открытый вебинар на тему "Продвинутый искусственный интеллект врагов в шутерах".

А пока предлагаем прочитать перевод полезной статьи.


Введение

За годы работы над множеством проектов я выработал четкий подход к структурированию игровых проектов в Unity, который зарекомендовал себя в особой степени расширяемым и удобным в сопровождении.

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

Эта статья является обновленной версией моего выступления на GDC в 2017 году (“Data Binding Architectures for Rapid UI Creation in Unity”).

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

Второй дисклеймер: после того, как эта статья была опубликована, читатели обратили мое внимание, что я не одинок в данном подходе, поскольку Kolibri Games также практикует нечто подобное: их статья

Архитектура

Основными целями этой архитектуры являются:

  • поддерживаемость

  • расширяемость

  • тестируемость

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

  1. Инверсия управления (inversion of control)

  2. Интерфейс передачи сообщений (MPI)

  3. Модель / представление / контроллер (MVC)

  4. Модульное тестирование (Unit testing)

Инверсия управления

Следующая диаграмма показывает, как обычно работают сильно связанные компоненты:

ClassA напрямую зависит от ServiceA/ServiceB. Это обременяет независимое тестирование ClassA необходимостью заботиться о деталях реализации этих двух служб.

Внедрение зависимостей (DI — Dependency Injection) — это подход к реализации инверсии управления. На следующем рисунке показан предыдущий пример с использованием внедрения зависимостей:

Внедрение зависимостей используется как строитель (Builder) для генерации нашего ClassA, проверки необходимых зависимостей и их автоматического предоставления. ClassA не заботит то, какой конкретно класс, реализующий требуемый интерфейс, используется, пока таковой имеется в наличии.

Для реализации этого паттерна мы остановились на Zenject/Extenject. Он основан на рефлексии. Используя функцию запекания рефлексий (reflection-baking), мы можем избавиться от негативного влияния рефлексии на производительность.

Модель-Представление-Контроллер

Суть этой архитектуры — разбиение кода на отдельные уровни. Паттерн Модель-Представление-Контроллер (Model-View-Controller — MVC), перенесенный на Unity, выглядит следующим образом:

Monobehaviour-ы Unity обитают на уровне представления (View), что, как предполагается, защищает остальную часть архитектуры от затрудняющих модульное тестирование элементов Unity. Этот уровень имеет доступ только к уровню контроллера. Представление создает инстансы префабов и использует [SerializeField] для использования типичных drag’n’drop компонентов Unity. Здесь не должно быть никакой игровой логики, только чистая визуализация данных.

Уровень контроллера содержит бизнес-логику и выполняет всю тяжелую работу. Этот код должен быть тестируемым, он не зависит от специфики уровня представления Unity. Но все же этот уровень не определяет способ хранения данных на уровне модели, он только контролирует изменения на нем.

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

Поскольку представление не должно запрашивать информацию об изменении данных, для его уведомления мы используем передачу сообщений (Message Passing). Так мы можем сохранять слои обособленными друг от друга и при этом сохранять производительность.

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

Передача сообщений

Вышеупомянутая архитектура полагается на соответствующих уведомлениях (notification messages), чтобы уровень представления мог подписаться и реагировать на изменения/события (events):

Мы используем Zenject Signals.

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

struct MessageType {}

bus.Subscribe<MessageType>(()=>Debug.Log("Msg received"));

bus.Fire<MessageType>();

Важно отметить, что сигналы (Signals) должны быть легковесными и не содержать данных — для этого мы используем остальные уровни MVC. Сигналы — это инструмент чисто для уведомления, распространения событий и уменьшения связанности кода.

Альтернативой этому подходу является использование инструментов для наблюдения за изменениями данных в модели, таких как UniRx, но я предпочитаю иметь более строгий контроль над тем, когда мы хотим уведомлять об изменениях, вместо того, чтобы позволять представлению видеть каждое отдельное изменение значения. Решение о том, когда уведомлять, следует принимать на уровне контроллера, и поэтому сигналы сюда подходят лучше.

Модульное тестирование

Благодаря всем вышеперечисленным ограничениям и механизмам мы теперь можем покрыть модульными тестами почти всю нашу игровую (бизнес) логику.

Для реализации технической части написания этих тестов мы используем стандартный фреймворк Unity NUnit и NSubstitute в качестве решения для создания моков.

Давайте посмотрим на один из наших тестов:

var level = Substitute.For<ILevel>();
var buildings = Substitute.For<IBuildings>();

// test subject: 
var build = new BuildController(null,buildings,level);

// smoke test
Assert.AreEqual(0, build.GetCurrentBuildCount());

// assert that `GetCurrent` was exactly called once
level.ReceivedWithAnyArgs(1).GetCurrent();

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

Давайте посмотрим на более интересный пример билдинга чего-либо на слоте 0:

var level = Substitute.For<ILevel>();
var bus = _container.Resolve<SignalBus>();
var buildCommandSent = false;
bus.Subscribe<BuildingBuild>(() => buildCommandSent = true);

// test subject 
var build = new BuildController(bus,new BuildingsModel(),level);
// test call
build.Build(0);

Assert.AreEqual(1, build.GetCurrentBuildCount());

// assert signals was fired
Assert.IsTrue(buildCommandSent);

Теперь мы проверяем, что наш GetCurrentBuildCount возвращает правильное количество новых билдов после успешного билда на слоте 0. Мы также ожидаем, что на шину будет отправлен правильный сигнал — таким образом, соответствующее представление сможет обновиться.

"Погодите-ка, нельзя мокать то, что имеет корни в Zenject?" (что очень метко сказано моим хорошим другом Питером)

Да, к сожалению, SignalBus не имеет интерфейса, который мы можем передать в NSubstitute -— поэтому мы должны фактически подписаться и проверить, был ли запущен правильный сигнал.

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

Заключение

Это было всего лишь взгляд с высоты птичьего полета на эту тему. Но подведем итоги:

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

В будущих статьях мы напишем конкретный пример игры, чтобы применить все это на практике, и, кроме того, посмотрим, как объединить эту архитектуру с:

  • практическим примером применения этих подходов,

  • мокингом сцены для тестирования пользовательского интерфейса

  • фейковыми бэкендами и сторонними SDK

  • промисами для поддерживаемого асинхронного кода


- Узнать подробнее о курсе "Unity Game Developer. Professional" и карьерных перспективах.

- Зарегистрироваться на бесплатный вебинар на тему "Продвинутый искусственный интеллект врагов в шутерах" .


Источник: https://habr.com/ru/company/otus/blog/530056/


Интересные статьи

Интересные статьи

Предлагаю ознакомиться с расшифровкой доклада начала 2020 года Андрея Бородина "Odyssey: архитектура, настройка, мониторинг" Совсем недавно мы выпустили версию 1.0 нашего пулера соедин...
Привет, Хабровчане! Мы продолжаем знакомить вас с российской гиперконвергентной системой AERODISK vAIR. В этой статье речь пойдет об архитектуре данной системы. В прошлой статье мы разобрали на...
В интернет-магазинах, в том числе сделанных на готовых решениях 1C-Битрикс, часто неправильно реализован функционал быстрого заказа «Купить в 1 клик».
Unity — игровой движок, с далеко не нулевым порогом вхождения (сравнивая с тем же Game Maker Studio), и в этой статье я расскажу с какими проблемами столкнулся начиная его изучение, и какие р...
Мы публикуем видео с прошедшего мероприятия. Приятного просмотра.