Introduction
Even the average Unity3D project is very quickly filled with a large number of different scripts and the question arises of how these scripts interact with each other.
This article offers several different approaches to the organization of such interactions from simple to advanced and describes what problems each of the approaches can lead to, as well as suggest ways to solve these problems.
Approach 1. Purpose through the Unity3D editor
Suppose we have two scripts in the project. The first creak is responsible for scoring points in the game, and the second for the user interface, which displays the number of points scored on the game screen.
Let's call both scripts managers: ScoresManager and HUDManager.
How does the manager in charge of the menu of the screen get the current number of points from the manager in charge of scoring?
It is assumed that in the object hierarchy (Hierarchy) of the scene there are two objects, one of which is assigned the ScoresManager script and the other is the HUDManager script.
One approach contains the following principle:
In the UIManager script, we define a variable of the ScoresManager type:
public class HUDManager : MonoBehaviour { public ScoresManager ScoresManager; }
But the ScoresManager variable must also be initialized with an instance of the class. To do this, select the object in the object hierarchy to which the HUDManager script is assigned and in the object settings we will see the ScoresManager variable with the value None.
')
Next, drag and drop an object from the hierarchy window containing the ScoresManager script into the area where None is written and assign it to the declared variable:
After that, we have the opportunity from the HUDManager code to access the ScoresManager script, thus:
public class HUDManager : MonoBehaviour { public ScoresManager ScoresManager; public void Update () { ShowScores(ScoresManager.Scores); } }
It's simple, but the game is not limited to the points scored, the HUD can display the player’s current lives, the menu of available player actions, level information, and more. The game can consist of tens and hundreds of different scripts that need to receive information from each other.
To get data from another script in one script, we will have to describe a variable in one script and assign it (drag it manually) using an editor, which is a tedious job in itself, which you can easily forget and then find which of the variables is not initialized for a long time .
If we want to refactor something, rename the script, then all the old initializations in the hierarchy of objects associated with the renamed script will be reset and we will have to assign them again.
At the same time, such a mechanism does not work for prefabs (prefab) - the dynamic creation of objects from a template. If a prefab needs to contact a manager located in the object hierarchy, then you cannot assign an element from the hierarchy to the prefab itself, but you will have to first create an object from the prefab and then programmatically assign an instance of the variable manager of the newly created object. Not necessary work, not necessary code, additional connectedness.
The following approach solves all these problems.
Approach 2. "Singltons"
Let's apply the simplified classification of possible scripts that are used when creating the game. The first type of scripts: “scripts-managers”, the second: “scripts-game-objects”.
The main difference between some of them is that the “script managers” always have a single instance in the game, while the “scripts-game-objects” can have more than one instance.
Examples
As a rule, in a single copy there are scripts that are responsible for the general logic of the user interface, for playing music, for tracking the conditions for completing a level, for managing the task system, for displaying special effects, and so on.
At the same time, game object scripts exist in a large number of instances: each bird from the “Angry Birds” is controlled by a bird script instance with its own unique state; for any unit in the strategy, a copy of the unit script is created, containing its current number of lives, position on the field and personal goal; The behavior of five different icons is provided by different instances of the same scripts responsible for this behavior.
In the example from the previous step, the HUDManager and ScoresManager scripts always exist in a single instance. For their interaction with each other, apply the singleton pattern (Singleton, aka single).
In the ScoresManager class, we will describe a static property of the ScoresManager type, in which a single instance of the points manager will be stored:
public class ScoresManager : MonoBehaviour { public static ScoresManager Instance { get; private set; } public int Scores; }
It remains to initialize the Instance property with an instance of the class that creates the Unity3D environment. Since ScoresManager is a successor of MonoBehaviour, it participates in the life cycle of all active scripts in the scene and during the script initialization it calls the Awake method. In this method we place the initialization code of the Instance property:
public class ScoresManager : MonoBehaviour { public static ScoresManager Instance { get; private set; } public int Scores; public void Awake() { Instance = this; } }
After that, you can use ScoresManager from other scripts as follows:
public class HUDManager : MonoBehaviour { public void Update () { ShowScores(ScoresManager.Instance.Scores); } }
Now there is no need for HUDManager to describe the ScoresManager type field and assign it in the Unity3D editor; any “script manager” can provide access to itself through the static Instance property, which will be initialized in the Awake function.
pros
- there is no need to describe the script field and assign it through the Unity3D editor.
- you can safely refactor the code, if something falls off, the compiler will let you know.
- other “script managers” can now be accessed from prefabs, through the Instance property.
Minuses
- the approach provides access only to the “script managers” existing in a single copy.
- strong coherence.
On the last "minus" dwell in more detail.
Let us develop a game in which there are characters (unit) and these characters can die (die).
Somewhere there is a section of code that checks whether our character has died:
public class Unit : MonoBehaviour { public int LifePoints; public void TakeDamage(int damage) { LifePoints -= damage; if (LifePoints <= 0) Die(); } }
How can the game respond to the death of the character? Many different reactions! I will give several options:
- it is necessary to remove the character from the scene of the game so that it no longer appears on it.
- In the game, points are added for each dead character, you need to accrue them and update the value on the screen.
- a special panel displays all the characters in the game, where we can choose a particular character. When a character dies, we need to refresh the panel, or remove the character from it, or display that it is dead.
- you need to play the sound effect of the death of the character.
- you need to play the visual effect of the death of the character (explosion, splashes of blood).
- The system of achievements of the game has an achievement that considers the total number of killed characters for all time. It is necessary to add a newly deceased character to the counter.
- the analytics system of the game sends to the external server the fact of the death of the character, this fact is important for us to track the player's progress.
Considering all of the above, the Die function might look like this:
private void Die() { DeleteFromScene(); ScoresManager.Instance.OnUnitDied(this); LevelConditionManager.Instance.OnUnitDied(this); UnitsPanel.Instance.RemoveUnit(this); SoundsManager.Instance.PlayUnitDieSound(); EffectsManager.Instance.PlaySmallExplosion(); AchivementsManager.Instance.OnUnitDied(this); AnaliticsManager.Instance.SendUnitDiedEvent(this); }
It turns out that the character after his death must send out all the components that are interested in this sad fact, he should know about the existence of these components and should know that they are interested in them. Is it too much knowledge for a small unit?
Since the game, according to logic, is a very connected structure, then events that occur in other components are of interest to others, the unit is nothing special here.
Examples of such events (not all):
- The condition of passing the level depends on the number of points scored, scored 1000 points - completed the level (LevelConditionManager is associated with ScoresManager).
- When we score 500 points, we reach an important stage of passing the level, you need to play a funny melody and visual effect (ScoresManager is connected with EffectsManager and SoundsManager).
- When a character recovers health, you need to lose the cure effect on the character in the character panel (UnitsPanel is associated with EffectsManager).
- and so on.
As a result of such connections, we come to a picture similar to the following, where everyone knows everything about everyone:
The example with the death of a character is a bit exaggerated, it is not often necessary to report the death (or other event) to six different components. But the options, when at some event in the game, the function in which the event occurred, reports this to 2-3 other components found very often throughout the code.
The following approach tries to solve this problem.
Approach 3. The World Broadcast (Event Aggregator)
We introduce a special component “EventAggregator”, the main function of which is to store a list of events occurring in the game.
An event in the game is a functional that provides any other component with the opportunity to subscribe to itself as well as publish the fact of the event. The implementation of the event functional can be any taste of the developer, you can use standard language solutions or write your own implementation.
An example of a simple implementation of an event from the last example (about the death of a unit):
public class UnitDiedEvent { private readonly List<Action<Unit>> _callbacks = new List<Action<Unit>>(); public void Subscribe(Action<Unit> callback) { _callbacks.Add(callback); } public void Publish(Unit unit) { foreach (Action<Unit> callback in _callbacks) callback(unit); } }
Add this event to the “EventAggregator”:
public class EventAggregator { public static UnitDiedEvent UnitDied; }
Now, the Die function from the previous example with eight lines is converted to a function with one line of code. We do not need to report that the unit has died to all interested components and to know about these interested. We simply publish the fact of the event:
private void Die() { EventAggregator.UnitDied.Publish(this); }
And any component that is interested in this event can react to it as follows (using the example of a manager who is responsible for the number of points scored):
public class ScoresManager : MonoBehaviour { public int Scores; public void Awake() { EventAggregator.UnitDied.Subscribe(OnUnitDied); } private void OnUnitDied(Unit unit) { Scores += CalculateScores(unit); } }
In the Awake function, the manager subscribes to the event and passes the delegate responsible for handling this event. The event handler itself takes an instance of a dead unit as a parameter and adds the number of points depending on the type of this unit.
In the same way, all other components who are interested in the event of the death of a unit can subscribe to it and process when the event occurs.
As a result, the diagram of connections between components, when each component knew about each other, turns into a diagram, when components know only about events that occur in the game (only about events of interest to them), but they don’t care where these events came from. The new chart will look like this:
I love another interpretation: Imagine that the “EventAggregator” rectangle stretched in all directions and captured all the other rectangles, turning into the boundaries of the world. In my head, in this diagram, "EventAggregator" is completely absent. “EventAggregator” is just a game world, a kind of “game broadcast” where the different parts of the game shout “Hey, people! A unit is dead! ”, And everyone listens on the air, and if they are interested in some of the events heard, they will react to it. Thus - there are no connections, each component is independent.
If I am a component and are responsible for the publication of some event, then I scream on the air saying this one died, this one got a level, the shell hit the tank. And I don’t care about anyone. Perhaps no one is listening to this event now, or maybe hundreds of other objects are signed to it. As an event author, I don’t care about a single gram, I don’t know anything about them and I don’t want to know.
This approach allows you to easily introduce new functionality without changing the old one. Suppose we decided to add an achievement system to the finished game. We create a new component of the achievement system and subscribe to all the events of interest to us. No other code changes. No need to walk on other components and from them cause the system of achievements and say to her they say, and count my event please. In addition, all who publish events in the world do not know anything about the system of achievements, even the fact of its existence.
Comment
Saying that no other code is changing, of course, I'm a little cunning. It may turn out that the achievement system is interested in events that had previously simply not been published in the game, because no other system had previously been interested in it. And in this case, we will need to decide what new events to add to the game and who will publish them. But in an ideal game, all possible events are already there and the air is full of them.
pros
- not connectedness of components, it is enough for me to simply publish an event, and who it is of interest does not matter.
- not connectedness of components, I just subscribe to the events I need.
- you can add individual modules without changing the existing functionality.
Minuses
- you need to constantly describe new events and add them to the world.
- violation of functional atomicity.
We consider the last minus in more detail.
Imagine that we have an ObjectA object in which the MethodA method is called. The “MethodA” method consists of three steps and calls within itself three other methods that perform these steps sequentially (“MethodA1”, “MethodA2” and “MethodA3”). In the second method “MethodA2” some event is published. And here the following happens: all those who are subscribed to this event will begin to process it, following some of their own logic. In this logic, the publication of other events may also occur, the processing of which may also lead to the publication of new events and so on. Tree publications and reactions in some cases can grow very much. Such long chains are extremely hard to debug.
But the most terrible problem that can occur here is when one of the branches of the chain leads back to ObjectA and starts processing the event by calling some other MethodB method. It turns out that the “MethodA” method has not yet completed all the steps, as it was interrupted at the second step, and now contains an invalid state (in step 1 and 2 we changed the state of the object, but the last change from step 3 is not yet done) and at the same time “MethodB” starts to be executed in the same object, having this not a valid state. Such situations give rise to errors, are very difficult to catch, lead to the need to control the order of calling methods and publishing events when, logically, there is no need to do this and introduce additional complexity that we would like to avoid.
Decision
Solving the described problem is not difficult; it is enough to add the delayed reaction functional to the event. As a simple implementation of such a functional, we can get a repository into which we will add the events that have occurred. When an event has occurred, we do not execute it immediately, but simply save it somewhere. And at the moment of the occurrence of the execution queue of the functionality of some component in the game (in the Update method, for example), we check for the occurrence of events and perform processing, if there are such events.
Thus, when the “MethodA” method is executed, it is not interrupted, and the interested event is recorded by all interested parties in a special repository. And only after the queue reaches the interested subscribers, they will get an event from the storage and process it. At this point, the entire “MethodA” will be completed and “ObjectA” will have a valid state.
Conclusion
A computer game is a complex structure with a large number of components that interact closely with each other. You can think of many mechanisms for organizing this interaction, but I prefer the mechanism that I described, based on events, and which I came to by an evolutionary path of walking through all kinds of rakes. I hope someone will like it too and my article will clarify and be useful.