Implementation of missions in the game on Unity

Technical task

Let’s suppose we are making a game where the player needs to complete various tasks and, when they are completed, receive a reward in the form of coins.

Mission window

Mission window

In this example, the mission mechanics would work as follows:

  • Missions come in three difficulty levels: EASY, MEDIUM, HARD

  • One mission is randomly generated for each difficulty level

  • When the mission is completed, the player can collect the reward in the form of coins

  • After receiving the reward, a new mission of the same difficulty level is generated

First of all, we will start with the development of the missions themselves, and then we will implement the system that will manage them.

Mission implementation

So, according to the terms of reference, the mission structure consists of the following elements:

To make it possible to store and change mission settings in a Unity project, let’s create a base MissionConfig class that will store general settings for each mission

public class MissionConfig : ScriptableObject
{
    [SerializeField] public string id; //Идентификатор миссии
    [SerializeField] public MissionDifficulty difficulty; //Сложность
    [SerializeField] public int moneyReward; //Награда в виде монет
    [SerializeField] public string title; //Название миссии
    [SerializeField] public Sprite icon; //Иконка миссии
}

We will also make an enum, which will list the difficulty levels:

public enum MissionDifficulty
{
    EASY = 0,
    NORMAL = 1,
    HARD = 2,
}

The question is brewing, where will we store information about “how many resources to collect” or “how many enemies to destroy”? For each type of task, we will make a successor class that will store additional information about a specific task:

//Миссия "Собрать ресурсы"
[CreateAssetMenu(
    fileName = "CollectResourcesMission",
    menuName = "Missions/New CollectResourcesMission"
)]
public sealed class CollectResourcesMissionConfig : MissionConfig
{
    [SerializeField] public ResourceType resourceType; //Тип ресурса (Enum)
    [SerializeField] public int requiredResources; //Сколько ресурсов нужно собрать
}


//Миссия "Уничтожить врагов"
[CreateAssetMenu(
    fileName = "KillEnemyMission",
    menuName = "Missions/New KillEnemyMission"
)]
public sealed class KillEnemyMissionConfig : MissionConfig
{
    [SerializeField] public int requiredKills; //Сколько врагов нужно уничтожить
}

Great, let’s now create these configs in a Unity project:

Mission settings in Unity

Mission settings in Unity

Super! The configuration is ready, now it’s time to develop the logic of the tasks themselves.

Obviously, the logic of the missions will differ depending on the specific task. Therefore, it would be wiser to make the base Mission class, which will store the general logic, and the inheritor classes will implement the fulfillment of the condition for each mission:

Mission class family

Mission class family

The abstract mission class can be written like this:

public abstract class Mission
{
    public event Action<Mission> OnStarted; //Событие начала миссии
    public event Action<Mission> OnCompleted; //Событие завершения миссии
    public event Action<Mission> OnStateChanged; //Событие изменения состояния миссии

    public string Id => this.config.id;
    public bool IsCompleted { get; private set; }
    public MissionDifficulty Difficulty => this.config.difficulty;
    public int MoneyReward => this.config.moneyReward;

    public string Title => this.config.title;
    public Sprite Icon => this.config.icon;

    private readonly MissionConfig config;

    public Mission(MissionConfig config)
    {
        this.config = config;
    }

    //Метод запуска миссии
    public void Start()
    {
        this.OnStarted?.Invoke(this);

        if (this.GetProgress() >= 1.0f)
        {
            this.Complete();
            return;
        }

        this.OnStart();
    }
    
    public abstract float GetProgress(); //Прогресс миссии от 0..1
    protected abstract void OnStart(); //Здесь происходит активация миссии
    protected abstract void OnComplete(); //Здесь происходит деактивация миссии
    
    //Этот метод нужно вызывать из наследника, когда прогресс миссии изменился
    protected void NotifyAboutStateChanged()
    {
        if (this.GetProgress() >= 1.0f)
        {
            this.Complete();
        }
        else
        {
            this.OnStateChanged?.Invoke(this);
        }
    }

    private void Complete()
    {
        this.OnComplete();
        this.IsCompleted = true;
        this.OnCompleted?.Invoke(this);
    }
}

Then the mission “Get resources” will look like this:

public sealed class CollectResourcesMission : Mission
{
    private readonly CollectResourcesMissionConfig config;
    private readonly Player player;

    private int collectedResources;

    public CollectResourcesMission(CollectResourcesMissionConfig config) 
      : base(config)
    {
        this.config = config;
        this.player = Player.Instance; //Лучше Depency Injection в конструктор
    }

    protected override void OnStart()
    {
        //Подписываемся на событие сбора ресурсов
        this.player.OnResourcesCollected += this.OnResourcesAdded;
    }

    protected override void OnComplete()
    {
        //Отписываемся от события сбора ресурсов
        this.player.OnResourcesCollected -= this.OnResourcesAdded;
    }

    protected override float GetProgress()
    {
        return (float) this.collectedResources / this.config.requiredResources;
    }

    //Обновляем состояние миссии, когда произошел сбор ресурсов
    private void OnResourcesAdded(ResourceType resourceType, int amount)
    {
        if (resourceType == this.config.resourceType)
        {
            this.collectedResources = Math.Min(
                this.collectedResources + amount,
                this.config.requiredResources
            );
            this.NotifyAboutStateChanged();
        }
    }
}

And the mission of destroying the enemy is like this:

public sealed class KillEnemyMission : Mission 
{
    private readonly KillEnemyMissionConfig config;
    private readonly Player player;

    private int currentKills;

    public KillEnemyMission(KillEnemyMissionConfig config) : base(config)
    {
        this.config = config;
        this.player = Player.Instance; //Лучше Depency Injection в конструктор
    }

    protected override void OnStart()
    {
        //Подписываемся на событие уничтожения противников
        this.player.OnEnemyKilled += this.OnEnemyKilled;
    }

    protected override void OnComplete()
    {
        //Отписываемся от события уничтожения противников
        this.player.OnEnemyKilled -= this.OnEnemyKilled;
    }

    protected override float GetProgress()
    {
        return (float) this.currentKills / this.config.requiredKills;
    }

    //Обновляем состояние миссии, когда произошло уничтожение противника
    private void OnEnemyKilled()
    {
        this.currentKills = Math.Min(
            this.currentKills + 1,
            this.config.requiredKills
        );
        this.NotifyAboutStateChanged();
    }
}

For simplicity of the example, I made a helper class Player. This class is a test stub that can be triggered on resource collection and enemy destruction events:

//Пример класса синглтона, где могут происходить события и храниться деньги:
public sealed class Player : MonoBehaviour
{
    public static Player Instance
    {
        get
        {
            if (_instance == null) _instance = FindObjectOfType<Player>();
            return _instance;
        }
    }

    private static Player _instance;

    public event Action<ResourceType, int> OnResourcesCollected;
    public event Action OnEnemyKilled;

    //Кол-во денег у игрока:
    public int Money { get; set; }

    //Заглушка для события сбора ресурсов
    public void CollectResource(ResourceType type, int amount)
    {
        this.OnResourcesCollected?.Invoke(type, amount);
    }
    
    //Заглушка для события уничтожения противника
    public void KillEnemy()
    {
        this.OnEnemyKilled?.Invoke();
    }
}

Now let’s write a script with which we can check the system performance:

#if UNITY_EDITOR

public sealed class MissionTest : MonoBehaviour
{
    [SerializeField] private CollectResourcesMissionConfig collectMissionConfig;
    [SerializeField] private KillEnemyMissionConfig killMissionConfig;

    private Mission collectMission;
    private Mission killMission;

    private void Start()
    {
        this.collectMission = new CollectResourcesMission(this.collectMissionConfig);
        this.killMission = new KillEnemyMission(this.killMissionConfig);
        
        this.collectMission.OnStateChanged += this.OnMissionStateChanged;
        this.killMission.OnCompleted += this.OnMissionCompleted;
        this.collectMission.OnCompleted += this.OnMissionCompleted;
        this.killMission.OnStateChanged += this.OnMissionStateChanged;
        
        this.killMission.Start();
        this.collectMission.Start();
    }

    private void OnMissionCompleted(Mission mission)
    {
        Debug.Log(mission.Title + ": completed!");
    }

    private void OnMissionStateChanged(Mission mission)
    {
        Debug.Log(mission.Title + ": " + mission.GetProgress());
    }
    
    [ContextMenu(nameof(CollectWood))]
    private void CollectWood()
    {
        Player.Instance.CollectResource(ResourceType.WOOD, 1);
    }

    [ContextMenu(nameof(KillEnemy))]
    private void KillEnemy()
    {
        Player.Instance.KillEnemy();
    }
}
#endif

Connect the Player & MissionTest scripts to the stage and check the execution of the tasks:

Mission health check

Mission health check

Great, everything works! You can proceed to the development of the system 🙂

Mission manager implementation

So let’s now talk about how you can make a mission management system. But before that, let’s remember what needs to be done according to the terms of reference:

  • One mission is randomly generated for each difficulty level

  • When the mission is completed, the player can collect the reward in the form of coins

  • After receiving the reward, a new mission of the same difficulty level is generated

As a result, the mission manager will have the following business logic:

Mission Manager Responsibilities

Mission Manager Responsibilities

Remark: In the future, the mission manager can be divided into several classes, since now he solves several tasks and violates the principle sole responsibility.

So, let’s write the code for the mission manager:

public sealed class MissionsManager : MonoBehaviour
{
    public event Action<Mission> OnRewardReceived; //Событие выдачи награды
    public event Action<Mission> OnMissionChanged; //Событие изменения миссии

    [SerializeField] 
    private MissionCatalog catalog; //Каталог всех миссий для генерации

    //Коллекция текущих миссий:
    private readonly Dictionary<MissionDifficulty, Mission> missions = new();
    
    private Player player;

    private void Awake()
    {
        this.player = Player.Instance;
        this.GenerateMissions();
    }

    private void Start()
    {
        foreach (var mission in this.missions.Values)
        {
            mission.Start();
        }
    }

    public Mission[] GetMissions()
    {
        return this.missions.Values.ToArray();
    }

    //Метод выдачи награды за выполненную миссию по ключу:
    public void ReceiveReward(MissionDifficulty difficulty)
    {
        var mission = this.missions[difficulty];
        this.ReceiveReward(mission);
    }

    //Метод выдачи награды за выполненную миссию:
    public void ReceiveReward(Mission mission)
    {
        if (!mission.IsCompleted)
        {
            throw new Exception($"Can not receive reward for not completed mission: {mission.Id}!");
        }

        this.player.Money += mission.MoneyReward; 
        this.OnRewardReceived?.Invoke(mission);

        var difficulty = mission.Difficulty;
        var nextMission = this.GenerateMission(difficulty);
        this.OnMissionChanged?.Invoke(nextMission);
    }
    
    private void GenerateMissions()
    {
        for (var d = MissionDifficulty.EASY; d <= MissionDifficulty.HARD; d++)
        {
            this.GenerateMission(d);
        }
    }

    private Mission GenerateMission(MissionDifficulty difficulty)
    {
        var missionConfig = this.catalog.RandomMission(difficulty);
        var mission = missionConfig.CreateMission(); 
        this.missions[difficulty] = mission;
        return mission;
    }
}

In order to write a manager, I had to add another MissionCatalog class, which will store a list of all mission configs. When the manager needs to generate the next mission, he will take a random mission from the catalog:

[CreateAssetMenu(
    fileName = "MissionCatalog",
    menuName = "Missions/New MissionCatalog"
)]
public sealed class MissionCatalog : ScriptableObject
{
    [SerializeField]
    public MissionConfig[] missions;

    //Возращает рандомную миссию определенного уровня сложности:
    public MissionConfig RandomMission(MissionDifficulty difficulty)
    {
        var missions = new List<MissionConfig>();
        for (int i = 0, count = this.missions.Length; i < count; i++)
        {
            var mission = this.missions[i];
            if (mission.difficulty == difficulty)
            {
                missions.Add(mission);
            }
        }

        var randomIndex = UnityEngine.Random.Range(0, missions.Count);
        return missions[randomIndex];
    }
}

I also had to tweak the MissionConfig class a bit so that it would create an instance of missions through polymorphism:

public abstract class MissionConfig : ScriptableObject
{
    [SerializeField] public string id;
    [SerializeField] public MissionDifficulty difficulty;
    [SerializeField] public int moneyReward;
    [SerializeField] public string title;
    [SerializeField] public Sprite icon;
    
    public abstract Mission CreateMission(); //Теперь конфиг стал еще и "фабрикой":
}


public sealed class KillEnemyMissionConfig : MissionConfig
{
    [SerializeField] public int requiredKills;
    public override Mission CreateMission() => new KillEnemyMission(this);
}

public sealed class CollectResourcesMissionConfig : MissionConfig
{
    [SerializeField] public ResourceType resourceType;
    [SerializeField] public int requiredResources;
    public override Mission CreateMission() => new CollectResourcesMission(this);
}

So, we are almost ready to test the functionality of our MissionsManager. It remains only to correct the MissionTest test script:

#if UNITY_EDITOR
using UnityEngine;

public sealed class MissionTest : MonoBehaviour
{
    [SerializeField]
    private MissionsManager manager;
    
    private void Start()
    {
        var missions = this.manager.GetMissions();
        this.manager.OnMissionChanged += this.OnMissionChanged;
        this.manager.OnRewardReceived += this.OnMissionRewardReceived;
        
        foreach (var mission in missions)
        {
            Debug.Log($"{mission.Id}: started");
            mission.OnStateChanged += this.OnMissionStateChanged;
            mission.OnCompleted += this.OnMissionCompleted;
        }
    }

    private void OnMissionRewardReceived(Mission mission)
    {
        Debug.Log($"{mission.Id}: reward received {mission.MoneyReward}");
    }

    private void OnMissionCompleted(Mission mission)
    {
        mission.OnCompleted -= this.OnMissionCompleted;
        mission.OnStateChanged -= this.OnMissionStateChanged;
        Debug.Log(mission.Id + ": completed!");
    }

    private void OnMissionStateChanged(Mission mission)
    {
        Debug.Log(mission.Id + ": " + mission.GetProgress());
    }

    private void OnMissionChanged(Mission mission)
    {
        Debug.Log($"{mission.Id}: started");
        mission.OnStateChanged += this.OnMissionStateChanged;
        mission.OnCompleted += this.OnMissionCompleted;
    }

    [ContextMenu(nameof(CollectWood))]
    private void CollectWood()
    {
        Player.Instance.CollectResource(ResourceType.WOOD, 1);
    }

    [ContextMenu(nameof(KillEnemy))]
    private void KillEnemy()
    {
        Player.Instance.KillEnemy();
    }

    [ContextMenu(nameof(ReceiveEasyReward))]
    private void ReceiveEasyReward()
    {
        this.manager.ReceiveReward(MissionDifficulty.EASY);
    }
    
    [ContextMenu(nameof(ReceiveNormalReward))]
    private void ReceiveNormalReward()
    {
        this.manager.ReceiveReward(MissionDifficulty.NORMAL);
    }
    
    [ContextMenu(nameof(ReceiveHardReward))]
    private void ReceiveHardReward()
    {
        this.manager.ReceiveReward(MissionDifficulty.HARD);
    }
}
#endif

We drag the MissionManager script onto the stage, connect the directory and start Play Mode in Unity:

Mission Manager Health Check

Mission Manager Health Check

As you can see from the logs, the mustache works 🙂

This is how you can easily and quickly write a system for missions. In addition, I am attaching link on the resulting solution, if you are interested in touching it yourself. And so I have everything. In the next part, we will talk about the interface for missions. Thank you for your attention 🙂

Finally, I would like to invite you to free lesson, where we will talk about the profession of a Unity developer and what he does. Let’s tell you what projects there are on Unity and what is better to choose as your own first game. Let’s look at the current job market in game development and discuss why it is constantly growing, and the number of released games is growing. Additionally, let’s talk about ChatGPT and whether it can replace the game developer with Unity.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *