Перевод статьи Мика Веста с его блога
Cowboy Programming
Рефакторинг игровых сущностей с помощью компонентов
До недавнего времени игровые программисты для представления игровых сущностей использовали глубокую иерархию классов. Но сейчас началось продвижение от использования этих глубоких иерархий к целому ряду методов, которые представляют объект игровой сущности как объединение компонентов. Эта статья объясняет, что это значит, и показывает некоторые преимущества и практические соображения такого подхода. Я опишу свой личный опыт во внедрении такой системы в большую кодовую базу, и в том числе, как преподнести идею другим программистам и менеджменту.
ИГРОВЫЕ СУЩНОСТИ
Различные игры имеют различные требования к тому, что должна представлять из себя игровая сущность, но в большинстве игр концепции игровых сущностей очень похожи. Игровая сущность - это некий объект, существующий в игровом мире, как правило, видимый игроку, и, как правило, способный передвигаться.
Примеры таких сущностей:
- Ракета
- Машина
- Танк
- Граната
- Ружье
- Герой
- Пешеход
- Чужой
- Реактивный ранец
- Аптечка
- Скала
Сущности обычно совершают различные действия. Например, сущности могут:
- Запускать скрипт
- Передвигаться
- Взаимодействовать как твердые тела
- Испускать частицы
- Проигрывать звук
- Подбираться игроком
- Выкидываться игроком
- Взрываться
- Взаимодействовать с магнитами
- Быть мишенью для игрока
- Следовать пути
- Анимироваться
ТРАДИЦИОННЫЕ ГЛУБОКИЕ ИЕРАРХИИ
Традиционный способ представления такого набора игровых сущностей - проведение объектно-ориентированной декомпозиции сущностей. Она обычно проводится с большим энтузиазмом, но часто меняется в ходе разработки - особенно, если движок повторно используется для другой игры. В итоге получается что-то вроде изображения 1, но с намного большим количеством узлов в иерархии классов.
В ходе разработки нам обычно нужно добавлять различную функциональность к сущностям. Объекты должны либо инкапсулировать функциональность сами, либо должны быть производными от объекта, включающего эту функциональность. Часто функциональность добавляется в иерархию классов на некоторый уровень недалеко от корня, как, например, у класса CEntity. Преимуществом этого является то, что функциональность может быть доступна для всех производных классов, а недостатком - оверхед, который несут подобные классы.
Даже довольно простые объекты, такие как скалы или гранаты, могут в конечном итоге иметь большое количество дополнительной функциональности (а значит и лишние данные-члены, приводящие к выполнению ненужных методов). Часто традиционная иерархия игровых объектов приводит к созданию так называемого "блоб" (blob). Блоб - это классический "анти-паттерн", представляющий собой гигантский класс (или специальную ветку в иерархии классов) с большим количеством сложных взаимосвязанных функций.
Хотя Блоб обычно появляется где-то около корня иерархии объектов, он также может появиться и в узлах-листьях. Наиболее любимый кандидат для этого - класс, представляющий игрока. Поскольку игра обычно программируется вокруг одного персонажа, объект, представляющий этого персонажа, часто имеет очень большой объем функциональности. Часто это реализуется в виде большого числа функций-членов класса, такого как CPlayer.
Результатом реализации Блоб около корня иерархии является перегрузка объектов-листьев ненужной функциональностью. Однако, противоположный метод - реализация функциональности в листовых узлах - может также иметь неблагоприятные последствия. Функциональность в этом случае становится разделенной, так что ее могут использовать только те объекты, которые специально запрограммированы для ее использования. Программистам придется дублировать код, чтобы добавить в объект функциональность, уже реализованную в другом объекте. Для перемещения и комбинирования функциональности потребуется рефакторинг.
Возьмем для примера возможность объекта взаимодействовать по физическим законам как твердое тело. Делать это должен не каждый объект. Как вы можете видеть на схеме 1, производными от CRigid у нас являются только классы CRock и CGrenade. Что случится, если мы захотим применить эту функциональность к машинам? Мы должны передвинуть класс CRigid вверх по иерархии, тем самым делая его все более и более похожим на Блоб около корня со всей функциональностью, собранной в узкой цепочке классов, из которых произведены большинство других классов сущностей.
АГРЕГИРОВАНИЕ КОМПОНЕНТОВ
Компонентный подход, имеющий все большую популярность в современном геймдеве, представляет собой разделение функциональности на индивидуальные компоненты, которые, как правило, независимы друг от друга. Традиционная иерархия объектов становится более свободной, а отдельный объект теперь просто является коллекцией независимых компонентов.
Теперь каждый объект использует только ту функциональность, которая ему нужна. Любая отдельная функциональность создается, как компонент.
Система формирования объекта через агрегирование компонентов может быть создана одним из трех способов, которые можно рассматривать в качестве отдельных этапов от иерархии Блобов к составному объекту.
ОБЪЕКТ КАК ОРГАНИЗОВАННЫЙ БЛОБ
Обычно рефакторинг Блоба заключается в разделении его функциональности на субобъекты, на которые будет ссылаться первый объект. После этого родительский Блоб обычно становится просто коллекцией указателей на другие объекты, а его функции превращаются в интерфейсные функции к субобъектам.
Это может быть действительно хорошим решением, если объем функциональности ваших игровых объектов достаточно мал или если время ограничено. Вы можете организовать агрегацию произвольного объекта, просто позволяя некоторым субобъектам отсутствовать (указатель на них будет NULL). Если существует не так много субобъектов, у вас будет коллекция псевдосоставных объектов, и Вам даже не нужно будет создавать framework для управления компонентами данного объекта.
Но с другой стороны, это все еще Блоб. Функциональность все еще инкапсулирована в одном огромном объекте. Врядли Вы полностью разделите Блоб на субобъекты, у вас все равно останется довольно значительный оверхед, который утяжелит Ваши легкие объекты. Также Вам придется постоянно проверять все указатели субобъектов на равенство NULL.
ОБЪЕКТ КАК КОНТЕЙНЕР КОМПОНЕНТОВ
Следующий этап - это разделение каждого из компонентов ("субобъектов" в предыдущем примере) на объекты, имеющие общий базовый класс, так что теперь мы можем хранить список компонентов внутри объекта.
Это промежуточное решение, поскольку мы все еще имеем корневой объекта, описывающий игровую сущность. Тем не менее, это может быть разумным решением или даже единственным возможным решением, если большая часть кода требует представления игровой сущности, как конкретного объекта.
Ваш игровой объект теперь стал интерфейсом, действующим как мост между старым кодом вашей игры и новой системой составных объектов. Если позволит время, Вы в конечном счете устраните представление игровой сущности как монолитного объекта, и вместо этого будете рассматривать объект только через его компоненты. В конце концов, вы сможете перейти к чистой агрегации.
ОБЪЕКТ КАК ЧИСТАЯ АГРЕГАЦИЯ
В этом окончательном строении объект - это просто сумма его частей. На схеме 2 каждая игровая сущность состоит из коллекции компонентов. Объекта "игровая сущность" не существует, как такового. Каждая колонка в диаграмме представляет собой список одинаковых компонентов, каждую строку можно понимать, как объект.
ПРАКТИЧЕСКИЙ ОПЫТ
Впервые я внедрил систему составления объектов из компонентов, работая в Neversoft над серией игр Tony Hawk. Наша система игровых объектов развивалась в течение трех успешных игр до тех пор, пока у нас не получилась иерархия, схожая с Блобом, о котором я говорил раньше. Она имела в точности такие же проблемы: объекты были тяжеловесными. Они обладали не нужными данными и функциональностью. Иногда эта функциональность замедляла игру. Также она дублировалась в разных ветвях дерева.
Я услышал о системе "component based objects" на одном геймдевелоперсокм мейлинг-листе и решил, что это очень хорошая идея. Я решил реорганизовать кодовую базу, и два года спустя это было сделано.
Почему так долго? Ну, во-первых, мы выпускали игры серии Tony Hawk каждый год, поэтому не удавалось посвятить рефакторингу много времени. Во-вторых, я недооценил масштаб проблемы. Кодовая база, наработанная за три года, была огромной. Большое количество кода превратилось во что-то монолитное. Поскольку код опирался на игровые объекты, пришлось проделать очень много работы, чтобы заставить все работать, как компоненты.
ЖДИТЕ СОПРОТИВЛЕНИЯ
Первая проблема, с которой я столкнулся, были попытки объяснить эту систему другим программистам. Если вы не очень знакомы с идеей композиции и агрегирования объектов, она может показаться вам бессмысленной, необоснованно сложной, и требующей необязательной работы. Программисты, работавшие с традиционной системой иерархий объектов много лет, набили себе руки на этом и работали над проблемами по мере их возникновения.
Представление идеи руководству также является сложностью. Вы должны быть в состоянии объяснить простыми словами, как именно это происходит, и как поможет игре делаться быстрее. Нечто вроде этого:
"Всякий раз, когда мы добавляем новую функциональность для игры, это занимает много времени и вызывает много багов. Если мы сделаем эту компонентную штуку, это позволит нам добавлять функциональность намного быстрее и иметь меньше багов."
Мой подход заключался в том, чтобы внедрить это незаметно. Сначала я индивидуально обсудил эту идею с парой программистов, и, в конечном счете, убедил их, что идея хорошая. Потом я создал базовый framework для общих компонентов и осуществил один небольшой аспект функционирования объекта, как компонента.
Потом я представил это остальным программистам. Было некоторое замешательство и сопротивление, но поскольку оно было внедрено и работало, много аргументов против не было.
МЕДЛЕННЫЙ ПРОГРЕСС
Когда framework был создан, переход от статической иерархии к композиции объектов происходил очень медленно. Это неблагодарная работа, поскольку Вы вынуждены проводить часы и даже дни за рефакторингом кода во что-то, не отличающееся своими функциями от кода, который оно заменяет. Кроме того, мы делали это во время добавления новых фич для следующей итерации игры.
На раннем этапе мы встретили проблему рефакторинга нашего самого большого класса - класса скейтера. Поскольку он содержит огромный объем функциональности, было невозможно переделать его за короткий промежуток времени. Плюс ко всему, он не мог быть переделан до тех пор, пока другие системы в игре не станут работать через компоненты. Они, в свою очередь, тоже не могли быть переделаны, пока скейтер не станет компонентом.
Решение заключалось в том, чтобы создать "Блоб-компонент". Это огромный единый компонент, вобравший в себя всю функциональность класса скейтера. Еще несколько Блоб-компонентов понадобились и в других местах, и мы в конечном итоге переклепали всю систему объектов в коллекцию компонентов. Как только это произошло, Блоб-компоненты постепенно разделялись на более мелкие компоненты.
РЕЗУЛЬТАТЫ
Первые результаты такого рефакторинга были едва ощутимы. Но со временем код стал чище и легче для понимания, поскольку функциональность была разделена на компоненты. Программисты стали создавать новые типы объектов намного быстрее, просто комбинируя несколько компонентов и добавляя новые.
Мы разработали data driven систему создания объектов, так что объекты совершенно нового типа могли быть созданы дизайнерами. Это внесло неоценимый вклад в быстроту создания и конфигурирования новых типов объектов.
В конце концов программисты (с разным успехом) приняли компонентную систему и адаптировались добавлять функциональность через компоненты. Общий интерфейс и строгая инкапсуляция привели к уменьшению количества ошибок, а код стало легче читать, сопровождать и повторно использовать.
ПОДРОБНОСТИ РЕАЛИЗАЦИИ
Предоставление компоненту общего интерфейса означает наследование его от базового класса с виртуальными функциями. Это влечет дополнительный оверхед. Не стоит из-за этого отказываться от идеи, так как этот оверхед мал по сравнению с сэкономленным на упрощение объектов временем.
Поскольку каждый компонент имеет общий интерфейс, очень легко добавить дополнительные дебаг-функции к каждому компоненту. Это позволит легко создать инспектор объектов, который производит дамп содержимого компонентов в человекочитаемом формате. Позже это превратится в сложный инструмент для remote-дебага, который всегда знает обо всех возможных типах игровых объектов. Это как раз то, что всегда утомительно осуществлять и поддерживать при традиционной иерархии.
В идеале компоненты не должны знать друг о друге. Однако, на практике всегда существуют связи между отдельными компонентами. Издержки производительности диктуют, что компоненты должны быстро получать доступ к другим компонентам. Изначально все связи между компонентами у нас проходили через менеджер компонентов, но когда это начало использовать более 5% CPU, мы позволили компонентам хранить указатели друг на друга и вызывать функции друг друга напрямую.
Порядок композиции компонентов в объекте может быть очень важен. В нашей начальной системе мы хранили компоненты в списке, хранящемся в объекте. У каждого компонента была функция "update", которая вызывалась, когда мы проходили по списку компонентов каждого объекта.
Создание объекта было data driven, и, если список компонентов был неупорядочен, это могло создать проблемы. Если один объект обновляет физику до анимации, а другой обновляет анимацию до физики, они могут быть не синхронизированы друг с другом. Зависимости, такие как эта, должны быть установлены и оговорены в коде.
ВЫВОДЫ
Переход от иерархий объектов с Блобом к составным объектам, сделанным из коллекции компонентов, был одним из лучших решений, которые я сделал. Первичные результаты были разочаровывающими, поскольку рефакторинг существующего кода занял много времени. Однако, конечные результаты стоили этого, сделав код легковесным, гибким, надежным и повторно использующимся.