[Из песочницы] Компонентно-ориентированный движок на C#

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

В теории это звучит очень интересно: всё независимое, мелкое, сложные объекты — всего лишь разные комбинации из более простых. Недавно решил попробовать написать систему, которая будет работать по таким законам, и всё оказалось не так тривиально. За подробностями приглашаю под кат.
Наверное, каждому знакомо чувство, когда хочется поделиться своим решением с другими и обсудить его, узнать чужое мнение, возможно, помочь кому-то.

Работая над проектом для личных учебных целей, я вспомнил о «компонентно-ориентированном» подходе, вновь ощутил все его плюсы и решил серьёзно заняться реализацией, которая мне казалась не совсем тривиальной.

Что это за ориентированность на компоненты? Объекты уже не в тренде?


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

Вероятно, именно поэтому так громко сказано:»компонентно-ориентированное программирование», ведь объект — это уже набор не данных (полей) и поведения (методов), а компонентов — других объектов.

Чем эта идея показалась привлекательной?

Во-первых, любой компонент решает одну задачу — тут даже без знания про Single Responsibility Principle (SOLID) интуитивно понятно, что это хорошо.

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

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

Если взять за основу правило о том, что каждый компонент, присоединённый к объекту-контейнеру, должен так или иначе влиять на возможное поведение последнего, возникает несколько проблем: как любой клиентский код, глядя на этот абстрактный контейнер, узнает о наборе компонентов, которые там находятся?

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

Начнём


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

  • »компонент» — абстрактная функциональная единица системы;
  • »контейнер компонентов» — объект, умеющий хранить в себе набор компонентов.


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

public class Player
{
  public int Health;  // Здоровье.
  public int Mana;  // Мана.
  public int Strength;  // Сила.
  public int Agility;  // Ловкость.
  public int Intellect; // Интеллект.
  public WeaponSlot WeaponSlot;  // Слот для оружия.
}


Теперь покажу как хотелось бы видеть это всё в компонентном стиле:

// Класс игрока теперь является контейнером компонентов.
public class Player : ComponentContainer
{
  // Какая-то специфическая логика, хотя не обязательно.
}

// Компонент базовых характеристик.
public class BaseStats : Component
{
  public int Health; // Здоровье.
  public int Mana;  // Мана.
}

// Компонент характеристик игровых персонажей.
public class CreatureStats : Component
{
  public int Strength;  // Сила.
  public int Agility;  // Ловкость.
  public int Intellect; // Интеллект.
}

// Компонент слота для оружия.
public class WeaponSlot : Component
{
  public Weapon Weapon; // Оружие.
}


Как видите, ни один из компонентов понятия не имеет о других, а также о самом игроке. Как в таком случае мы его создаём?

// Код какой-то фабрики или любого другого объекта.
public Player CreatePlayer()
{
  var player = new Player(); // или new ComponentContainer();
  player.AddComponent();
  player.AddComponent();
  player.AddComponent();

  return player;
}


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

Итак, контекст проблемы следующий: в какой-то момент игрок кликает по модели NPC (или нажимает кнопку на клавиатуре) и активирует вызов диалогового окна. Необходимо отобразить все задания, которые доступны игроку на данный момент с учётом ограничений по уровню.
Попытаюсь набросать краткий набросок того, как это будет выглядеть:

// ... код открытия диалогового окна.
// Как-то получаем ссылки на "действующих лиц" - двух контейнеров.
var player = GetPlayer();
var questGiverNpc = GetQuestGiver();

var playerStats = player.GetComponent();
if (playerStats == null) return;
// Игрок не может брать задания у этого персонажа, если его уровень меньше 10.
if (playerStats.Level < 10) return;

var questList = questGiverNpc.GetComponent();
if (questList == null) return;

// Заберём все доступные игроку задания.
var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level);
// ... какие-то манипуляции с этими заданиями.


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

Первый шаг: вынести взаимодействие объектов-контейнеров в отдельный слой. Таким образом появляется абстракция Interactor (от англ. interaction — взаимодействие, следовательно, interactor — тот, кто взаимодействует).

При разработке любой системы, я люблю представлять, как будет выглядеть код самого верхнего уровня, иными словами: «Если это фреймворк, то как с ним будет работать конечный пользователь?»

Возьмём за основу код прошлого примера с заданиями:

// ... игрок попытался поговорить с NPC.
var player = GetPlayer();
var questGiver = GetQuestGiver();
player.Interact(questGiver).Using();


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

public class QuestDialogInteractor : Interactor
{
   public void Interact(ComponentContainer a, ComponentContainer b)
   {
     var player = a as Player;
     if (player == null) return;
     var questGiver = b as QuestGiver;
     if (questGiver == null) return;

     var playerStats = player.GetComponent();
     if (playerStats == null) return;
     if (playerStats.Level < 10) return;

     var questList = questGiverNpc.GetComponent();
     if (questList == null) return;
            
     var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level);

    // Манипуляции с заданиями.
   }
}


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

if (playerStats.Level < 10) 


Один персонаж выдаёт задачи для 5-го уровня, другой для 27-го и т.д. Во-вторых, самый серьёзный прокол: есть зависимость от типов Player и QuestGiver. Их можно заменить на ComponentContainer, но что, если мне таки понадобятся ссылки на конкретные типы? А понадобились на эти типы, понадобятся и на другие. Любое изменение приведёт к нарушению Open/Closed Principle (SOLID).

Рефлексия


Решение нашлось в механизме мета-данных, предлагаемом в .NET, который позволяет ввести ряд правил и ограничений.

Общая идея заключается в том, чтобы иметь возможность определять методы в наследнике типа Interactor, которые принимают два параметра с типами, производными от ComponentContainer. Такой метод не будет является дееспособным, если не пометить его атрибутом [InteractionMethod].

Таким образом, предыдущий код превращается в интуитивно-понятный:

public class QuestDialogInteractor : Interactor
{
   [InteractionMethod]
   public void PlayerAndQuestGiver(Player player, QuestGiver questGiver)
   {
     var playerStats = player.GetComponent();
     if (playerStats == null) return;
     if (playerStats.Level < 10) return;

     var questList = questGiverNpc.GetComponent();
     if (questList == null) return;
            
     var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level);

     // Манипуляции с заданиями.
   }
}


Всё ещё бросаются в глаза эти «попытки» достать компонент из контейнера, которые хотелось бы куда-то убрать.

C помощью того же инструмента, вводим дополнительный контракт в виде атрибута [RequiresComponent (parameterName, ComponentType)]:

public class QuestDialogInteractor : Interactor
{
   [InteractionMethod]
   [RequiresComponent("player", typeof(StatsComponent))]
   [RequiresComponent("questGiver", typeof(QuestList))]
   public void PlayerAndQuestGiver(Player player, QuestGiver questGiver)
   {
     var playerStats = player.GetComponent();
     if (playerStats.Level < 10) return;

     var questList = questGiverNpc.GetComponent();    
     var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level);

     // Манипуляции с заданиями.
   }
}


Вот теперь всё выглядит чистым и аккуратным. Что изменилось с первоначального варианта:

  • добавили слой Interaction;
  • добавили правила и ограничения.


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

public class ComponentContainer
{
   public void AddComponent(Component component)
   {
     // код... Добавляем, кешируем.
     component.OnAttach(this);
   }
}


Метод OnAttach в свою очередь находит у конкретного типа компонента (с помощью рефлексии и полиморфизма) метод, помеченный атрибутом [AttachHandler], который умеет работать с конкретным типом контейнера.

В случае, если такому компоненту для работы необходимо наличие некоторых других компонентов контейнера, его класс можно пометить тем же атрибутом [RequiresComponent (ComponentType)].

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

[RequiresComponent(typeof(PositionComponent))]
[RequiresComponent(typeof(SpriteBatch))]
public class TextureDrawComponent : Component
{
    [AttachHandler]
    public void OnTextureHolderAttach(ITexture2DHolder textureHolder)
    {
      // В интерфейсе ITexture2DHolder нет метода GetComponent, но
      // он есть в базовом ComponentContainer, который сперва приходит в родитель Component,
      // поэтому можно сделать protected метод GetComponent для всех наследников Component.
      var spriteBatch = GetComponent();

      spriteBatch.Draw(textureHolder.Texture2D, GetComponent(), Color.White);
    }
}


Напоследок я бы хотел привести ещё парочку очень простых примеров взаимодействия:

// Игрок атакует монстра.
player.Interact(monster).Using();

// Игрок использует зелье лечения.
player.Interact(healthPotion).Using();

// Игрок подбирает предметы с убитого монстра.
player.Interact(monster).Using();


Итоги


Пока что система готова не полностью: есть несколько окон для расширения, оптимизации (всё-таки активно используется рефлексия, надо не поскупиться на кеширование), необходимо более тщательно продумать взаимодействие ОЧЕНЬ сложных сущностей.
Хотелось бы добавить в атрибут [RequiresComponent] вариант поведения в случае, если код не соответствует контракту (игнорировать или бросать исключение).

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

Сам подход я назвал CIM — Component Interactor Model, и планирую тщательно проверить его на работоспособность в ближайших «домашних» проектах. Если тема кого-то заинтересует, в следующей части могу выложить рассмотреть source-code таких классов как Component, ComponentContainer, реализация, связанная с Using и Interactor.

Спасибо за внимание!

© Habrahabr.ru