Tracking objectives
The classes we created so far in this chapter will help us to define the objectives on each level, and now we are going to implement a new class that is going to take care of showing the updated progress to the player, as well as keeping track of the progress.
To track the progress of how many resources the player collected, we will listen to an existing message, the UpdateResourceMessage
class, which is triggered when a new number of resources is produced and is already used by our UI to display the values in the top-right corner of the screen.
We are ready to go with the resource collection progress; however, we do not have a similar message that we can use to track how many enemies were killed by the player. So, before moving on to the script that will keep track of the objectives, we first need to add a new message that should be triggered every time an enemy is defeated.
Let’s create a new script in the Scripts | MessageQueue | Messages | Enemy folder, name it EnemyKilledMessage
, and replace the content with the following code block:
namespace Dragoncraft { public class EnemyKilledMessage : IMessage { public EnemyType Type; } }
The EnemyKilledMessage
class has only one property, Type
, which will have the information related to the EnemyType
that the player killed. Also, this class inherits from the IMessage
interface so we can send and receive messages of the EnemyKilledMessage
type using the MessageQueueManager
class.
Now that we have our new message class, we need to send the EnemyKilledMessage
in one of our existing classes that has information about the enemy that the player defeated. The best place to send the message is in the DeadComponent
class, which is a script added to both unit and enemy GameObjects when they die in combat. Let’s open the DeadComponent
script, located inside the Scripts | Battle folder, and add the following highlighted method inside the class:
public class DeadComponent : MonoBehaviour { … public void Start() { UpdateObjective(); } … }
This Start
method will execute the UpdateObjective
method as soon as the DeadComponent
class is added to the GameObject as a component. However, this class is added to both units and enemies when they are killed.
In the following UpdateObjective
method, we are going to add a validation to ensure the message is sent only when the GameObject is an enemy and not a unit. Add the following method to the DeadComponent
class:
private void UpdateObjective() { if (TryGetComponent<EnemyComponent>(out var enemy)) { MessageQueueManager.Instance.SendMessage( new EnemyKilledMessage { Type = enemy.Type }); } }
The UpdateObjective
method is using the TryGetComponent
method from the MonoBehaviour
class to get the EnemyComponent
class from the GameObject. If the EnemyComponent
class is present in this GameObject, the TryGetComponent
method returns true
and we enter the if
statement with a valid enemy
object, and then we use the MessageQueueManager
singleton to send the EnemyKilledMessage
object with the Type
from the enemy
variable.
Now that we have the two distinct messages for tracking the resource collection and enemies defeated, we can move on to the new ObjectioveComponent
script that will use the EnemyKilledMessage
and UpdateResourceMessage
messages to track and update the player progress on each objective and create a new GameObject in the UI to display the current progress on each objective. We will also see how to pause the game when the player opens the pause pop-up window by manipulating the time scale from the Unity Engine.
Creating the Objective component
The next script is responsible for listening to the messages that will update the number of resources collected and the enemies that were killed in combat, and then use this information to update a new UI element to display the progress on each objective. We will first create the script, and then update the GameUI scene to have the new UI element that will display the objective progress.
Let’s add a new script to the Scripts | Objective folder, name it ObjectiveComponent
, and replace the content with the following code snippet:
using System; using System.Collections.Generic; using System.Text; using TMPro; using UnityEngine; namespace Dragoncraft { public class ObjectiveComponent : MonoBehaviour { [SerializeField] private TMP_Text _objectiveText; private Dictionary<ResourceType, int> _resourceCounter = new Dictionary<ResourceType, int>(); private Dictionary<EnemyType, int> _enemyCounter = new Dictionary<EnemyType, int>(); private ObjectiveData _objectiveData; private float _timeCounter; private bool _playerWin; } }
The ObjectiveComponent
class is a bit long, so we will see each part of it in the next code blocks. Now, let’s look at each property that we defined in this class:
-
objectiveText
is a reference to a TextMeshPro text GameObject (TMP_Text
) that we are going to use to display a formatted message with the objectives and the progress of each, as well as the time left and a short description – all the information will come from theObjectiveData
object and the messages we will use to track the progress. resourceCounter
is aDictionary
that has theResourceType
as the key and the amount of resources as the value. We will use it to track how much of eachResourceType
was collected or generated by the player.enemyCounter
is also aDictionary
, but this time we are using theEnemyType
as a key to count how many enemies the player killed of each type.objectiveData
is a reference to the ScriptableObject that has the settings for the objectives in the current level, and we are going to get this reference using theLevelManager
class.timeCounter
is a simple float variable that we will use to count the elapsed time and check whether the player still has time left or it is game over.- The last property,
playerWin
, is going to be used to validate whether the player completes all the objectives and, therefore, wins the game.
The properties we declared in the ObjectiveComponent
class will be used in the subsequent methods that we are going to add to this class, starting with the following OnEnable
method:
private void OnEnable() { MessageQueueManager.Instance. AddListener<EnemyKilledMessage>(OnEnemyKilled); MessageQueueManager.Instance. AddListener<UpdateResourceMessage>(OnResourceUpdated); }
The OnEnable
method is executed every time the GameObject that has this script attached becomes active. Here we are adding a couple of listeners to the two messages we need to receive to update the progress: EnemyKilledMessage
and UpdateResourceMessage
. These two messages will execute the OnEnemyKilled
and OnResourceUpdated
methods, respectively.
Since we are adding listeners, we also need to remove them when they are not needed. We do that in the following OnDisable
method:
private void OnDisable() { MessageQueueManager.Instance. RemoveListener<EnemyKilledMessage>( OnEnemyKilled); MessageQueueManager.Instance. RemoveListener<UpdateResourceMessage>( OnResourceUpdated); }
When the GameObject is deactivated, the OnDisable
method is executed, and here, we are removing the listeners from EnemyKilledMessage
and UpdateResourceMessage
, as well as their respective callback methods, OnEnemyKilled
and OnResourceUpdated
. Using the OnEnable
and OnDisable
methods to add and remove the message listeners, we ensure that we are waiting for the messages when the object is active.
The next method, Start
, is also called when the GameObject is active, but only once, which makes it ideal for initialization code. Add the following Start
method to the class:
private void Start() { _objectiveData = LevelManager.Instance.GetObjectiveData(); _timeCounter = _objectiveData.TimeInSeconds; }
In the Start
method, we are doing a couple of things: it gets the objectiveData
from the LevelManager
and initializes the timeCounter
property with the TimeInSeconds
value. The timeCounter
property will be decreased on each game loop, starting from TimeInSeconds
; the player loses the game if the objectives are not completed once the timeCounter
reaches zero.
The code that will reduce the timeCounter
value on each game loop, as well as updating the objective progress in the UI and validating whether the player has reached the end of the game, is in the Update
method, as we can see in the following code block:
private void Update() { if (_timeCounter > 0) { _timeCounter -= Time.deltaTime; UpdateObjectives(); CheckGameOver(); } }
The first thing we check in the Update
method is whether the timeCounter
is greater than 0
, and only execute the next lines if this statement is true. Then, as mentioned prior to the code block, the timeCounter
is decreased by subtracting the deltaTime
on each game loop.
The last couple of lines in this method are executing the UpdateObjectives
and CheckGameOver
methods, where we update the UI information with the current progress and check whether the game is over, respectively. We are going to see these two methods in a moment, but first, let’s define other methods, such as the OnResourceUpdated
method, which we can see in the following code snippet:
private void OnResourceUpdated(UpdateResourceMessage message) { if (message.Amount < 0) { return; } if (_resourceCounter.TryGetValue(message.Type, out int counter)) { _resourceCounter[message.Type] = counter + message.Amount; } else { _resourceCounter.Add(message.Type, message.Amount); } }
The OnResourceUpdated
method is executed when we receive a message of the UpdateResourceMessage
type, which indicates that a new amount of one specific ResourceType
has been collected or produced by the player. If the Amount
is less than 0
, which is a negative value, the message was sent to consume resources, so we can ignore the rest of the method using the return
keyword to exit the method execution because we will only count the resources that have been collected.
Since we are using a Dictionary
to keep track of the resource objective progress, in the resourceCounter
property, we first try to find a value using the message Type
as the key. If we find an item using the TryGetValue
method, we will have the current value in the counter
variable and then add the value from the message Amount
before updating it in the resourceCounter
. If the Type
is not found in the resourceCounter
, we can safely add both Type
and Amount
as a new key and value, respectively, using the Add
method.
This is how we are going to keep track of the number of resources collected by the player of each ResourceType
. The next method defined in the following code block, the OnEnemyKilled
method, is doing a similar thing, but instead counts the enemies defeated by the player:
private void OnEnemyKilled(EnemyKilledMessage message) { if (_enemyCounter.TryGetValue(message.Type, out int counter)) { _enemyCounter[message.Type] = counter + 1; } else { _enemyCounter.Add(message.Type, 1); } }
In the OnEnemyKilled
method, which is executed when we receive a message of the EnemyKilledMessage
type, we are trying to find the current number of enemies of the Type
received in the enemyCounter
, through the TryGetValue
method. If we find one item that has the enemy type, we increase the counter
by 1
because this message is received once per enemy that is defeated. Then, if the item is not found, we add a new item to the enemyCounter
with the Type
key and a value of 1
using the Add
method.
After the methods to update the resourceCounter
and enemyCounter
, we have the UpdateObjective
method, which is executed in the Update
method, and is defined in the following code snippet:
private void UpdateObjectives() { StringBuilder objectives = new StringBuilder(); objectives.Append( $"{_objectiveData.Description}{Environment.NewLine}"); objectives.Append(GetTimeObjectiveText(_timeCounter)); foreach (EnemyObjective enemy in _objectiveData.Enemies) { objectives.Append(GetEnemyObjectiveText(enemy)); } foreach (ResourceObjective resource in _objectiveData.Resources) { objectives.Append(GetResourceObjectiveText(resource)); } _objectiveText.text = objectives.ToString(); }
The UpdateObjectives
method is responsible for creating a string with text that contains the Description
for each objective, the updated timeCounter
, the progress of each EnemyObjective
, and the progress of each ResourceObjective
. This is how the method creates the string:
- We are using a
StringBuilder
to create our string, which is perfect for manipulating a string because, being a mutable string, we can modify it without creating a new instance of the string on each change, which is fine; but here, we can use the extra performance thatStringBuilder
provides. - The first string we append to the
objectives
variable isDescription
, and we are adding theEnvironment.NewLine
constant after the string so we can have a line breaker at the end of the string. - Next, we have the current time elapsed string, which is created using
GetTimeObjectiveText
. - Then, we have a couple of
foreach
loops:- The first
foreach
loop goes through all theEnemyObjective
items inobjectiveData
, represented by theEnemies
property. Here, we are getting each individualEnemyObjective
from the list and using theGetEnemyObjectiveText
method to create the string with the updated progress. - The second
foreach
loop is similar, but now we are accessing eachResourceObjective
in theObjectives
property, and then creating the string using theGetResourceObjectiveText
method.
- The first
- At the end of the method, we convert the
StringBuilder
to a regular string by using theToString
method and then setting this value to theobjectiveText
in the UI.
The StringBuilder
is a great class to use when we are manipulating an unknown number of strings constantly, which is exactly what we are doing in the UpdateObjectives
method because it is executed on every game loop by the Update
method.
Now, let’s see each individual method that is responsible for creating the strings, starting with GetTimeObjectiveText
in the following code block:
private string GetTimeObjectiveText(float seconds) { if (seconds < 0) { return $"<color=red>Time left:" + $" 00:00{Environment.NewLine}</color>"; } TimeSpan time = TimeSpan.FromSeconds(seconds); return $"Time left: {time:mm\\:ss}{Environment.NewLine}"; }
The GetTimeObjectiveText
method is responsible for creating a string with the current time left for the player in the level to complete the objectives. The seconds
parameter has the information of how much time the player has left, but we are going to display this information to the player in minutes and seconds format, for example, 10:25.
However, before converting the time, we check whether the seconds
variable is less than 0
, which indicates that there is no time left for the player, and in this case, we return a string with the time 00:00
. Here, we are using a nice feature of TextMeshPro, which is the <color=red>
and </color>
tags that change the color of the text in between these tags to red in the UI, which will give feedback to the player that they have missed this objective.
If the player still has time left, we convert the seconds
variable to a TimeSpan
variable using the FromSeconds
method, and then we use the mm\\:ss
string format to convert the time
variable into a string with the format we want to display, which is minutes and seconds.
The next method, GetEnemyObjectiveText
, is also creating a script with a format we want to display, but this time it shows how many enemies the player has defeated. Add the following method to the class:
private string GetEnemyObjectiveText(EnemyObjective enemy) { _enemyCounter.TryGetValue(enemy.Type, out int counter); if (counter >= enemy.Quantity) { return $"<color=green>Kill {enemy.Type}: " + $"{enemy.Quantity}/{enemy.Quantity}" + $"{Environment.NewLine}</color>"; } return $"Kill {enemy.Type}: " + $"{counter}/{enemy.Quantity}{Environment.NewLine}"; }
We are creating a string in the GetEnemyObjectiveText
method that has the information on the Type
of the enemy, the number of enemies of that type already killed by the player, and the expected Quantity
that is in the EnemyObjective
. Using the TryGetValue
method, we look at the enemyCounter
dictionary for the current quantity based on the Type
key, and the updated value is returned in the counter
variable.
Since the counter
variable is an int
, even if no value is found, the default value will be zero, which is fine for what we want. As soon as we have the counter
variable, we check whether it is equal to or greater than the Quantity
that we expect in this objective, and if that is true, we return a string with the <color=green>
and </color>
tags to give visual feedback that the objective was completed with some green text, and we stop modifying this string.
If this objective is still incomplete, we return a string that has the enemy Type
, the counter
, and the expected Quantity
, so the player can have updated information about how many enemies of this type need to be defeated to complete the objective.
The following GetResourceObjectiveText
method is very similar to the previous GetEnemyObjectiveText
method, and we are doing the same things but using different variables and text. Add the following code snippet to the class:
private string GetResourceObjectiveText(ResourceObjective resource) { _resourceCounter.TryGetValue(resource.Type, out int counter); if (counter >= resource.Quantity) { return $"<color=green>Collect {resource.Type}: " + $"{resource.Quantity}/{resource.Quantity}" + $"{Environment.NewLine}</color>"; } return $"Collect {resource.Type}: " + $"{counter}/{resource.Quantity}{Environment.NewLine}"; }
The GetResourceObjectiveText
method uses the resource
parameter to find the current counter
for the resource Type
in the resourceCounter
dictionary. Then, we check whether the value of the counter
variable is greater than or equal to the Quantity
we are expecting to complete this objective. If the player completes this objective, we return the object in green text to give visual feedback that it is completed and we are no longer counting this resource.
If the objective is not complete yet, we return a string with the Type
, the current counter
for this resource type, and the Quantity
the player needs to collect to complete this objective. As we can see, the enemy and resource objective information is very similar but use their own variables and text.
The last method we are going to add to the ObjectiveComponent
class is the CheckGameOver
method. However, as we can see in the following code block, this method is empty now:
private void CheckGameOver() { }
The CheckGameOver
method is empty because we still need to add a few scripts to the project before we can implement it, but we are going to do it in a moment, just after we finish the UI changes for the objective progression.
Creating the Objectives UI
Now that we have finished the ObjectiveComponent
class, we can move on to the UI. We are going to add this script and a new text to display the string we are building and updating with the objective progress:
- Open the GameUI scene and, in the Hierarchy view, right-click on the Canvas GameObject. Select the Create Empty option and name it
Objectives
. The new Objectives GameObject will be added as the last item under the Canvas GameObject – drag and drop it right below the Canvas GameObject to make it the first GameObject on the Canvas. - Left-click on the new Objectives GameObject and, in the Inspector view, in the Rect Transform component, change the Anchor Preset values to right on the right rectangle edge and top on the top rectangle edge. Then, change Pivot X to 1 and Pivot Y to 1. After that, change Pos X to 0, Pos Y to -50, Width to 200, and Height to 150.
- In the Hierarchy view, right-click on the Objectives GameObject, select the UI | Image option, and name it
Background
. - Left-click on the newly created Background GameObject and, in the Inspector view, in the Rect Transform component, change the Anchor Preset values to stretch horizontally and stretch vertically. Then, change Pos X to 0, Pos Y to 0, Right to 0, and Bottom to 0.
- In the Image component, click on the Color property and, in the new Color window, change the values of R to 0, G to 0, B to 0, and A to 200. This will change the image color to black with transparency.
- In the Hierarchy view, right-click on the Objectives GameObject, select the UI | Text - TextMeshPro option, and name it
Text
. - Left-click on the newly created Text GameObject and, in the Inspector view, in the Rect Transform component, change Anchor Preset to stretch horizontally and stretch vertically. Then, change Pos X to 10, Pos Y to 10, Right to 0, and Bottom to 0.
- In the TextMeshPro – Text (UI) component, click on the Vertex Color property and, in the new Color window, change the value of R to 255, G to 255, B to 0, and A to 255. This will change the text color to yellow. In the same component, change the Font Size property to 14 and click on the SC button in the Font Style property.
- In the Hierarchy view, left-click on the Objectives GameObject to select it. In the Inspector view, click on the Add Component button, search for
ObjectiveComponent
, and double-click to add it. Drag the Text GameObject we just created from the Hierarchy view and drop it into the Objective Text property in the Inspector view, in the Objective Component. - Save the scene changes using the File | Save menu option.
The Objectives GameObject in the UI will display a black panel, with a bit of transparency, on the right side of the screen, with the list of objectives and their current progress in yellow. The following screenshot shows the Objectives GameObject after setting it up:

Figure 15.2 – The Objectives GameObject with ObjectiveComponent
The ObjectiveComponent
script is going to use the Text GameObject to display the string we build in the code, with the description, time lapsed, resource collection progress, and enemy defeated progress. The whole text will be displayed in yellow but, as we saw in the ObjectiveComponent
class, the time text changes to red when there is no more time left, and the resources and enemy-related objectives text changes to green when the objectives are completed.
We now have script, asset, and Prefab changes that enable us to create a configuration with objectives for the player, add it to a level, and display the player’s progress toward the current objectives in the UI. The only thing left to do now is to check whether the player wins or loses the game, but before that, let’s add one more script to pause the game when the menu is opened.
Pausing the game
We already have a Menu button in the top-left corner of the screen that opens the pause popup, with the options to Resume or Exit the game. However, when this popup is open, the game is not actually paused, and the Exit button does nothing at the moment.
Let’s change that by adding a new script that will allow us to pause the game when the popup is open, and to resume the game when it is dismissed. Add a new script in the Scripts | UI folder, name it PauseComponent
, and replace the content with the following code block:
using UnityEngine; namespace Dragoncraft { public class PauseComponent : MonoBehaviour { private void OnEnable() { Time.timeScale = 0; } private void OnDisable() { Time.timeScale = 1; } public void ExitGame() { Application.Quit(); } } }
The PauseComponent
class is quite simple and has only three methods that have one line on each of them. The OnEnable
and OnDisable
methods are changing the value of the Time.timeScale
property between 0
to pause the game and 1
to resume it. timeScale
is used by the Unity Engine to set up things such as animation, physics, and the game loop, for example; by changing it to 0
, we are telling the engine to stop everything, and the game stays paused until we change it back to 1
.
The last method in this class, the ExitGame
method, uses the Quit
method from the Application
API to close the game. It is worth mentioning that it does not work in the Editor, but it will close the game when we are running it on a desktop PC, for example.
Now that we have our new script for pausing and resuming the game, let’s add it to the PausePopup GameObject in the UI, so the game is automatically paused and resumed when the pause popup is opened and closed, respectively:
- Open the GameUI scene and, in the Hierarchy view, expand the Canvas GameObject, then expand the Menu GameObject and left-click on the PausePopup GameObject to select it.
- In the Inspector view, click on the Add Component button, search for
PauseComponent
, and double-click to add it. - Save the scene changes using the File | Save menu option.
With a simple script, we can now pause and resume the game when the pause popup is opened or closed. We are going to reuse this script in the next part of this chapter, where we will create a new popup to display a message saying that the player has won or lost the game.