Component Oriented C # Engine

Several times I came across on the Internet articles on " component-oriented programming ", the general idea of ​​which is to present each complex object as a set of independent functional blocks - components.

In theory, this sounds very interesting: everything is independent, small, complex objects - just different combinations of simpler ones. Recently I decided to try to write a system that will work according to such laws, and everything turned out to be not so trivial. For details, I invite you to Cat.

Probably everyone knows the feeling when you want to share your decision with others and discuss it, find out someone else’s opinion, perhaps help someone.

While working on the project for personal training purposes, I remembered the “component-oriented” approach, again felt all its advantages and decided to seriously take up the implementation, which seemed to me not quite trivial.

What is this component orientation? Are objects no longer in trend?


Imagine that any complex object of your system looks like a set of smaller ones, each of which can perform its own and only its own task, but together this symphony of components generates exactly the behavior that you need. All that remains is to construct all complex entities and launch the application.

Perhaps this is why it is so loudly said: " component-oriented programming, " because an object is no longer a set of data (fields) and behavior (methods), but components - other objects.

How did this idea seem attractive?

First, any component solves one problem - even without knowing about the Single Responsibility Principle (SOLID), it is intuitively clear that this is good.

Secondly, the components are independent of each other: everyone knows only what he does and what he needs for this. Such a component is easy to maintain, modify, reuse and test.

Maybe I'm a romantic, but these two positions were enough to head deep into thinking over how to implement such a solution.

If we take as a basis the rule that each component attached to the container object must somehow influence the possible behavior of the latter, several problems arise: as any client code, looking at this abstract container, learns about the set of components that are there are located?

Another nuance: it is necessary to provide for the possibility that one component will need “communication” with the other, if both of them are on the same container.

Let's start


Following the rules of common sense, you must try to describe as abstractly as possible the process of how each of the elements of the future system will work. The simplest consequence is the allocation of two abstract entities:

  • " component " is an abstract functional unit of a system;
  • " component container " - an object that can store a set of components.

I really like to work out any complex things with the most simple and intuitive examples, so let's try to imagine a very simple entity “Player” in a traditional object style:

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

Now I’ll show how I would like to see it all in component style:

// Класс игрока теперь является контейнером компонентов.
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; // Оружие.
}

As you can see, none of the components has a clue about the others, nor about the player himself. How then do we create it?

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

If the effectiveness of the design of the system manifests itself precisely when changes come, then with the current version we will have problems if we try to consider a more complex example: the player’s interaction with a non-game character (NPC).

So, the context of the problem is as follows: at some point, the player clicks on the NPC model (or presses a button on the keyboard) and activates a dialog box call. It is necessary to display all the tasks that are currently available to the player, taking into account level restrictions.
I will try to sketch a brief outline of how this will look:

// ... код открытия диалогового окна.
// Как-то получаем ссылки на "действующих лиц" - двух контейнеров.
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);
// ... какие-то манипуляции с этими заданиями.

As you can see, given that we do not know (and do not want to know) anything about the contents of containers, the only way is to try to get the appropriate components. Sometimes, such a solution is enough, but I wanted to go further and understand what else can be done with this and how to transform it in order to turn it into a very convenient model.

The first step: move the interaction of container objects into a separate layer. Thus, the abstraction of Interactor appears (from the English interaction - interaction, therefore, interactor is the one who interacts).

When developing any system, I like to imagine what the code of the highest level will look like, in other words: “If this is a framework, how will the end user work with it?”

Let's take as a basis the code of the past example with tasks:

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

The whole intrigue went to the QuestDialogInteractor class . How to organize it to magically achieve the result? I will show the simplest and most obvious, again based on the previous example:

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);
    // Манипуляции с заданиями.
   }
}

Almost immediately it is clear that the current implementation is terrible. Firstly, we fully got involved in checking:

if (playerStats.Level < 10) 

One character issues tasks for the 5th level, another for the 27th, etc. Secondly, the most serious puncture: there is a dependence on the types Player and QuestGiver . You can replace them with a ComponentContainer , but what if I still need references to specific types? And they were needed for these types, they will be needed for others. Any change will violate the Open / Closed Principle (SOLID).

Reflection


The solution was found in the meta-data mechanism proposed in .NET, which allows a number of rules and restrictions to be introduced.

The general idea is to be able to define methods in an inheritor of type Interactor that take two parameters with types derived from ComponentContainer . Such a method will not be capable if you do not mark it with the [InteractionMethod] attribute .

Thus, the previous code becomes intuitive:

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);
     // Манипуляции с заданиями.
   }
}

These “attempts” to get the component out of the container, which I would like to remove somewhere, are still striking.

Using the same tool, we introduce an additional contract in the form of the attribute [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);
     // Манипуляции с заданиями.
   }
}

Now everything looks clean and tidy. What has changed from the original version:

  • added layer Interaction ;
  • added rules and restrictions.

In addition to the interaction of the two container objects, there was a problem with how to ensure the interaction between several components within the same container. To solve this problem, I used a similar approach to the previous one: when the user adds a component to the container, he calls the first handler method, passing himself as a parameter:

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

The OnAttach method , in turn, finds a method marked with the [AttachHandler] attribute on a specific type of component (using reflection and polymorphism) , which can work with a specific type of container.

If such a component requires some other container components to work, its class can be marked with the same attribute [RequiresComponent (ComponentType)] .

Consider the example of a component whose task is to draw a texture using the XNA library :

[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);
    }
}

Finally, I would like to give a couple more very simple examples of interaction:

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

Summary


So far, the system is not completely ready: there are several windows for expansion and optimization (nevertheless, reflection is actively used, you should not skimp on caching), you need to more carefully consider the interaction of VERY complex entities.
I would like to add a behavior option in the [RequiresComponent] attribute if the code does not comply with the contract (ignore or throw an exception).

It is likely that, in the end, this “race” for extensibility and convenience will not lead to anything good, but, in any case, there will be an invaluable experience.

The approach itself I called CIM - Component Interactor Model, and I plan to carefully check it for performance in the next "home" projects. If someone is interested in the topic, in the next part I can consider the source-code of such classes as Component , ComponentContainer , implementation related to Usingand Interactor .

Second part.

Thanks for attention!

Also popular now: