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

в 19:34, , рубрики: game development, unity3d, архитектура приложений, Проектирование и рефакторинг, проектирование по

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

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

В публикации рассматриваются следующие темы:

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

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

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

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

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

КОП упрощает повторное использование написанного кода – использование одного компонента в разных объектах. Также из различных комбинаций уже существующих компонентов, можно собрать новый тип объекта.

Для примера, возьмем объект «персонаж». С точки зрения ООП – это был бы один большой класс, возможно, наследуемый от чего-то. С точки зрения КОП – это набор компонентов, составляющих объект «персонаж». Например: характеристики/статы персонажа – компонент «Stats», управление персонажем “CharacterController”, анимация персонажа – “CharacterAnimationController”, обработчик столкновений – «CharacterCollisionHandler».

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

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

Многие разработчики, незнакомые или плохо знакомые с КОП, делают одну и ту же ошибку при проектировании сложных систем классов для юнитов, предметов. Или даже правильно выделяют компоненты, но зачем-то делают наследование AI компонентов.
Рассмотрим подробнее иерархию классов юнитов разных типов. Для сокращения текста, юниты в данном контексте могут быть и персонажами и зданиями.
Приемы при проектировании архитектуры игр - 1
Красными линиями показаны проблемные места – наследуемый тип должен обладать свойствами, поведением разных типов. В данном примере, что ни придумывай – ничего не решит проблему постоянных изменений всех классов в данной иерархии.

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

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

Схема в предыдущей части была очень упрощенной. На самом деле компонентов понадобиться гораздо больше. Также понабиться организовывать их взаимодействие друг с другом. Для упрощения работы с объектами со сложным поведением (юниты, персонажи) используются машины состояний, деревья поведений.

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

  • Состояние объекта – может быть как классом без игровой логики, просто хранящий некие данные, например, название состояния объекта: атака, перемещение. Либо класс «состояние» может описывать поведение объекта в конкретном состоянии.
  • Действие – функция, которая может выполниться в данном состоянии.
  • Переход – связь между 2-мя состояниями. Указывает, из какого состояния в какое возможен переход.
  • Событие – некое сообщение/команда, передаваемое в машину состояний или вызываемое внутри нее. Служит для указания, что надо выполнить переход в другое состояние, если это возможно из текущего состояния.

На скриншоте изображена диаграмма (граф) машины состояний, сделанная в PlayMaker.
Приемы при проектировании архитектуры игр - 3

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

2. Иерархическая машина состояний
Когда состояний много, увеличивается количество связей между ними, что усложняет работу с графом машины состояний. Для упрощения работы с ним, можно использовать иерархическую машину состояний. Она отличается тем, что в качестве состояния можно использовать вложенную машину состояний. Таким образом получается древовидная иерархия состояний.

3. Дерево поведения
Для упрощения написания AI, в играх используются деревья поведений (Behavior Tree).
Дерево поведения — это древовидная структура, в качестве узлов которой выступают небольшие блоки игровой логики. Из различных блоков логики разработчик конструирует в визуальном редакторе древовидную структуру, настраивает узлы дерева. Эта структура будет отвечать за принятия решений персонажем и его взаимодействие с игровым миром.

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

Основные типы узлов в дереве поведения:

  • Action Node (действие)
    Просто некоторая функция, которая должна выполниться при посещении данного узла.
  • Condition (условие)
    Обычно служит для того, чтобы определить, выполнять или нет следующие за ним узлы. При true вернет Success, а при false возвращает Fail.
  • Sequencer (последовательность)
    Выполняет все вложенные узлы по порядку, пока какой-либо из них не завершится неудачей (в таком случае возвращает Fail), либо пока все они успешно не завершатся (тогда возвращает Success).
  • Selector (селектор)
    В отличие от Sequencer, прекращает обработку, как только любой вложенный узел вернет Success.
  • Iterator (итератор — выполняет роль цикла for)
    Используется для выполнения в цикле серии действий некоторое число раз.
  • Parallel Node
    Выполняет все свои дочерние узлы «одновременно». Здесь не имеется ввиду, что узлы выполняются несколькими потоками. Просто создается иллюзия параллельного выполнения, аналогично корутинам в Unity3d.

На скриншоте изображено дерево поведения, сделанное в плагине Behaviour Machine.
Приемы при проектировании архитектуры игр - 4

Когда лучше использовать машину состояний, а когда дерево поведения?
В книге «Artificial Intelligence for Games II» на 370-ой странице говорится, что деревья поведения сложнее реализовать, если им нужно реагировать на события извне. Также там предлагается возможное решение – ввести понятия «задача» (например: патрулирование, преследование противника, атака противника) в дерево поведения. Т.е. предполагается, что контроллер дерева поведения будет переходить на другой узел-задачу, чтобы изменить поведение. Также в книге предлагается альтернативный вариант – объединить дерево поведения с машиной состояний. Кстати, в плагине Behaviour Machine это уже реализовано.

Пробовал использовать первый вариант – ввести узлы-задачи в дерево поведения. Сильно усложняется работа, т.к. надо не только реализовать смену задач, но и реализовать «обнуление» переменных завершенной/отмененной задачи.

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

Абстракции игровых объектов

Не стоит рассматривать, что управляемый игроком персонаж – это объект 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 префаба, но тогда, если объектов много, придется настраивать в 2 раза больше префабов.

Можно пойти другим путем и организовать структуру следующим образом:
ObjectView (картинка объекта)

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

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

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

Во многих играх у персонажей, предметов, способностей и т.д. есть какие-то характеристики (здоровье, мана, прочность, продолжительность). Причем у различных типов, набор характеристик отличается.
Из своего опыта могу сказать, что будет удобней работать с характеристиками, если их вынести в отдельный компонент(ы). Другими словами — отделить данные от функционала.

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

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

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

Универсальности добиться довольно сложно. Наборы статов у предметов одни (прочность), у персонажей другие (здоровье, мана), у способностей – третьи (длительность перезарядки). Эти наборы не должны пересекаться, чтобы не возникало путаницы. Лучше их хранить в разных enum-ах, а не в одном. К тому же появляется проблема задания характеристик в инспекторе, т.к. словари и тип “object” не сериализуются в Unity3D.

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

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

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

Модификатор – некий компонент, в котором перечислены характеристики, на которые он влияет, и величины влияния. Возможно будет даже лучше, если модификатор будет влиять только на одну указанную характеристику. Когда на персонажа накладывается эффект, к нему добавляется компонент-модификатор. Далее модификатор вызывает функцию «применить себя к такому-то объекту», и выполняется пересчет характеристик объекта. Причем только тех характеристик, которые он затрагивает. При удалении модификатора – аналогично выполняется перерасчёт. Скорее всего, понадобиться хранить 2 словаря характеристик – актуальных (рассчитанных) и изначальных.

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

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

При разработке игр все еще очень часто используют XML, хотя есть альтернативы, зачастую более удобные – JSON, SQLite, хранения данных в префабах. Конечно, выбор зависит от задач.

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

Вместо всего этого можно использовать сериализацию. Структура XML и JSON в этом случае будет генерироваться из кода (Code First подход).

Для сериализации XML в Unity3D можно использовать встроенные в .NET средства, а для JSON можно использовать плагин JsonFx. Работоспособность обоих решений я проверял на Android. Вроде как должно работать и на других платформах, т.к. используемое API применяется в сторонних кроссплатформенных плагинах.

Пример использования XML сериализации:
Saving and Loading Data: XmlSerialize

Что можно почитать по архитектуре в играх

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

  • Роллингз Э., Моррис Д. Проектирование и архитектура игр (второе издание, 2006 г.)
  • Game Programming Patterns

Также полезно поразбирать сторонние системы визуального сриптинга, например: PlayMaker, Behaviour Machine, Behavior Designer, Rain AI (полезен для изучения, но не удобен в реальных проектах). Из них можно почерпнуть некоторые идеи, посмотреть на какие логические блоки можно разделить игровые классы.

Автор: strannik_k

Источник

Поделиться новостью

* - обязательные к заполнению поля