Простой менеджер ассинхронных задач для Unity 3D

в 4:35, , рубрики: coroutine, unity3d, асинхронные задачи, разработка игр

Введение

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

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

Рассмотрим простой пример, с которым я думаю многие сталкивались при разработке игр. У нас есть некий персонаж, который должен выполнить серию действий: перейти в точку A, взять предмет, переместиться в точку Б, положить предмет. Как видно это обычная последовательность. Реализовать в коде ее можно по-разному, как самый примитивный вариант в одном Update с проверкой условий. Однако, все усложняется, если у нас много таких персонажей и действий у них тоже достаточно много. Хотелось бы, чтобы наш код умел сказать такому персонажу, соверши ряд действий последовательно и сообщи, когда закончишь, а я пока займусь другими вещами. В этом случае как раз и будет полезен асинхронный подход. На данный момент существует много различных систем (в то числе и для Unity), которые позволяют это делать, например, UniRx (реактивное асинхронное программирование). Но все подобные вещи для начинающих разработчиков достаточно сложны в понимании и освоении, поэтому попробуем воспользоваться тем, что предоставляет нам сам движок, а именно Coroutine.

Примечание: описанный выше пример лишь один из многих, помимо него есть множество областей, где можно описать аналогичную ситуацию: последовательная (или параллельная) загрузка ресурсов по сети, инициализация взаимозависимых подсистем, анимации в UI и т.д.

Реализация

Перед тем как писать код и вдаваться в глубины C# остановимся на архитектуре и терминологии.

Итак, выше я написал в качестве примера, действия персонажа, которые он должен выполнять. В рамках системы, которая будет описана в этой статье, данное действие является некой задачей, которую должен выполнить персонаж. Если обобщить это понятие, то задача, это любое действие, которое может выполнить любая сущность логики игры. Задача должна подчиняться следующим правилам:

  • Ее можно запустить
  • Можно подписаться на событие завершения ее выполнения
  • Ее можно принудительно остановить

Опишем эти правила через интерфейс.

public interface ITask
{
       void Start();
       ITask Subscribe(Action completeCallback); 
       void Stop();
}

Почему Subscribe возвращает ITask? Просто это повышает удобство из-за возможности создания конструкции вида:

ITask myTask;
myTask.Subscribe(() => Debug.Log(“Task Complete”)).Start();

Интерфейс для задачи создан, однако в нем не хватает одной важной вещи – это приоритет выполнения. Для чего он нужен? Представим ситуацию, когда мы выставили задачи персонажу и по логике возникает ситуация, что он должен все свои задачи остановить и выполнить другую – важную для игрового процесса. В этом случае, нам надо полностью остановить текущую цепочку и выполнить новую задачу. Описанный пример лишь один из нескольких вариантов поведения, помимо этого приоритеты могут быть следующими:

  • Обычный приоритет, каждая новая задача помещается в конец очереди
  • Высший приоритет, новая задача помещается в начало очереди
  • Приоритет с принудительной остановкой текущих задач

С учетом приоритетов интерфейс задачи примет конечный вид.

public enum TaskPriorityEnum
{
        Default,
        High,
        Interrupt
}
public interface ITask
{
       TaskPriorityEnum Priority { get; }

       void Start();
       ITask Subscribe(Action feedback); 
       void Stop();
}

Итак, мы определились с общим пониманием, что такое задача, теперь нам необходима конкретная реализация. Как было описано выше, в данной системе будут использоваться Coroutine. Coroutine, в простом понимании, это сопрограмма (если переводить дословно), которая выполняется в основном поток, но без его блокирования. За счет использования итераторов (IEnumerator) возврат в эту сопрограмму происходит на каждом кадре, если внутри нее произошел вызов yield return.

Реализуем класс Task, который будет имплементировать интерфейс ITask

public class Task : ITask
{
    public TaskPriorityEnum Priority
    {
        get
        {
            return _taskPriority;
        }
    }

    private TaskPriorityEnum _taskPriority = TaskPriorityEnum.Default;

    private Action _feedback;
    private MonoBehaviour _coroutineHost;
    private Coroutine _coroutine;
    private IEnumerator _taskAction;

    public static Task Create(IEnumerator taskAction, TaskPriorityEnum priority = TaskPriorityEnum.Default)
    {
        return new Task(taskAction, priority);
    }

    public Task(IEnumerator taskAction, TaskPriorityEnum priority = TaskPriorityEnum.Default)
    {
        _coroutineHost = TaskManager.CoroutineHost;
        _taskPriority = priority;
        _taskAction = taskAction;
    }

    public void Start()
    {
        if (_coroutine == null)
        {
            _coroutine = _coroutineHost.StartCoroutine(RunTask());
        }
    }

    public void Stop()
    {
        if (_coroutine != null)
        {
            _coroutineHost.StopCoroutine(_coroutine);
            _coroutine = null;
        }
    }

    public ITask Subscribe(Action feedback)
    {
        if (feedback != null)
        {
            _feedback += feedback;
        }

        return this;
    }


    private IEnumerator RunTask()
    {
        yield return _coroutineHost.StartCoroutine(_taskAction());

        CallSubscribe();
    }

    private void CallSubscribe()
    {
        if (_feedback != null)
        {
            _feedback();
        }
    }
}

Немного пояснений по коду:

  • Статический метод Create необходим для удобства записи вида:
    Task.Create(..).Subscribe(..).Start()
  • _coroutineHost это ссылка на экземпляр любого MonoBehaviour объекта от лица которого будет запускаться задача (она же Coroutine). Передать ссылку можно, например, через статическую переменную
  • В методе Subscribe подписчики добавляются через +=, поскольку их может быть несколько (и это нам понадобиться позже)

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

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

Реализация класса менеджера задач.

public class TaskManager
{       
    public ITask CurrentTask
    {
        get
        {
            return _currentTask;
        }
    }

    private ITask _currentTask;
    private List<ITask> _tasks = new List<ITask>();
	

    public void AddTask(IEnumerator taskAction, Action callback, TaskPriorityEnum taskPriority = TaskPriorityEnum.Default)
    {
        var task = Task.Create(taskAction, taskPriority).Subscribe(callback);

        ProcessingAddedTask(task, taskPriority);
    }

    public void Break()
    {
        if(_currentTask != null)
        {
            _currentTask.Stop();
        }
    }

    public void Restore()
    {
        TaskQueueProcessing();
    }

    public void Clear()
    {
        Break();

        _tasks.Clear();
    }

    private void ProcessingAddedTask(ITask task, TaskPriorityEnum taskPriority)
    {
        switch(taskPriority)
        {
            case TaskPriorityEnum.Default:
                {
                    _tasks.Add(task);
                }
                break;
            case TaskPriorityEnum.High:
                {
                    _tasks.Insert(0, task);
                }
                break;
            
                return;
            case TaskPriorityEnum.Interrupt:
                {
                	if (_currentTask != null && _currentTask.Priority != TaskPriorityEnum.Interrupt))
                    {
                    		_currentTask.Stop();
                    }

_currentTask = task;

task.Subscribe(TaskQueueProcessing).Start();
                }
                break;
        }

        if(_currentTask == null)
        {
            _currentTask = GetNextTask();

            if (_currentTask != null)
            {
                _currentTask.Subscribe(TaskQueueProcessing).Start();
            }
        }
    }

    private void TaskQueueProcessing()
    {
        _currentTask = GetNextTask(); 

        if(_currentTask != null)
        {
            _currentTask.Subscribe(TaskQueueProcessing).Start();
        }
    }

    private ITask GetNextTask()
    {
        if (_tasks.Count > 0)
        {
            var returnValue = _tasks[0]; _tasks.RemoveAt(0);

            return returnValue;
        } else
        {
            return null;
        }
    }
}

Разберем приведенный код:

  • Свойство CurrentTask – необходимо, чтобы отслеживать активность цепочки задач и иметь возможно подписаться на событие завершения текущей задачи любому кто имеет доступ к менеджеру
  • AddTask – основной метод класса, который создает и добавляет в очередь новую задачу согласно заданному приоритету. После добавления, если список задач пустой, она автоматически запускается
  • В момент запуска задачи (в методе ProcessingAddedTask), происходит дополнительная подписка самим менеджером задач на событие ее завершения (именно поэтому использовался += в классе Task). Когда задача завершается, менеджер забирает из очереди следующую и так до тех пор, пока все задачи в списке не будут выполнены

В остальном, как и в случае с классом Task, код очень примитивный, но это и было целью данной статьи.

Использование

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

public class TaskManagerTest : MonoBehaviour
{
    public Button StartTaskQueue;
    public Button StopTaskQueue;

    public Image TargetImage;
    public Transform From;
    public Transform To;

    private TaskManager _taskManager = new TaskManager();

    private void Start()
    {
        StartTaskQueue.onClick.AddListener(StartTaskQueueClick);
        StopTaskQueue.onClick.AddListener(StopTaskQueueClick);
    }

    private void StartTaskQueueClick()
    {
        _taskManager.AddTask(MoveFromTo(TargetImage.gameObject.transform, From.position, To.position, 2f));
        _taskManager.AddTask(AlphaFromTo(TargetImage, 1f, 0f, 0.5f));
        _taskManager.AddTask(Wait(1f));
        _taskManager.AddTask(AlphaFromTo(TargetImage, 0f, 1f, 0.5f));
        _taskManager.AddTask(MoveFromTo(TargetImage.gameObject.transform, To.position, From.position, 2f));        
    }

    private void StopTaskQueueClick()
    {
        if (_taskManager.CurrentTask != null)
        {
            _taskManager.Break();
        }else
        {
            _taskManager.Restore();
        }
    }

    private IEnumerator Wait(float time)
    {
        yield return new WaitForSeconds(time);
    }

    private IEnumerator MoveFromTo(Transform target, Vector3 from, Vector3 to, float time)
    {
        var t = 0f;
        do
        {
            t = Mathf.Clamp(t + Time.deltaTime, 0f, time);

            target.position = Vector3.Lerp(from, to, t / time);

            yield return null;
        } while (t < time);
    }

    private IEnumerator AlphaFromTo(Image target, float from, float to, float time)
    {
        var imageColor = target.color;
        var t = 0f;
        do
        {
            t = Mathf.Clamp(t + Time.deltaTime, 0f, time);

            imageColor.a = Mathf.Lerp(from, to, t / time);
            target.color = imageColor;

            yield return null;
        } while (t < time);
    }
}

Итак, что делает данный код. По нажатию на кнопку StartTaskQueue происходит запуск цепочки задач по оперированию объектом Image (TargetImage):

  • перемещает объект из позиции From в позицию To
  • скрывает объект через альфу
  • ждет одну секунду
  • показывает объект через альфу
  • перемещает объект из позици To в позицию From

При нажатии же на кнопку StopTaskQueue происходит остановка текущей цепочки задач, в случае если в менеджере есть активная задача, а если ее нет, то происходит восстановление цепочки задач (если это возможно).

Заключение

Несмотря на относительную простоту кода, данная подсистема позволяет решить множество задач при разработке, которые при решении в лоб могут вызвать определенные трудности. При использовании таких менеджеров и других подобных (более сложных) вы получаете гибкость и гарантию, что применяемые действия к объекту будут завершены в нужной последовательности и в случае, если этот процесс будет нужно прервать, это не вызовет “танцев с бубном”. В своих проектах я использую более сложный вариант описанной системы, которая позволяет работать и с Action и c YieldInstruction и с CustomYieldInstruction. Помимо прочего я использую больше вариантов приоритетов выполнения задач, а также режим запуска задачи вне менеджера и вне очередей с использованием Func(позволяет возвращать результат выполнения задачи). Реализация этих вещей не представляет собой сложности, и вы сами можете легко понять, как это сделать, используя код представленный выше.

Автор: Ichimitsu

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js