📜 ⬆️ ⬇️

Component-oriented C # engine

Several times I came across articles about " component-oriented programming " on the Internet, 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: all independent, small, complex objects are 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 under the cat.

Probably, everyone knows the feeling when you want to share your decision with others and discuss it, get to know someone else's opinion, maybe help someone.

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

What is this component orientation? Objects are no longer in trend?


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

This is probably why it says so loudly: " component-oriented programming, " because an object is already a collection not of data (fields) and behavior (methods), but components — of other objects.

How did this idea seem attractive?

Firstly, any component solves one problem - here even without knowledge of 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 that. Such a component is easy to maintain, modify, reuse and test.

I may be a romantic, but these two positions were enough to plunge deep into thinking about how to implement such a decision.

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

Another caveat: it is necessary to provide the possibility that one component will need to “communicate” with another, if they are both located on the same container.

Let's start


Following the rules of common sense, it is necessary to 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 selection of two abstract entities:


I really like any complex things to work on as simple and intuitive examples as possible, so let's try to present the very simple “Player” entity in the 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 let me show you how I would like to see it all in the 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 have any idea 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<BaseStats>(); player.AddComponent<CreatureStats>(); player.AddComponent<WeaponSlot>(); return player; } 

If the effectiveness of the system design is manifested exactly 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 the dialog box call. It is necessary to display all the tasks that are available to the player at the moment, taking into account the level restrictions.
I will try to sketch a brief outline of how it will look like:

 // ...    . // -    " " -  . var player = GetPlayer(); var questGiverNpc = GetQuestGiver(); var playerStats = player.GetComponent<StatsComponent>(); if (playerStats == null) return; //        ,     10. if (playerStats.Level < 10) return; var questList = questGiverNpc.GetComponent<QuestList>(); 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 the containers, the only way is to try to get the appropriate components. Sometimes, such a decision 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.

First step: remove the interaction of container objects in a separate layer. Thus, an abstraction of Interactor appears (from the English. Interaction - interaction, therefore, the interactor is the one who interacts).

When developing any system, I like to imagine what the top level code 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 last example with tasks:

 // ...     NPC. var player = GetPlayer(); var questGiver = GetQuestGiver(); player.Interact(questGiver).Using<QuestDialogInteractor>(); 

All intrigue went to the class QuestDialogInteractor . How to organize it to magically achieve the result? I will show the most simple and 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<StatsComponent>(); if (playerStats == null) return; if (playerStats.Level < 10) return; var questList = questGiverNpc.GetComponent<QuestList>(); if (questList == null) return; var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level); //   . } } 

Almost immediately it is clear that the current implementation is terrible. First, we are fully tied to the check:

 if (playerStats.Level < 10) 

One character issues tasks for level 5, another for level 27, and so on. Secondly, the most serious puncture: there is a dependence on the Player and QuestGiver types . They can be replaced by ComponentContainer , but what if I still need references to specific types? And needed for these types, need 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 you to enter a number of rules and restrictions.

The general idea is to be able to define methods in the successor of the Interactor type, which take two parameters with types derived from ComponentContainer . Such a method will not be valid unless it is marked 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<StatsComponent>(); if (playerStats == null) return; if (playerStats.Level < 10) return; var questList = questGiverNpc.GetComponent<QuestList>(); if (questList == null) return; var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level); //   . } } 

Still striking are these “attempts” to get the component out of the container that I would like to remove somewhere.

With the help of the same tool, we introduce an additional contract in the form of the [RequiresComponent (parameterName, ComponentType)] attribute:

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

Now everything looks clean and neat. What has changed since the original version:


In addition to the interaction of two container objects, there was a problem with how to ensure interaction between several components inside a single container. To solve this problem, I used a similar approach to the previous one: when a user adds a component to a 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 with a specific component type (using reflection and polymorphism), marked with the attribute [AttachHandler] , which can work with a specific type of container.

If such component requires some other components of the container 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>(); spriteBatch.Draw(textureHolder.Texture2D, GetComponent<Position>(), Color.White); } } 

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

 //   . player.Interact(monster).Using<AttackInteractor>(); //    . player.Interact(healthPotion).Using<ItemUsingInteractor>(); //      . player.Interact(monster).Using<LootInteractor>(); 

Results


So far, the system is not fully ready: there are several windows for expansion, optimization (after all, reflection is actively used, it’s necessary not to be stingy with caching), it is necessary to think over the interaction of VERY complex entities more carefully.
I would like to add to the [RequiresComponent] attribute a variant of behavior in case the code does not correspond to 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, it will be an invaluable experience.

I called the approach itself CIM - Component Interactor Model , and I plan to carefully test it for operability in the nearest "home" projects. If the topic is of interest to someone, in the next part I can consider the source-code of such classes as Component , ComponentContainer , the implementation associated with Using <T> and Interactor .

The second part of.

Thanks for attention!

Source: https://habr.com/ru/post/270825/


All Articles