Здравствуйте, в этой статье я хочу поделиться с читателями своим взглядом на подход к разработке на Unreal Engine 4 и использовании такого полезного класса как Actor Component.

Я заметил, что в разных туториалах к Unreal Engine 4 часто используют глубокую и сложную иерархию наследования классов. Хотя сам движок Unreal Engine 4 подталкивает использовать компонентный подход на базе Actor Component.

Если читатель не знаком с тем, что такое Actor, Actor Component и система визуального скриптинга Blueprints, то сначала я рекомендую ознакомиться со следующими материалами:

Unreal Engine 4/Gameplay Programming/Actors или материал на русском языке Введение в разработку C++ в UE4 Часть 2 (раздел Классы геймплея: Object, Actor и Component)
Туториал по Unreal Engine. Часть 2: Blueprints

Проблема


В качестве примера рассмотрим такую вещь как оружие в шутерах. Обычной практикой является создание Actor класса BaseGun и реализация в этом классе логики стрельбы оружия, разброса при стрельбе, создание эффекта стрельбы, инстанцирование Projectile (пуля) и др. Такой класс, как правило, описывает большую часть функционала разных видов оружия: пистолет, винтовка, автомат. Позже новые типы оружия реализуются наследованием от BaseGun.

А что делать, если в игру нужно добавить дробовик или снайперскую винтовку?

Часто в таком случае создают классы-потомки для новых видов оружия. Для снайперской винтовки класс-потомок будет реализовывать дополнительную логику включения/отключения снайперского прицела и уменьшения разброса при стрельбе через прицел. В случае дробовика нужно будет переписать (override) логику для стрельбы. Ведь дробовик не стреляет пулей — он стреляет дробью.

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

  • создание класса потомка от одного из уже существующих видов оружия или от BaseGun;
  • модифицирование (override) или расширение логики родительского класса.

Вот здесь и появляются проблемы. Чем глубже иерархия классов, тем больше времени требуется программисту чтобы разобраться/вспомнить, как устроена логика выполнения кода. А еще при таком подходе придется постоянно вносить изменения в родительские классы (вплоть до BaseGun). Причина в том, что на момент проектирования базовых классов разработчик почти наверняка не сможет просчитать, какие виды оружия окажутся в финальной версии игры.

Существует еще один способ. Добавить логику для дробовика и снайперской винтовки в класс BaseGun. Но со временем мы получим жирный класс (God object), который делает «слишком много» и нарушает принцип «разделяй и властвуй». Делать такие классы — это порочная стратегия разработки.

Решение


А если перенести логику оружия в компоненты? Сначала определимся со списком действий, которые игрок может делать с оружием. Все эти действия добавляются в Enum E_WeaponActionType.



Для создания оружия нам нужны два класса: BP_BaseWeapon и BP_Weapon_Action.

BP_BaseWeapon


BP_BaseWeapon — это Actor, для всех видов оружия он будет основой и базовым классом. В BP_BaseWeapon мы добавим CustomAction на каждое действие из E_WeaponActionType. Эти действия будут интерфейсом взаимодействия игрока с оружием. Также BP_BaseWeapon будет содержать в себе коллекцию объектов типа BP_Weapon_Action.



BP_Weapon_Action


BP_Weapon_Action — это ActorComponent, потомки этого класса будут реализовывать поведение оружия: выстрел, перезарядку, включение/отключение снайперского прицела и др. Каждый BP_Weapon_Action содержит в себе ссылку на оружие, а также тип действия, которое выполняет конкретный BP_Weapon_Action. Еще BP_Weapon_Action содержит набор public функций для работы с ним из BP_BaseWeapon (StartUseAction, StopUseAction, IsCanUseAction), а также несколько protected функций и свойств.



BP_BaseWeapon и BP_Weapon_Action пометим как абстрактные классы, чтобы случайно их не инстанцировать. Но для их потомков так делать не будем.



А теперь как будет выглядеть автомат с подствольным гранатометом и прицелом. Это будет потомок класса BP_BaseWeapon, в который нужно поместить три компонента:

  • BP_Fire_Action — реализует поведение стрельбы патронами, имеет тип Main из E_WeaponActionType;
  • BP_Scope_Action — реализует включение/отключение отображение снайперского прицела, имеет тип Secondary из E_WeaponActionType;
  • BP_Scope_Action — реализует стрельбу из подствольного гранатомета, имеет тип Special из E_WeaponActionType.

При вызове события в BP_BaseWeapon будет произведен поиск соответствующего поведения, и у него будет вызван StartUseAction. Например, при вызове UseWeaponAction_Main будет найден BP_Fire_Action, и наше оружие выстрелит.



Плюс такого подхода в том, что если для нового вида оружия требуется новое поведение, мы не переписываем BaseGun или какой-то другой класс-родитель и не override-им логику. Мы создаем потомка BP_BaseWeapon, а затем просто добавляем и настраиваем компоненты, которые реализуют поведение нового типа оружия. Получается, мы собираем новые классы из готовых блоков. Если какого-то поведения не оказалось, то мы просто дописываем новый компонент на основе BP_Weapon_Action.

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

  • Gamemode для разных игровых режимов;
  • игровые способности персонажей;
  • создание разнообразных мобов;
  • создание разнообразных интерактивных игровых объектов и др.

Комментарии (3)


  1. Sinatr
    22.01.2018 11:28

    Другими словами: «Composition over inheritance».

    Если оружия много, и необходимо много видов одного действия (отличающихся малыми деталями), то наследование все-таки получше, т.к. можно просто перегрузить (overload) конкретно интересующий метод и все. В вашем случае придется создавать новый экземпляр компонента или рефакторить существующий компонент, что, по сути, нивелирует приемущества.


    1. Fire_Raccoone Автор
      22.01.2018 14:34

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

      А если отличия находятся в логике, тогда нужно рассматривать каждый случай отдельно. Я бы предпочел написать новый класс поведения. Разные классы поведения должны быть максимально независимы от друг друга, чтобы изменения в одном не меняли поведение других классов. Хотя в ряде случаем возможно допустимо наследование, главное не увлекаться.


  1. Flakky
    23.01.2018 12:54

    Шило на мыло поменяли :)

    Плюс такого подхода в том, что если для нового вида оружия требуется новое поведение, мы не переписываем BaseGun


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

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

    Ну и в довесок можно сказать, что вам все равно придется наследовать пушки, хотя бы изходя из того, что вам нужен один класс «Оружие», с которым будет работать менеджер оружия или интерфейс, например. Ну и хранить какой-то общий функционал для всего оружия… А наследование + компоненты это не очень удобно…

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