📜 ⬆️ ⬇️

A simple asynchronous task manager for Unity3D

Introduction


Welcome, dear readers. This article will discuss the implementation of a simple manager of asynchronously executed tasks for the development of Unity3d . 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 encountered 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 the code in different ways, as the most primitive version in one Update with condition checking. However, everything becomes more complicated if we have a lot of such characters and actions, they also have quite a lot. I would like our code to be able to say such a character, perform a series of actions consistently and let you know when you’re done, but for the time being I’ll do other things. In this case, just asynchronous approach will be useful. At the moment there are many different systems (including for Unity ) that allow you to do this, for example, UniRx (reactive asynchronous programming). But all such things for beginner developers are quite complicated in understanding and mastering, so let's try to take advantage of what the engine itself gives us, 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 of resources over the network, initialization of interdependent subsystems, animation in the UI, etc.
')

Implementation


Before you write code and go into the depths of C #, we’ll dwell on architecture and terminology.

So, above, I wrote as an example, the actions of the character that he should perform. As part of the system that will be described in this article, this action is a kind of task that the character must perform. If we generalize this concept, then a task is any action that any essence of the game logic can perform. The task should obey the following rules:


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 convenience due to the possibility of creating a structure of the form:

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

The interface for the task is created, but it lacks one important thing - this is the priority of execution. What is it for? Imagine a situation where we set tasks for a character and, logically, a situation arises that he must stop all his tasks and perform another - important for the game process. In this case, we need to completely stop the current chain and perform a new task. The described example is only one of several behaviors, besides this the priorities may be as follows:


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 have decided on a common understanding of what a task is, now we need a concrete implementation. As described above, Coroutine will be used in this system. Coroutine , in the simplest sense, is a coroutine (if translated literally), which runs mostly on the thread, but without blocking it. Due to the use of iterators (IEnumerator), a return to this coroutine occurs on each frame if a yield return call has occurred within it.

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 bit of explanation on the code:


In the rest, it is quite simple and clear code without any frills.

So, we described the task interface and made an implementation of the class that implements it, but for a full-fledged system, this is not enough, we need a manager who will monitor the execution of tasks along the chain in compliance with priorities. Since in any game there can be a multitude of subsystems that may need their own task manager, we will implement it in the form of a regular class, instances of which will be created and stored by everyone it needs.

Implementing a 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 sort the given code:


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

Using


Consider 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? Clicking the StartTaskQueue button launches a chain of tasks for operating an Image ( TargetImage ) object:


When you click the StopTaskQueue button, the current task chain is stopped, if there is an active task in the manager, and if it is not there, 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 the development, which, when solved in the forehead, can cause certain difficulties. When using such managers and other similar (more complex) ones, you get flexibility and a guarantee that the applied actions to the object will be completed in the necessary sequence and if this process is to be interrupted, it will not cause “dances with a tambourine”. In my projects, I use a more complex version of the described system, which allows working with both Action and YieldInstruction and CustomYieldInstruction . Among other things, I use more priority options for performing tasks, as well as the mode for starting a task outside the manager and out of queues using Func (allows you to return the result of the task). Implementing these things is not difficult, and you yourself can easily figure out how to do this using the code above.

Source: https://habr.com/ru/post/352296/


All Articles