Methods for organizing interaction between scripts in Unity3D

Introduction


Even an average Unity3D project is very quickly filled with a wide variety of scripts and the question arises of how these scripts interact with each other.
This article offers several different approaches to organizing such interactions from simple to advanced and describes what problems each of the approaches can lead to, as well as suggests ways to solve these problems.

Approach 1. Assignment 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.
We will call both scripts managers: ScoresManager and HUDManager.
How can the manager responsible for the screen menu receive the current number of points from the manager responsible for scoring?
It is assumed that in the hierarchy of objects (Hierarchy) of the scene there are two objects, one of which is assigned the ScoresManager script, and the other is the HUDManager script.
One of the approaches contains the following principle:
In the UIManager script, define a variable of the ScoresManager type:

public class HUDManager : MonoBehaviour
{
	public ScoresManager ScoresManager;
}

But the ScoresManager variable still needs to be initialized with an instance of the class. To do this, select in the object hierarchy the object to which the HUDManager script is assigned and in the object settings we will see the ScoresManager variable with the value None.

image

Next, from the hierarchy window, drag the object containing the ScoresManager script to the area where None is written and assign it to the declared variable:

image

After that, we have the opportunity from the HUDManager code to access the ScoresManager script, this way:

public class HUDManager : MonoBehaviour
{
	public ScoresManager ScoresManager;
	public void Update ()
	{
		ShowScores(ScoresManager.Scores);
	}
}

It's simple, but the game is not limited to just scored points, the HUD can display the player’s current lives, the menu of available player actions, level information and much more. The game can contain dozens and hundreds of different scripts that need to receive information from each other.
To get data from another script in one script, each time we have to describe the variable in one script and assign (drag and drop manually) it using the editor, which in itself is a tedious job that you can easily forget to do and then look for a long time which of the variables is not initialized .
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 will have to be assigned again.
At the same time, such a mechanism does not work for prefabs - the dynamic creation of objects from a template. If any prefab needs to contact the manager located in the hierarchy of objects, then you cannot assign the element from the hierarchy to the prefab itself, but you will first have to create the object from the prefab and then programmatically assign the manager instance to the variable of the newly created object. Unnecessary work, unnecessary code, additional connectedness.
The following approach solves all these problems.

Approach 2. The Singleton


We apply a simplified classification of possible scripts that are used to create the game. The first type of scripts: "scripts-managers", the second: "scripts-game-objects."
The main difference of some from others is that “script-managers” always have a single instance in the game, while “script-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 monitoring the level completion conditions, for managing the task system, for displaying special effects, and so on.
At the same time, scripts of game objects exist in a large number of instances: each bird from "Angry Birds" is controlled by an instance of the bird script with its unique state; for any unit in the strategy, an instance 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, we apply the “singleton” pattern (Singleton, aka loner).
In the ScoresManager class, we 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 the heir to MonoBehaviour, it participates in the life cycle of all active scripts in the scene and during the initialization of the script, the Awake method is called. In this method we put the initialization code for 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 the HUDManager to describe a field of the ScoresManager type 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" that exist in a single copy.
- strong connectedness.
At the last "minus" we dwell in more detail.
Let us develop a game in which there are characters (unit) and these characters can die (die).
Somewhere is a piece of code that checks to see if our character is dead:

public class Unit : MonoBehaviour
{
	public int LifePoints;
	public void TakeDamage(int damage)
	{
		LifePoints -= damage;
		if (LifePoints <= 0)
			Die();
	}
}

How can a game respond to a character’s death? A lot of different reactions! I will
give you several options: - you need to remove the character from the game scene so that it no longer appears on it.
- in the game points are awarded for each dead character, you need to accrue them and update the value on the screen.
- on a special panel displays all the characters in the game, where we can select a specific character. When the character dies, we need to update the panel, either 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, blood splatter).
- The game’s achievement system has an achievement that counts the total number of characters killed for all the time. It is necessary to add to the counter the character who has just died.
- the game’s analytics system sends to the external server the fact of the character’s death, this fact is important for us to track the player’s progress.
Given 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 should send to 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 him. Is there too much knowledge for a small unit?
Since the game, logically, is a very connected structure, then the events occurring in other components are of interest to the third, the unit here is not special.
Examples of such events (far from all):
- The condition for passing the level depends on the number of points scored, scored 1000 points - passed the level (LevelConditionManager is associated with the ScoresManager).
- When we collect 500 points, we reach an important stage of passing the level, you need to play a fun melody and visual effect (ScoresManager is associated with EffectsManager and SoundsManager).
- When the character restores health, you need to play the healing effect over the character’s picture in the character’s panel (UnitsPanel is associated with the EffectsManager).
- etc.
As a result of such connections, we come to a picture similar to the following, where everyone knows everything about everyone: The

image

example with the death of a character is a little exaggerated, six different components do not have to report death (or another event) so often. But the options are, when at some event in the game, the function in which the event occurred informs about this 2-3 other components are found all over the place throughout the code.
The following approach attempts to solve this problem.

Approach 3. 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 both subscribe to itself and publish the fact of the occurrence of this event. The implementation of the event functionality can be of any taste to the developer, you can use standard language solutions or write your own implementation.
An example of a simple implementation of an event from a past example (about the death of a unit):

public class UnitDiedEvent
{
private readonly List> _callbacks = new List>(); 
public void Subscribe(Action callback)
{
_callbacks.Add(callback);
}
public void Publish(Unit unit)
{
foreach (Action 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 eight-line example is converted to a one-line function. We do not need to report that the unit has died to all interested components and to know about these interested. We just publish the fact of the event:

private void Die()
{
EventAggregator.UnitDied.Publish(this);
}

And any component that is interested in this event can respond to it as follows (for example, the manager 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 deceased 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 death event of a unit can subscribe to it and process it when the event occurs.
As a result, the diagram of connections between the components, when each component knew about each other, turns into a diagram when the components know only about the events that occur in the game (only about the events of interest to them), but they do not care where these events came from. The new chart will look like this:

image

I love another interpretation: imagine that the “EventAggregator” rectangle stretched out in all directions and captured all the other rectangles inside itself, turning into the borders of the world. In my head, in this diagram, the EventAggregator is completely absent. “EventAggregator” is just a game world, a kind of “game broadcast”, where various parts of the game shout “Hey people! The unit has died so! ”And everyone is listening to the broadcast, and if any of the heard events interests them, they will react to it. Thus, there are no connections; each component is independent.
If I am a component and responsible for the publication of some event, then I scream on the air saying that this one died, this one got the level, the shell hit the tank. And I don't care if anyone cares about it. Perhaps no one is listening to this event now, or maybe a hundred other objects are subscribed to it. I, as the author of the event, do not care about a single gram, I do not know anything about them and do not want to know.
This approach makes it easy to 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. You don’t have to go through other components and from them call up the achievement system and tell her they say and count my event please. In addition, all who publish events in the world do not know anything about the achievement system, even the fact of its existence.

Comment


Saying that no other code does not change, I certainly mislead a little. It may turn out that the system of achievements is interested in events that previously simply were not published in the game, because no other system was previously interested. 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 ether is filled with them to the fullest.

pros


- the components are not connected, it’s enough for me to simply publish the event, and who it interests is not important.
- the components are not connected, 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.

Last minus consider 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 inside itself three other methods that execute these steps sequentially (MethodA1, MethodA2, and MethodA3). In the second method, MethodA2, an event is published. And here the following happens: everyone who is subscribed to this event will begin to process it, performing some kind of logic of their own. In this logic, the publication of other events can also occur, the processing of which can also lead to the publication of new events and so on. The tree of publications and reactions in some cases can grow very much. Such long chains are extremely difficult to debug.
But the worst problem that can happen 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 in the second step, and now contains an invalid state (in steps 1 and 2 we changed the state of the object, but the last change from step 3 has not yet done) and at the same time “MethodB” starts to be executed in the same object, having this invalid state. Such situations give rise to errors, are very difficult to catch, lead to the fact that it is necessary to control the order of calling methods and publishing events, when the logic does not need to do this and introduce additional complexity that we would like to avoid.

Decision


It is not difficult to solve the described problem, just add the functionality of the deferred reaction to the event. As a simple implementation of such functionality, we can create a repository in which we will add the events that have occurred. When an event occurs, we do not execute it immediately, but simply store it somewhere at home. And at the time of the turn of the functional execution of some component in the game (in the Update method, for example), we check for the presence of events that have occurred and execute processing if there are such events.
Thus, when the MethodA method is executed, its interruption does not occur, and all interested parties record their published event in a special repository. And only after the queue reaches the interested subscribers, they will get the 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 closely interact with each other. You can come up with many mechanisms for organizing this interaction, but I prefer the mechanism that I described, based on events, and to which I arrived by evolutionary passage through all kinds of rakes. I hope someone will like it too and my article will clarify and be useful.

Also popular now: