2850

Приемы при проектировании архитектуры игр

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

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

В статье мы рассмотрим следующие темы:

  • Сложные иерархии классов юнитов, предметов и прочего
  • Наследование VS компоненты
  • Абстракции игровых объектов
  • Упрощение доступа к другим компонентам в объекте, сцене
  • Сложные составные игровые объекты
  • Характеристики объектов в игре
  • Машины состояний, деревья поведений
  • Сериализация данных
  • Модификаторы (баффы/дебаффы)

Сложные иерархии классов юнитов, предметов и прочего

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

Для того чтобы текст был не таким объемным юниты могут быть и персонажами, и зданиями.

unit tree 1

 

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

На примере ниже мы покажем, как может выглядеть часть данной схемы в КОП.

unit tree 3

 

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

Наследование VS компоненты

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

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

КОП (Компонентно-ориентировочное программирование) был сформирован именно как решение этой проблемы. Вкратце, принцип работы КОП такова: «Есть некий класс-контейнер, а также класс-компонент, который можно добавить в класс-контейнер. Объект состоит из контейнера и компонентов в этом контейнере.»

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

В ООП подходе объект определяется приписанным ему классом.

В КОП подходе объект определяют компоненты, из которых он состоит. Не важно какой это объект, важно из каких компонентов он состоит, и что он умеет выполнять. Работая с КОП, можно значительно упростить себе задачу повторного использования уже написанного кода, ведь вы просто вставляете готовый компонент в различные объекты. Благодаря этому, комбинируя различные компоненты, можно собрать новый тип объекта. 

Возьмем, к примеру, объект “Персонаж”. Создавая его через ООП, вы бы получили один большой класс, возможно, наследуемый от чего-то. Для КОП — это комбинация компонентов, из которых и состоит объект “Персонаж”. 

Например:

Управление персонажем “CharacterController”, обработчик столкновений – «CharacterCollisionHandler», характеристики/статы персонажа – компонент «Stats», анимация персонажа – “CharacterAnimationController”. 

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

Абстракции игровых компонентов

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

Player (это может быть как человек, так и компьютер, но не стоит их смешивать в одном классе) — отдельный объект. 

Юнит, либо персонаж  — отдельный объект, управлять которым может любой игрок. В играх жанра “Стратегии” можно вынести еще один отдельный объект - “Отряд”.

Следовать подобной логике и разделять разнообразные игровые объекты несложно. При этом, использовать такое деление можно в играх абсолютно разных жанров.

Упрощение доступа к другим компонентам в объекте, сцене

Если объект состоит из множества компонентов, может появиться проблема при надобности обращения к ним. Причина этого — потребность создавать в каждом компоненте поля, в которых будут храниться ссылки на другие компоненты, либо обращаться к ним через GetComponent().

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

В таком случае, мы просто кэшируем ссылки в одном классе для удобства доступа к другим компонента объекта.

К примеру:

public class CharacterLinks : MonoBehaviour
{

   public Stats stats;
   public CharacterAnimationController animationController;
   public CharacterController charactercontroller;


   void Awake()
   {
       stats = GetComponent‹Stats>();
       animationController = GetComponent‹CharacterAnimationController›();
       characterController = GetComponent<CharacterController›();
   }
}

public class CharacterAnimationController : MonoBehaviour
{

   CharacterLinks_links;

   void Start()
   {

        _links = GetComponent<CharacterLinks>();
   }

   void Update()
   {

        if (_links.characterController.isGrounded)
            ...
   }
}

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

Пример:

public class GameSceneUILinks: MonoSingleton<GameSceneUILinks>
{
    public MainMenu MainMenu;
    public SettingsMenu SettingsMenu;
    public Tooltip Tooltip;
}

Использование:

GameSceneUILinks.Instance.MainMenu.Show();

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

Сложные составные игровые объекты

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

Мы можем предложить вам 2 случая, когда действительно стоит продумать иерархию объекта:

  • В процессе игры, определенные части объекта должны заменяться другими.
  • Некоторая часть скриптов должна срабатывать в одной сцене, а другая — в другой.

Сначала мы разберем первый случай.

Полностью заменить объект не проблема, но задача ощутимо усложняется, если объект которым мы заменили, должен иметь те же данные, и находиться в таком же состоянии, что и первоначальный. Чтобы сделать это было проще, нужную часть объекта надо структурировать. К примеру, это можно сделать так:

  • Character
  • Data
  • ControlLogic (скрипты для управления персонажем)
  • RootBone (рутовая кость персонажа; компоненты Animator и скрипты для работы с IK должны быть здесь, иначе они не будут работать)
  • Animation (прочие скрипты для работы с анимацией)
  • Model

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

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

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

  • ObjectView (картинка объекта)
  • Data (данные объекта, используемые в обеих сценах)
  • UpgradeLogic (кнопка и скрипты для сцены улучшений)
  • GameLogic (кнопка и скрипты для сцены игры)

Поле targetGraphic в кнопках должно ссылаться на картинку в ObjectView. Данный подход проверялся в uGUI.

Характеристики объектов в игре

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

Еще вам может понадобиться завести дополнительный класс, в котором будут храниться сами значения в словаре. Таким образом, словарь будет хранить не сами значения, а экземпляры класса-обертки для этих значений. В таком классе может быть:

  • Событие, вызываемое при изменении значения;
  • Значение, которое может быть нескольких видов, например:
  1. минимальное и максимальное значения (например, случайная сила атаки в диапазоне значений 20 — 40);
  2. текущее значение;
  3. текущее и максимальное значения (например, мана).

Так как наборы статов у навыков, персонажей и предметов разные, добиться универсальности будет нелегко. К тому же для предотвращения путаницы, нужно исключить возможность пересечения этих наборов. Для этого мы советуем хранить их в разных enum-ax. Еще появляется проблема задания характеристик в инспекторе, т.к. словари и тип “object” не сериализуются в Unity3D.

В общем, мы не советуем гнаться за универсальность, ведь часто хватает только одного типа данных(float или int), чтобы ваша работа стала проще. Дополнительно можно вынести уникальные характеристики отдельно от словаря. 

Машины состояний, деревья поведений

Чтобы упростить работу с юнитами, персонажами (то есть, объектами со сложным поведением) используются деревья поведений и машины состояний.

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

Состояние объекта может играть роль класса, не владеющим игровой логикой, в котором хранятся определенные данные, к примеру название состояния объекта: защита, бросок.  В другом варианте, класс “состояние” может описывать поведение объекта в определенном состоянии. 

Действие — доступная к выполнению в данном состоянии функция. 

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

Событие — используется для указания потребности выполнить переход в некое другое состояние, при условии, что этот переход возможен из текущего состояния.

На скриншоте вы можете видеть граф машины состояний, которую выполнили в программе PlayMaker. 

e6667ca774f94128b182b3dfd3fe3d40

 

В плагине Behaviour Machine интересно работает машина состояний. Там MonoBehaviour компонент является состоянием, которое отвечает за логику работы в этом состоянии. Так же, состоянием может быть и дерево поведения. Теперь мы рассмотрим иерархическую машину состояний. Иерархическая машина состояний используется, чтобы упростить работу когда состояний много. Ведь это увеличивает количество связей между ними. 

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

Дерево поведения

Чтобы прописать искусственный интеллект в играх было проще, используют деревья поведений (Behavior Tree).

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

Каждый задействованный узел будет возвращать результат, это позволяет выяснить как будут обрабатываться другие узлы дерева. Возвращаемый результат обычно бывает трех видов: “Успех”, “Неудача” и “Выполняется”. 

Сейчас мы быстро разберем основные типы узлов в дереве поведения:

Condition (условие) —  функция, которая позволяет определить, должны ли выполняться следующие за ней узлы. При false в результате вы получите “Ffail”, а при “Ttrue” — “Success”.

Iterator (заменяет цикл “for”) — функция, которая позволяет использовать в цикле серии действий некоторое число раз.

Action Node (действие) — определенная функция, которая выполняется при посещении данного узла.

Sequencer (последовательность) — по порядку выполняет все вложенные в неё узлы. Если какой-то из узлов завершиться неудачно, вы получите “Fail”, если все узлы успешно закончатся, вам вернется результат “Success”

Selector — в отличие от Sequencer, обработка прекращается сразу после того, как любой из вложенных узлов вернет “Success”.

Parallel Node — создает иллюзию выполнения всех вторичных узлов “одновременно”. На самом деле это не так, происходит процесс, аналогичный корутинам в Unity 3d. 

Скриншот показывает вам дерево поведения, которое было создано на базе плагина Behaviour Machine.

behavior tree

 

В какой момент тогда стоит использовать дерево поведения, а когда машину состояний?

Есть книга, которая называется «Artificial Intelligence for Games II». В ней говориться, что деревья поведения, сложно реализуемы, если они должны будут давать отклик на изменения извне. В этой же книге есть два решения, которые можно использовать:

Первое: введение понятия “задача” в дерево поведения. Предполагается, что, чтобы изменить поведение, контроллер дерева поведения будет переходить на другой узел-задачу.

Второе: использовать то, что уже реализовано в плагине Behaviour Machine, т.е. объединить машину состояний и дерево поведений.

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

Мы же посоветуем вам следовать такой логике — если AI самостоятельно получает информацию из игрового мира, то лучше использовать Behavior Tree. Если “искин” чем-то управляется, игроком либо другим AI (к примеру, юнит в играх стратегиях, где он управляется “игроком” - компьютером), тогда выгоднее задействовать стейт-машину. 

Сериализация данных

При массе удобных альтернатив (JSON, SQLite), разработчики все еще зачастую используют XML. Естественно, выбор зависит не только от удобства, но и от поставленных задач. 

К сожалению, многие создатели компьютерных игр, которые используют JSON, либо XML, делают это не оптимизировано. 

Создается куча кода, которая служит лишь для создания/чтения/записи структур данного формата, при этом вы обязательно должны указывать в строковом виде названия имен элементов, к которым надо обращаться. 

Чтобы избежать подобного, лучше использовать сериализацию. В таком случае структура будет генерироваться с помощью кода (Code first подход). Для того чтобы воспользоваться сериализацией XML в Unity 3D,  вы можете использовать встроенные в .NET инструменты. 

Для JSON есть отличный плагин JSONFx. 

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

Модификаторы (баффы/дебаффы)

Благодаря определенным эффектам, характеристики любого игрового объекта могут меняться. В данном контексте сущность, которая изменяет эти характеристики, можно назвать модификатором. 

Модификатор — определенная величина, компонент, в нем записаны характеристики, на которые он может повлиять. Т.е. когда персонаж подвергается какому-либо эффекту, на него используется модификатор (компонент). После чего, данный модификатор использует функцию “применить себя к такому-то объекту”. В нашем случае, это объект “Персонаж”. После чего происходит перерасчет характеристик “Персонажа”, которые может изменить этот модификатор. 

После удаления модификатора (например, эффект имел определенную длительность), снова выполняется перерасчет характеристик. 

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