Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Save more on your purchases! discount-offer-chevron-icon
Savings automatically calculated. No voucher code required.
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Newsletter Hub
Free Learning
Arrow right icon
timer SALE ENDS IN
0 Days
:
00 Hours
:
00 Minutes
:
00 Seconds
Arrow up icon
GO TO TOP
Creating an RTS Game in Unity 2023

You're reading from   Creating an RTS Game in Unity 2023 A comprehensive guide to creating your own strategy game from scratch using C#

Arrow left icon
Product type Paperback
Published in Oct 2023
Publisher Packt
ISBN-13 9781804613245
Length 548 pages
Edition 1st Edition
Languages
Tools
Arrow right icon
Author (1):
Arrow left icon
Bruno Cicanci Bruno Cicanci
Author Profile Icon Bruno Cicanci
Bruno Cicanci
Arrow right icon
View More author details
Toc

Table of Contents (23) Chapters Close

Preface 1. Part 1: Foundations of RTS Games
2. Chapter 1: Introducing Real-Time Strategy Games FREE CHAPTER 3. Chapter 2: Setting Up Unity and the Dragoncraft Project 4. Chapter 3: Getting Started with Our Level Design 5. Chapter 4: Creating the User Interface and HUD 6. Part 2: The Combat Units
7. Chapter 5: Spawning an Army of Units 8. Chapter 6: Commanding an Army of Units 9. Chapter 7: Attacking and Defending Units 10. Chapter 8: Implementing the Pathfinder 11. Part 3: The Battlefield
12. Chapter 9: Adding Enemies 13. Chapter 10: Creating an AI to Attack the Player 14. Chapter 11: Adding Enemies to the Map 15. Part 4: The Gameplay
16. Chapter 12: Balancing the Game’s Difficulty 17. Chapter 13: Producing and Gathering Resources 18. Chapter 14: Crafting Buildings and Defense Towers 19. Chapter 15: Tracking Progression and Objectives 20. Chapter 16: Exporting and Expanding Your Game 21. Index 22. Other Books You May Enjoy

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 the ObjectiveData object and the messages we will use to track the progress.
  • resourceCounter is a Dictionary that has the ResourceType as the key and the amount of resources as the value. We will use it to track how much of each ResourceType was collected or generated by the player.
  • enemyCounter is also a Dictionary, but this time we are using the EnemyType 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 the LevelManager 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 that StringBuilder provides.
  • The first string we append to the objectives variable is Description, and we are adding the Environment.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 the EnemyObjective items in objectiveData, represented by the Enemies property. Here, we are getting each individual EnemyObjective from the list and using the GetEnemyObjectiveText method to create the string with the updated progress.
    • The second foreach loop is similar, but now we are accessing each ResourceObjective in the Objectives property, and then creating the string using the GetResourceObjectiveText method.
  • At the end of the method, we convert the StringBuilder to a regular string by using the ToString method and then setting this value to the objectiveText 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:

  1. 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.
  2. 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.
  3. In the Hierarchy view, right-click on the Objectives GameObject, select the UI | Image option, and name it Background.
  4. 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.
  5. 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.
  6. In the Hierarchy view, right-click on the Objectives GameObject, select the UI | Text - TextMeshPro option, and name it Text.
  7. 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.
  8. 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.
  9. 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.
  10. 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

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:

  1. 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.
  2. In the Inspector view, click on the Add Component button, search for PauseComponent, and double-click to add it.
  3. 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.

lock icon The rest of the chapter is locked
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at $19.99/month. Cancel anytime
Visually different images