
Simple Asynchronous Task Manager for Unity3D
Introduction
Greetings, dear readers. This article will discuss the implementation of a simple asynchronous task manager for among 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 has to carry out 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. You can implement it in code in different ways, as the most primitive option in one Updatewith checking conditions. 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’ll do other things. In this case, an asynchronous approach will be useful. At the moment there are many different systems (including 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 just 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, I wrote above 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 structure:
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 one - 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 just 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 the simple sense, is a coroutine (if translated literally) that runs mostly 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 may be several (and we will need this later)
Otherwise, the code is quite simple and straightforward without any frills.
So, we described the task interface and implemented a class that implements it; however, 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 _tasks = new List();
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 during development, which, when solved head-on, 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 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.