Simple Asynchronous Task Manager for Unity3D

Introduction


Greetings, dear readers. This article will discuss the implementation of a simple asynchronous task manager for Unity3d development . This manager basically uses the so-called Coroutine , which is present in the engine.

As usual, before describing the implementation and going into details, you need to understand what we are doing and why we need it.

Consider a simple example that I think many have come across when developing games. We have a certain character who must perform a series of actions: go to point A, take an object, move to point B, put an object. As you can see this is the usual sequence. It can be implemented in code in different ways, as the most primitive option in one Update with condition checking. However, things get complicated if we have a lot of such characters and they also have a lot of actions. I would like our code to be able to tell such a character, take a series of actions in sequence and let me know when you are done, but for now I will do other things. In this case, an asynchronous approach will be useful. At the moment there are many different systems (including for Unity ) that allow this, for example, UniRx (reactive asynchronous programming). But all such things for beginner developers are quite difficult to understand and master, so let's try to take advantage of what the engine itself provides us with, namely Coroutine .

Note : the example described above is only one of many, besides it there are many areas where you can describe a similar situation: sequential (or parallel) loading resources over the network, initializing interdependent subsystems, animations in the UI, etc.

Implementation


Before writing code and going into the depths of C #, we’ll dwell on the architecture and terminology.

So, above I wrote as an example, the actions of the character that he must perform. In the framework of the system that will be described in this article, this action is a certain task that the character must perform. If to generalize this concept, then a task is any action that can be performed by any entity of the game logic. The task must obey the following rules:

  • It can be run
  • You can subscribe to the event of its completion
  • It can be forced to stop

We describe these rules through the interface.
public interface ITask
{
       void Start();
       ITask Subscribe(Action completeCallback); 
       void Stop();
}


Why does Subscribe return ITask? It just increases the usability due to the possibility of creating a view design:

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

An interface for the task has been created, but one important thing is missing in it - this is the execution priority. What is it for? Imagine a situation when we set tasks for a character and, logically, a situation arises that he must stop all his tasks and complete another, important for the game process. In this case, we need to completely stop the current chain and complete a new task. The described example is only one of several behaviors; in addition, priorities may include the following:

  • Normal priority, each new task is placed at the end of the queue
  • Top priority, new task placed at the top of the queue
  • Priority with forced stopping of current tasks

Taking into account the priorities, the task interface will take the final form.
public enum TaskPriorityEnum
{
        Default,
        High,
        Interrupt
}
public interface ITask
{
       TaskPriorityEnum Priority { get; }

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


So, we decided on a common understanding of what the task is, now we need a specific implementation. As described above, Coroutine will be used in this system . Coroutine , in a simple sense, is a coroutine (if translated literally), which runs basically a thread, but without blocking it. Due to the use of iterators (IEnumerator), a return to this coroutine occurs on every frame if a yield return call is made inside it .

We will implement the Task class, which will implement the ITask interface
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)
    {
        _feedback += feedback;

        return this;
    }


    private IEnumerator RunTask()
    {
        yield return _taskAction;

        CallSubscribe();
    }

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


A little explanation on the code:

  • The static Create method is necessary for the convenience of writing the form:
    Task.Create(..).Subscribe(..).Start()
  • _coroutineHost is a reference to an instance of any MonoBehaviour object on behalf of which the task will be launched (it is Coroutine ). You can pass a link, for example, through a static variable
  • In the Subscribe
  • method, subscribers are added via + =, since there can be several (and we will need this later)

For the rest, a fairly simple and understandable code without any frills.

So, we described the task interface and implemented a class that implements it, but this is not enough for a full-fledged system, we need a manager who will monitor the execution of tasks in a chain in compliance with priorities. Since any game can have many subsystems that may require its own task manager, we will implement it in the form of a regular class, copies of which will be created and stored by everyone who needs it.

Implementation of the task manager class.
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;
        }
    }
}


Let's analyze the code below:

  • The CurrentTask
    • property is necessary to track the activity of the task chain and to be able to subscribe to the event of completion of the current task to anyone who has access to the manager
    • AddTask is the main method of the class that creates and queues a new task according to the given priority. After adding, if the task list is empty, it starts automatically
    • When the task starts (in the ProcessingAddedTask
    • method ), the task manager additionally subscribes to the event of its completion (this is why + = was used in the Task class ). When the task is completed, the manager removes the next one from the queue and so on until all tasks in the list are completed

    Otherwise, as in the case of the Task class , the code is very primitive, but this was the purpose of this article.

    Using


    Let's look at a simple example of how and where you can use the system described above.
    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);
        }
    }
    


    So what does this code do. By clicking on the StartTaskQueue button , the chain of tasks for operating the Image ( TargetImage ) object is launched :

    • moves an object from From to To
    • hides an object through alpha
    • waiting one second
    • shows an object through alpha
    • moves an object from To to From

    When you click on the StopTaskQueue button , the current task chain is stopped, if the manager has an active task, and if it is not, then the task chain is restored (if possible).

    Conclusion


    Despite the relative simplicity of the code, this subsystem allows you to solve many problems in development, which, when solved face-to-face, can cause certain difficulties. When using such managers and other similar (more complex) you get the flexibility and guarantee that the applied actions to the object will be completed in the right sequence and if this process needs to be interrupted, this will not cause "dancing with a tambourine". In my projects I use a more complex version of the described system, which allows you to work with both Action and c YieldInstruction and CustomYieldInstruction . Among other things, I use more options for task execution priorities, as well as the task launch mode outside the manager and outside the queues using Func (allows you to return the result of the task). The implementation of these things is not difficult, and you yourself can easily understand how to do this using the code presented above.