- PVSM.RU - https://www.pvsm.ru -
К сожалению, нигде нет более менее полной публикации на тему проектирования архитектуры в играх. Есть отдельные статьи на конкретные темы, но нигде все это вместе не собрано. Каждому разработчику приходится самостоятельно по крупицам собирать подобную информацию, набивать шишки. Поэтому решил попробовать собрать часть из этого воедино в данной статье.
Для примеров будет использоваться популярный движок Unity3D. Рассматриваются подходы, применимые в больших играх. Написано из моего личного опыта и из того, как я это понимаю. Конечно, где-то я могу быть не прав, где-то можно лучше сделать. Я тоже все еще в процессе набирания опыта и набивания новых шишек.
В публикации рассматриваются следующие темы:
В больших играх довольно сложная архитектура. Сложные сущности и сложное взаимодействие между классами. Если пытаться разрабатывать игры, используя стандартный ООП подход, то гарантированы постоянные переделки куча кода и сильное увеличение длительности разработки.
Проблема кроется в наследовании (проблема хрупких базовых классов — ситуация, когда изменить реализацию типа-предка невозможно, не нарушив корректность функционирования типов-потомков). При поиске решений для борьбы с этой проблемой был сформирован Компонентно-ориентированный подход (КОП). Вкратце, суть КОП следующая:
«Есть некий класс-контейнер, а также класс-компонент, который можно добавить в класс-контейнер. Объект состоит из контейнера и компонентов в этом контейнере.»
Компоненты немного напоминают интерфейсы. Но интерфейсы позволяют только выделить у классов общую сигнатуру функций и свойств, а компоненты позволяют вынести общую реализация классов отдельно.
В ООП подходе объект определяется описываемым его классом.
В КОП подходе объект определяется компонентами, из которых он состоит. Не важно, что это за объект. Важно, что у него есть и что он умеет делать.
КОП упрощает повторное использование написанного кода – использование одного компонента в разных объектах. Также из различных комбинаций уже существующих компонентов, можно собрать новый тип объекта.
Для примера, возьмем объект «персонаж». С точки зрения ООП – это был бы один большой класс, возможно, наследуемый от чего-то. С точки зрения КОП – это набор компонентов, составляющих объект «персонаж». Например: характеристики/статы персонажа – компонент «Stats», управление персонажем “CharacterController”, анимация персонажа – “CharacterAnimationController”, обработчик столкновений – «CharacterCollisionHandler».
Не стоит отказываться от наследования в играх. Наследование компонентов вполне нормальная практика. Да и в некоторых ситуациях, это будет более правильным. Но, если видно, что будет несколько уровней наследования классов для описания объектов, то лучше использовать компоненты.
Многие разработчики, незнакомые или плохо знакомые с КОП, делают одну и ту же ошибку при проектировании сложных систем классов для юнитов, предметов. Или даже правильно выделяют компоненты, но зачем-то делают наследование AI компонентов.
Рассмотрим подробнее иерархию классов юнитов разных типов. Для сокращения текста, юниты в данном контексте могут быть и персонажами и зданиями.
Красными линиями показаны проблемные места – наследуемый тип должен обладать свойствами, поведением разных типов. В данном примере, что ни придумывай – ничего не решит проблему постоянных изменений всех классов в данной иерархии.
На картинке ниже показано, как часть этой схемы могла бы выглядеть при использовании КОП.
Т.к. объекты собираются из компонентов, то уже нет проблемы, что при изменении одного объекта понабиться изменять другие. Также не надо больше ломать голову над созданием иерархии классов, подходящей всем объектам.
Схема в предыдущей части была очень упрощенной. На самом деле компонентов понадобиться гораздо больше. Также понабиться организовывать их взаимодействие друг с другом. Для упрощения работы с объектами со сложным поведением (юниты, персонажи) используются машины состояний, деревья поведений.
1. Машина состояний
Логика объекта разбивается на состояния, события, переходы, а также может разбиваться еще и на действия. Вариации реализации этих элементов могут заметно отличаться.
На скриншоте изображена диаграмма (граф) машины состояний, сделанная в PlayMaker.
Интересно реализована машина состояний в плагине Behaviour Machine. Там состоянием является MonoBehaviour компонент, отвечающий за логику работы в этом состоянии. Причем, состоянием также может быть дерево поведения.
2. Иерархическая машина состояний
Когда состояний много, увеличивается количество связей между ними, что усложняет работу с графом машины состояний. Для упрощения работы с ним, можно использовать иерархическую машину состояний. Она отличается тем, что в качестве состояния можно использовать вложенную машину состояний. Таким образом получается древовидная иерархия состояний.
3. Дерево поведения
Для упрощения написания AI, в играх используются деревья поведений (Behavior Tree).
Дерево поведения — это древовидная структура, в качестве узлов которой выступают небольшие блоки игровой логики. Из различных блоков логики разработчик конструирует в визуальном редакторе древовидную структуру, настраивает узлы дерева. Эта структура будет отвечать за принятия решений персонажем и его взаимодействие с игровым миром.
Каждый узел возвращает результат, от которого зависит, как будут обрабатываться остальные узлы дерева. Варианты возвращаемого результата обычно следующие: успех, неудача, выполняется.
Основные типы узлов в дереве поведения:
На скриншоте изображено дерево поведения, сделанное в плагине Behaviour Machine.
Когда лучше использовать машину состояний, а когда дерево поведения?
В книге «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
Подобное разбиение позволит изменить внешний вид объекта или контроллер анимации, не сильно затрагивая остальные компоненты.
Теперь рассмотрим второй случай.
Например, есть некий объект со спрайтом и данными. При клике на него в сцене улучшений, нужно улучшить данный объект. А при клике на него в сцене игры должно выполниться какое-то игровое действие.
Можно сделать 2 префаба, но тогда, если объектов много, придется настраивать в 2 раза больше префабов.
Можно пойти другим путем и организовать структуру следующим образом:
ObjectView (картинка объекта)
Поле targetGraphic в кнопках должно ссылаться на картинку в ObjectView.
Данный подход проверялся в uGUI.
Во многих играх у персонажей, предметов, способностей и т.д. есть какие-то характеристики (здоровье, мана, прочность, продолжительность). Причем у различных типов, набор характеристик отличается.
Из своего опыта могу сказать, что будет удобней работать с характеристиками, если их вынести в отдельный компонент(ы). Другими словами — отделить данные от функционала.
Чтобы не создавать кучу классов для хранения различных характеристик в сложной системе, лучше хранить их в словаре в отдельном классе, контролирующим работу с этим словарем.
Также, скорее всего, понадобиться завести специальный класс для хранения самих значений в словаре. Таким образом, словарь будет хранить не сами значения, а экземпляры класса-обертки для этих значений.
Что может быть в таком классе:
Универсальности добиться довольно сложно. Наборы статов у предметов одни (прочность), у персонажей другие (здоровье, мана), у способностей – третьи (длительность перезарядки). Эти наборы не должны пересекаться, чтобы не возникало путаницы. Лучше их хранить в разных 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 [1]
В электронном виде есть перевод следующих книг на русский язык:
Также полезно поразбирать сторонние системы визуального сриптинга, например: PlayMaker, Behaviour Machine, Behavior Designer, Rain AI (полезен для изучения, но не удобен в реальных проектах). Из них можно почерпнуть некоторые идеи, посмотреть на какие логические блоки можно разделить игровые классы.
Автор: strannik_k
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/arhitektura-prilozhenij/88642
Ссылки в тексте:
[1] Saving and Loading Data: XmlSerialize: http://wiki.unity3d.com/index.php?title=Saving_and_Loading_Data:_XmlSerializer
[2] Game Programming Patterns: http://gameprogrammingpatterns.com/contents.html
[3] Источник: http://habrahabr.ru/post/255561/
Нажмите здесь для печати.