Привет, Хабр ????

Меня зовут Игорь, и я Unity Developer. В этой статье хотел бы рассказать, как можно сделать миссии в игре на Unity. Статья будет состоять из трех частей. В первой части напишем систему для миссий, во второй — интерфейс, а в третьей — сохранение. Ну что ж, поехали!

Техническое задание

Давайте предположим, что мы делаем игру, в которой игроку нужно выполнять различные задания и, когда они завершаются, получать награду в виде монет.

Окно миссий
Окно миссий

В данном примере механика миссий будет работать следующим образом:

  • Миссии бывают трех уровней сложности: ЛЕГКАЯ, СРЕДНЯЯ, СЛОЖНАЯ

  • Для каждого уровня сложности случайно генерируется по одной миссии

  • Когда миссия выполнена, то игрок может забрать награду в качестве монет

  • После получения награды, генерируется новая миссия того же уровня сложности

В первую очередь начнем с разработки самих миссий, а затем уже будем реализовывать систему, которая будет ими управлять.

Реализация миссий

Итак, согласно техническому заданию, структура миссии состоит из следующих элементов:

  • название

  • описание

  • сложность

  • состояние прогресса

  • награда

  • иконка

Чтобы в Unity проекте можно было хранить и изменять настройки миссий, сделаем базовый класс MissionConfig, в котором будут храниться общие настройки для каждой миссии

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

Также сделаем enum, в котором будет перечисление уровней сложности:

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

Назревает вопрос, а где будем хранить информацию о том "сколько ресурсов нужно собрать" или "какое кол-во врагов нужно уничтожить"? Ответ для каждого типа задания сделаем класс-наследник, который и будет хранить дополнительную информацию о конкретном задании:

//Миссия "Собрать ресурсы"
[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; //Сколько врагов нужно уничтожить
}

Отлично, давайте теперь создадим, эти конфиги в Unity проекте:

Настройки миссий в Unity
Настройки миссий в Unity

Супер! Конфигурация готова, теперь время разработать логику самих заданий.

Очевидно, что логика миссий будет отличаться в зависимости от конкретного задания. Поэтому разумнее будет сделать базовый класс Mission, который будет хранить в себе общую логику, а классы наследники будут реализовывать выполнение условия под каждую миссию:

Семейство классов миссий
Семейство классов миссий

Абстрактный класс миссии можно написать так:

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);
    }
}

Тогда миссия "Добыть ресурсы" будет выглядеть так:

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; //Лучше Dependency 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();
        }
    }
}

А миссия уничтожения противника — вот так:

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; //Лучше Dependency 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();
    }
}

Для простоты примера я сделал вспомогательный класс Player. Этот класс является тестовой-заглушкой, который можно триггерить на события сбора ресурсов и уничтожения противников:

//Пример класса синглтона, где могут происходить события и храниться деньги:
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();
    }
}

Теперь напишем скрипт, с помощью которого проверим работоспособность системы:

#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

Подключим скрипты Player & MissionTest на сцену и проверим выполнение заданий:

Проверка работоспособности миссий
Проверка работоспособности миссий

Отлично, все работает! Можно переходить к разработке системы :)

Реализация менеджера миссий

Итак, давайте теперь поговорим о том, как можно сделать систему управления миссиями. Но перед этим вспомним, что нужно сделать по техническому заданию:

  • Для каждого уровня сложности генерируется случайно по одной миссии

  • Когда миссия выполнена, игрок может забрать награду в качестве монет

  • После получения награды, генерируется новая миссия того же уровня сложности

В результате менеджер миссий будет иметь следующую бизнес-логику:

Ответственности менеджера миссий
Ответственности менеджера миссий

Ремарка: В будущем менеджер миссий можно будет разделить на несколько классов, поскольку сейчас он решает несколько задач и нарушает принцип единственной ответственности.

Итак, напишем код для менеджера миссий:

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;
    }
}

Для того, чтобы написать менеджер, мне пришлось добавить еще один класс MissionCatalog, который будет хранить список всех конфигов миссий. Когда менеджеру нужно будет сгенерировать следующую миссию, то он будет брать случайную миссию из каталога:

[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];
    }
}

Также пришлось немного подкрутить класс MissionConfig так, чтобы он занимался созданием экземпляра миссий через полиморфизм:

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);
}

Итак, мы почти готовы к проверки работоспособности нашего MissionsManager. Осталось только поправить тестовый скрипт MissionTest:

#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

Перетаскиваем скрипт MissionManager на сцену, подкючаем каталог и запускаем Play Mode в Unity:

Проверка работоспособности менеджера миссий
Проверка работоспособности менеджера миссий

Как видно по логам, усе работает :)

Вот так можно легко и быстро написать систему для миссий. В дополнение прикрепляю ссылку на полученное решение, если вам интересно пощупать самому. А так у меня все. В следующей части поговорим об интерфейсе для миссий. Спасибо за внимание :)

Напоследок хочу пригласить вас на бесплатный урок, где поговорим о профессии Unity-разработчика и о том, чем он занимается. Расскажем какие проекты на Unity бывают и что лучше выбрать в качестве собственной первой игры. Рассмотрим текущий рынок вакансий в геймдеве и обсудим почему он постоянно увеличивается, а количество выпускаемых игр растёт. Дополнительно поговорим о ChatGPT и сможет ли он заменить разработчика игр на Unity.

Комментарии (3)


  1. haska2748
    06.06.2023 11:23

    Раз хардкор начали почему не приводите пример зачем именно использовать скриптабл обжект? Если награда однотипная проще будет вручную завести кастомные кнопки.

    Давайте пример где действительно оправдан скриптабл обжект: к примеру разные награды.


    1. StarKRE Автор
      06.06.2023 11:23

      ScriptableObject — это просто способ хранения информации о миссии в Unity проекте. Конечно, можно хранить инфу о миссиях как в формате json, так и в XML, но зачем себе жизнь усложнять, если есть ScriptableObject (про кастомные кнопки не понял). Если у тебя будут разные награды, то ты просто делаешь отдельное поле, например, так:

      //Конфиг для миссии:
      public abstract class MissionConfig : ScriptableObject
      {
          [SerializeField] public string id;
          [SerializeField] public MissionDifficulty difficulty;
          [SerializeField] public string title;
          [SerializeField] public Sprite icon;
      
          [SerializeField] public RewardConfig reward; //Ссылка на награду
      
      
          public abstract Mission CreateMission();
      }
      
      //Конфиг для награды:
      public class RewardConfig : ScriptableObject
      {
          //Структура награды зависит от тз...
      }


      1. Tutanhomon
        06.06.2023 11:23
        +1

        SO вам подтянет все зависимости которые вы в нем укажете - все иконки, конфиги и другие ресурсы, которые вы возможно в будущем захотите в этом конфиге описывать. Даже те, которые не нужны, например прошедшие миссии. Не страшно, если миссий 5-10. А если 50-100? Все это будет в памяти. Так что как минимум для самих СО конфигов стоит предусмотреть загрузку по требованию, хотя бы из ресурсов, а лучше из бандлов/аддрессаблов.