Что вы получите с этой страницы: Эффективные подходы к архитектуре развивающегося проекта с целью обеспечить масштабирование с минимальными проблемами. По мере развития вашего проекта приходится регулярно перерабатывать и улучшать его структуру. Всегда полезно отступить от вносимых изменений, разбить проект на отдельные элементы и доработать каждый из них, а потом снова собрать их воедино.
Давайте рассмотрим несколько примеров кода из очень простой игры в стиле Pong, которую моя команда сделала для выступления на Unite Berlin. Как видно на изображении выше, здесь есть два весла и четыре стены - сверху и снизу, слева и справа - логика игры и пользовательский интерфейс счета. К ракеткам и стенам прикреплен простой скрипт.
Этот пример построен на нескольких ключевых принципах:
- Один «предмет» = один префаб
- Логика для одного «предмета» = один класс MonoBehavior
- Приложение = сцена со взаимосвязанными префабами
Эти принципы работают для простого проекта, подобного этому, но если проект станет сложнее, то структуру придется изменить. Какой способ организации кода выбрать?
Во-первых, давайте разберемся в разнице между экземплярами, префабами и ScriptableObject. Выше показан компонент Paddle, прикрепленный к GameObject 1-го игрока, отображаемый в окне Inspector:
Как видим, у компонента есть три параметра. При этом из всего этого я не смогу понять, чего ожидает от меня код этого компонента.
Стоит ли менять параметр Input Axis на левой ракетке только в экземпляре или это лучше сделать в префабе? Допустим, что ось ввода у игроков должна быть разной, поэтому параметр нужно менять в экземпляре. А как быть с параметром Movement Speed Scale? Где его менять — в префабе или в экземпляре?
Взгляните на код компонента Paddle:
Если подумать, то становится понятно, что разные параметры используются в нашей программе по-разному. Мы должны изменить InputAxisName отдельно для каждого игрока: Масштаб движения (MovementSpeedScaleFactor) и масштаб позиции (PositionScale) должны быть общими для обоих игроков. Исходя из этого можно понять, когда использовать экземпляры, когда префабы, а когда — объекты ScriptableObject.
- Нужна одна-единственная уникальная копия? Создайте префаб, а из него — экземпляры.
- Нужно много копий, из которых некоторые могут быть уникальными? Создайте префаб, сделайте экземпляры и переопределите некоторые из параметров.
- Хотите, чтобы одинаковое значение повторялось у нескольких экземпляров? Создайте ScriptableObject и передавайте данные оттуда.
В следующем отрывке кода показан пример использования ScriptableObject с нашим компонентом Paddle.
Поскольку мы переместили эти настройки в ScriptableObject типа PaddleData, то нам достаточно указать ссылку на PaddleData в компоненте Paddle. И в итоге мы видим в инспекторе два элемента: PaddleData и два экземпляра Paddle. Вы по-прежнему можете изменить ось и пакет общих настроек, на который будет ссылаться каждая отдельная ракетка. Из новой структуры лучше понятно, в чем смысл различных настроек.
Если бы это была настоящая игра, то мы бы наблюдали постепенный рост объема классов MonoBehavior. Давайте рассмотрим, как разделить их на основе принципа одиночной ответственности, подразумевающего, что один класс должен отвечать за один элемент. При правильном применении вы должны уметь давать краткие ответы на вопросы "что делает тот или иной класс?", а также "что он не делает?". Таким образом, каждый разработчик в вашей команде сможет легко понять, что делают отдельные классы. Это принцип, который можно применить к кодовой базе любого масштаба. На рисунке выше показан простой пример.
Это код для шарика. Выглядит просто, но если присмотреться, то станет понятно, что у шарика есть переменная скорости, доступная как дизайнеру для задания начального вектора скорости, так и самодельной физической модели, отслеживающей текущую скорость шарика.
Мы используем одну и ту же переменную для разных целей. Как только шарик придет в движение, информация о первоначальной скорости теряется.
Физическая модель не только определяет движение через метод FixedUpdate(); она также включает обработку столкновений со стеной.
В глубинах обратного вызова OnTriggerEnter() есть операция Destroy(). Она отвечает за уничтожение собственного объекта GameObject. В крупных программах редко встречается такое поведение — обычно сам скрипт уничтожает объекты, которые ему принадлежат.
Здесь есть возможность разбить сложную систему на небольшие элементы. Есть множество разных типов ответственности для классов — игровая логика, обработка ввода, моделирование физики, представление информации и многое другое.
Вот способы такого разбиения:
- Общая игровая логика, обработка ввода, моделирование физики и представление данных могут быть назначены MonoBehavior, объектам ScriptableObject или базовым классам C#.
- Если нужно сделать параметры доступными в Inspector, то можно использовать MonoBehavior или ScriptableObject.
- Обработчики событий движка или управление существованием объекта GameObject обязательно должны находиться в MonoBehavior.
Думаю, что в большинстве игр имеет смысл вынести как можно больше кода за пределы MonoBehavior. Это можно сделать с помощью объектов ScriptableObject, и по этой теме существуют отличные ресурсы.
Можно перенести MonoBehavior в обычные классы C#, но в чем преимущество такого способа?
Обычные классы C# располагают большими возможностями, чем собственные объекты Unity, с точки зрения создания компактных и совместимых друг с другом структур. Кроме того, обычный код C# можно использовать с другим кодом .NET за пределами Unity.
С другой стороны, если использовать обычные классы C#, то редактор не воспримет объекты, не сможет нативно отобразить их в инспекторе и так далее.
Этим методом лучше разделять логику по зонам ответственности. Если взглянуть на наш пример, то мы вынесли простую физическую модель в класс C#, который назвали BallSimulation. Единственная его задача — интеграция физики и обработка столкновений шарика с другими объектами.
Но имеет ли смысл модели шарика принимать решения по типу объекта, с которым он столкнулся? Это больше похоже на логику игры. И у нас получается так, что шарику назначена часть логики, ответственная за моделирование физики, а затем результат этого моделирования возвращается обратно в MonoBehavior.
Если взглянуть на переработанный код выше, то мы увидим одно значительное изменение: нам больше не придется высматривать операцию Destroy() в глубинах кода. Мы получили несколько четко разграниченных областей ответственности в MoneBehavior.
Можно доработать еще несколько элементов. Если взглянуть на алгоритм обновления положения в FixedUpdate(), то станет понятно, что он должен отправить информацию о положении и затем вернуть новое положение. У модели шарика нет информации о положении; алгоритм проводит цикл симуляции на основе предоставленной информации о местоположении и возвращает результат.
Если воспользоваться интерфейсами, то часть MonoBehavior шарика можно сделать доступной физической модели — только ту, которая нужна (см. рисунок выше).
Давайте снова взглянем на код. Класс Ball создает простой интерфейс. Класс LocalPositionAdapter делает возможным передать ссылку на объект Ball другому классу. Нам не нужно передавать весь объект Ball, а всего лишь его LocalPositionAdapter.
BallLogic также должен сообщать объекту Ball, когда придет время уничтожить GameObject. Вместо того чтобы вернуть флаг, Ball может делегировать его BallLogic. Именно за это и отвечает последняя выделенная строка в переработанном коде. Получается аккуратная архитектура: много шаблонного кода, но каждый класс имеет четко определенную задачу.
Используя эти принципы, можно легко сохранять аккуратность структуры проекта даже силами одного человека.
Давайте взглянем на примеры архитектуры более крупных проектов. Если воспользоваться примером пинг-понга, то начав внедрять специализированные классы в код, такие как BallLogic, BallSimulation и так далее, мы сможем построить следующую иерархию:
Классам MonoBehaviour нужно иметь доступ ко всей информации, потому что они выступают оберткой для всей логики, но симуляции в игре не обязательно должны знать, как работает логика. Они должны просто выполнять свою задачу. Иногда логика передает сигналы симуляции, и она вычисляет соответствующую реакцию.
Обработке ввода выгоднее всего будет отвести собственное, изолированное место. Здесь будет происходить генерация событий ввода и их передача логическим алгоритмам. Все, что происходит после — будет симуляцией.
Этот подход хорошо работает с вводом и симуляцией. Но это чревато проблемами со всем, что относится к представлению данных, например, к логике, отвечающей за спецэффекты, обновление счета и так далее.
Системе представления данных нужен доступ информации о работе других систем, но ей не нужен полный доступ ко всем этим системам. Постарайтесь разделить логику и систему представления. Попробуйте сделать так, чтобы кодовая база могла работать в двух режимах — только логика и логика с представлением данных.
Иногда приходится объединять логику и представление, чтобы обновление второй системы происходило в нужный момент. И все равно система представления данных должна иметь доступ только к тем элементам, которые нужные ей для отображения информации, и ни к чему больше. Таким образом у вас будет естественное разграничение двух систем, что позволит сделать архитектуру игры проще.
Иногда вполне допустим класс, содержащий только данные, без включения в него всей логики и операций с этими данными.
Кроме того, бывают полезны классы, которые не имеют никаких данных, но содержат функции, цель которых — выполнять операции с предоставляемыми им объектами.
Статический метод хорош тем, что если он не затрагивает глобальные переменные, то нам легко определить зону ответственности этого метода, просто взглянув на аргументы, используемые при вызове метода. Нам не требуется анализировать его реализацию.
Этот подход пересекается с областью функционального программирования. Базовая единица здесь такова: вы отправляете данные функции, а функция возвращает результат или меняет один из внешних параметров. Опробуйте этот подход и, возможно, вы придете к выводу, что он дает меньше багов по сравнению с классическим объектно-ориентированным программированием.
Объекты также можно разделять вводом связующей логики между ними. Вернемся к примеру с пинг-понгом: как взаимодействуют логика шарика и система представления счета? Передает ли логика шарика системе представления счета информацию в случае, когда с шариком что-то происходит? Опрашивает ли логика счета логику шарика? Как-то ведь они должны взаимодействовать.
Можно создать буферный объект, единственная задача которого — выступать хранилищем для записи данных логикой и считывания данных системой представления. Другой вариант — реализовать между ними очередь, в которой логика будет размещать данные, а система презентации будет их считывать.
Еще один хороший способ отделения логики от системы представления по мере развития игры — шина сообщений. Ключевой принцип заключается в том, что ни отправитель, ни получатель не знают о существовании друг друга, но оба знают о существовании системы или шины сообщений. Система представления должна получать от шины сообщения о событиях, меняющих счет. Игровая логика будет отправлять в систему сообщений события, меняющие счет игрока. Если вы хотите реализовать такую систему, то лучше всего использовать UnityEvents или разработать собственную; благодаря этому у вас будет возможность использовать разные шины для разных задач.
Перестаньте использовать LoadSceneMode.Single и вместо нее обратите внимание на LoadSceneMode.Additive.
Используйте явные выгрузки, если хотите выгрузить сцену — рано или поздно вы столкнетесь с необходимостью оставить несколько объектов при переходе от одной сцены к другой.
И перестаньте использовать DontDestroyOnLoad. Он лишает вас контроля над временем жизни объекта. Кстати, если загружать объекты с помощью LoadSceneMode.Additive, то вам вообще не понадобится DontDestroyOnLoad — просто разместите долгоживущие объекты в специальной долгоживущей сцене.
Еще один совет, который был полезен во всех разработанных мной играх — поддержка аккуратного и контролируемого завершения работы.
Сделайте так, чтобы приложение могло высвободить практически все ресурсы перед выходом. Если это возможно, то в приложении на момент выхода не должно быть назначенных глобальных переменных и объектов с пометкой DontDestroyOnLoad.
Имея четко определенный порядок завершения работы, вам будет легче выявлять ошибки и утечки ресурсов. Кроме того, это позволит редактору избавляться от лишнего мусора при выходе из режима игры. Unity не выполняет полной перезагрузки домена при выходе из режима Play. Чистое завершение работы снижает вероятность непонятного поведения редактора или скриптов после выхода из режима игры.
Это можно обеспечить с помощью системы управления версиями, например, Git, Perforce или Plastic. Храните все ассеты в виде текста, выводите объекты из сцены путем преобразования их в префабы. И наконец, разделяйте сцену на несколько сцен меньшего размера, но имейте в виду, что для этого потребуются дополнительные инструменты.
Если вы планируете расширить команду до 10 сотрудников (или больше), то вам стоит поработать над автоматизацией процесса.
Будучи творческим программистом, вы хотите заниматься уникальной, тонкой работой, делегируя рутинную работу средствам автоматизации.
Начните с разработки тестов для вашего кода. Особенно стоит отметить, что, выводя код из классов MonoBehaviour в регулярные классы, вы упрощаете себе возможность использования платформы юнит-тестирования для разработки тестов как логики, так и симуляции. Этот подход актуален не везде, но он повышает доступность кода другим программистам.
Тестирование касается не только кода. Тестировать нужно и контент. Если в вашей команде есть разработчики контента, то вам лучше выработать стандартизированный метод проверки разработанного контента.
Логика тестирования, например, валидация префабов или данных, вводимых с помощью собственного редактора, должна быть доступна всем вашим разработчикам контента. Если им достаточно нажать кнопку в редакторе для быстрой проверки, то они быстро оценят экономию времени, которое дает это решение.
Следующий этап — настройка Unity Test Runner для автоматического повторного тестирования элементов на регулярной основе. Мы рекомендуем настраивать ее как часть системы сборки, чтобы она также проводила все ваши тесты. Неплохо также настроить оповещения, чтобы при возникновении проблем ваши коллеги получали уведомление в Slack или по электронной почте.
Автоматическое прохождение подразумевает создание искусственного интеллекта, который может играть в вашу игру и регистрировать ошибки. Проще говоря, любая ошибка, обнаруженная искусственным интеллектом — это время, сэкономленное на ее поиске!
В нашем случае мы запустили 10 игровых клиентов на одной машине с минимальными настройками детализации и позволили им пройти игру. Мы наблюдали за вылетами, а затем изучали логи. Каждое падение клиента избавляло нас от необходимости тратить время на самостоятельное прохождение игры или привлечение тестеров к поиску багов. Это означало, что когда мы сами или другие игроки садились за игру, мы могли сосредоточиться на впечатлениях, на визуальном качестве и тому подобном.