Будущих студентов курса "Unity Game Developer. Professional" приглашаем посетить открытый вебинар на тему "Продвинутый искусственный интеллект врагов в шутерах".
Введение
За годы работы над множеством проектов я выработал четкий подход к структурированию игровых проектов в Unity, который зарекомендовал себя в особой степени расширяемым и удобным в сопровождении.
Долгое время я хотел записать свои соображения, преобразовав их в формат пригодный для публики.
Эта статья является обновленной версией моего выступления на GDC в 2017 году (“Data Binding Architectures for Rapid UI Creation in Unity”).
Дисклеймер: вы должны понимать, что это лишь наработанные мной практические рекомендации, которые отражают мой опыт и взгляд на разработку, а не универсальное решение всех проблем и определенно не единственно правильный подход для каждого проекта или команды.
Второй дисклеймер: после того, как эта статья была опубликована, читатели обратили мое внимание, что я не одинок в данном подходе, поскольку Kolibri Games также практикует нечто подобное: их статья
Архитектура
Основными целями этой архитектуры являются:
поддерживаемость
расширяемость
тестируемость
Эти три цели нелегко достичь в движке, который в первую очередь нацелен на быстрое прототипирование. Среди разработчиков игр распространено мнение, что эти принципы больше подходят для бизнес-решений, чем для игр, и я категорически с этим не согласен. Игры все больше и больше переплывают в парадигму программного обеспечения как сферы услуг. Обращая свой взгляд на решения в этой области, мы можем обнаружить, что существуют полезные инструменты, которые мы можем применить и к играм.
Инверсия управления (inversion of control)
Интерфейс передачи сообщений (MPI)
Модель / представление / контроллер (MVC)
Модульное тестирование (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" и карьерных перспективах.
foxairman
Интересно посмотреть как спроектировать игру на уровне софта. Вот создал игрока, врагов и окружение. А как оно взаимодействует между собой? Конечно создаешь скрипты со здоровьем, атакой и другие, но как все это правильно связать если оно разрастется? Я начинающий программист, поэтому тяжело понять. Буду ждать примеров в следующей статье, спасибо!
Brightori
MVC не самый удобный для геймдева паттерн/парадигма имхо, и тут надо мыслить не общим смыслом — здоровье, атака и тд, а тем как и где это будет храниться. Хранение данных происходит в модели(отдельный скрипт). Будет некая модель в которой будут атака, здоровье, скорость, и тд и тп. Когда это надо отрисовать например в UI. Создается отдельный скрипт View. Там всё про отрисовку. А общая логика и зависимости от другой логики пишутся в скрипте Controller. В итоге на одну сущность минимум три класса/файла.
foxairman
А что порекомендуете? Может пример какой-нибудь есть с лучшими практиками? У меня вот на игрока навешаны несколько скриптов, а внешний мир ищет, например, скрипт здоровье игрока и пытается взаимодействовать с игроком: нанести урон, вызвать метод, собрать предмет.
Brightori
я бы смотрел в сторону MVVM — попроще для понимания, меньше писанины, или ECS.