I would like to know what are the existing ways for triggering methods at some specific time. Let’s say, for example, the actions that will be triggered after pressing the attack button:
Spawn Particles at 0s
Spawn Particles at 1s
Deal Damage at 2s
Spawn Particles at 2s
How can this be done?
I am using the following code for this purpose, but I am not sure if this is a good implementation:
public class CommandQueue
{
public static CommandQueue Instance => _instance ?? (_instance = new CommandQueue());
private static CommandQueue _instance;
private readonly Queue<(Func<Task> task, int delayInMilliseconds)> _tasksToExecute;
private CommandQueue()
{
_tasksToExecute = new Queue<(Func<Task>, int)>();
}
public void EnqueueTask((Func<Task>, int) task)
{
_tasksToExecute.Enqueue(task);
}
public async Task RunTasks()
{
var tasks = new List<Task>();
while (_tasksToExecute.Count > 0)
{
var task = _tasksToExecute.Dequeue();
tasks.Add(AsyncUtils.StartAfterDelay(task.task, task.delayInMilliseconds));
}
await Task.WhenAll(tasks);
}
}
public static async Task StartAfterDelay(Func<Task> task, int milliseconds)
{
await Task.Delay(milliseconds);
await task?.Invoke();
return;
}
I also know that using animations, there is a possibility of attaching events to the animation clip so they can be triggered at a specific time, but in my case I will not have any animations at all.
Are there any other typical implementations for scheduling methods?
The approach above should be fine if it suits your needs.
There’s so many ways to accomplish this… as you noted you could use animations, or just having lots of little timers.
You can even just start a Coroutine and have waits and things happening, much like you’re doing with async await above.
Just be sure that all the parts of what you implement above work correctly with Time.timeScale changes. AFAIK the timeScale property exists only within the Unity engine, so it wouldn’t affect things like system timers, unless perhaps something has changed since I last looked. For instance, I make all my games pause when Time.timeScale == 0 so I have to make sure I only derive my time cues from things that are affected by this, like WaitForSeconds().
I see… the timeScale is a good point. My only concern is that coroutines need Monobehaviors and since I use some non-Monobehaviors classes I am using async instead. Is there any way to use coroutines on standard C# classes?
Than you very much for your answer!
Coroutines themselfs can be declared whereever you want. You only need a MonoBehaviour to start / host a coroutine. This can also be done by single MonoBehaviour singleton instance. As I said, where the coroutines are defined is irrelevant. Coroutines actually create an iterator object that they return. This object just needs to be passed to StartCoroutine of “some” MonoBehaviour.
Do you really want to specify a certain relative time for each event? At the moment you start all tasks at the same time, each with a different time offset. In many cases you usually have a certain order of events and maybe a delay between them. Currently you use a queue to accumulate your actions. However this doesn’t need to be a queue in your case as you just iterate through all of them at once and start them individually. If you have many actions, this is quite a waste of recources (since you start a new async task for every action) and also can cause time precision issues since every task runs on its own.
If you have a particular order of events, it usually makes sense to actually put them in oder in a single async method / coroutine and wait between the individual steps if necessary / wanted. So in your case you would
Spawn Particles right away (0 delay)
Spawn Particles after 1s delay
Deal Damage after 1s delay
Spawn Particles right away (0 delay)
So when executing those in order, the first action would run at relative time 0. The second action at time 1. The third and fourth action at time 2
I have just realized that I need to use Tasks instead of Coroutines because I do not see any way of doing something similar to Task.WhenAll with Coroutines… so would the following code be a solution for measuring any Task time before completing it and also react to time.timeScale?
Time.deltaTime should be scaled so a casual glance at your code would lead me to conclude “Yes it would.”
But… it’s so easy to test! Just test it and you will know for 100% sure… the empirical proof on top of the reasoned engineering you already did based on how we understand Time !
Yes, sorry! I have already tested it and it is working. What I am not sure about is if Task.Yield is adding some overhead or possible issues compared to yield return null used in coroutines.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class WhenAll : CustomYieldInstruction
{
private List<Coroutine> coroutines = new List<Coroutine>();
private MonoBehaviour owner;
private bool isCompleted;
public WhenAll(MonoBehaviour owner, List<IEnumerator> coroutineList)
{
this.owner = owner;
foreach (var coroutine in coroutineList)
{
var coroutineInstance = owner.StartCoroutine(RunCoroutine(coroutine));
this.coroutines.Add(coroutineInstance);
}
this.isCompleted = false;
}
public override bool keepWaiting
{
get { return !isCompleted; }
}
private IEnumerator RunCoroutine(IEnumerator coroutine)
{
yield return coroutine;
coroutines.Remove(owner.StartCoroutine(coroutine));
if (coroutines.Count == 0)
{
isCompleted = true;
}
}
}
Example - GPT-4o
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CoroutineManager : MonoBehaviour
{
private void Start()
{
StartCoroutine(ExampleUsage());
}
private IEnumerator ExampleUsage()
{
List<IEnumerator> coroutineList = new List<IEnumerator>
{
SampleCoroutine1(),
SampleCoroutine2()
};
// Wait for all coroutines in the list to complete
yield return new WhenAll(this, coroutineList);
Debug.Log("All coroutines completed!");
}
private IEnumerator SampleCoroutine1()
{
yield return new WaitForSeconds(2);
Debug.Log("SampleCoroutine1 completed");
}
private IEnumerator SampleCoroutine2()
{
yield return new WaitForSeconds(3);
Debug.Log("SampleCoroutine2 completed");
}
}
Start coroutine creates a new Coroutine instance. So it’s absolutely impossible to remove it from a list since the instance can not exist before that. Apart from that the code adds the “wrapping” coroutine “RunCoroutine” to the list.
The internal list should also simply store the IEnumerator instances and add / remove them from the list. Though since there is not really any benefit of storing them, just using a counter would be enough. I also would use a params array instead
blic class WhenAll : CustomYieldInstruction
{
private MonoBehaviour owner;
private int count = 0;
public WhenAll(MonoBehaviour owner, params IEnumerator[] coroutines)
{
this.owner = owner;
foreach (var coroutine in coroutines)
{
count++;
owner.StartCoroutine(RunCoroutine(coroutine));
}
}
public override bool keepWaiting
{
get { return count > 0; }
}
private IEnumerator RunCoroutine(IEnumerator coroutine)
{
yield return coroutine;
count--;
}
}
Of course this has an issue when the coroutines would be terminated as it could never detect the completion in that case. I wish Unity would provide an IsDone property on the Coroutine class (as well as a Stop method).
That is not a correct solution. Task.Yield is not equivalent to waiting for one frame.
If you look at the source code for Unity’s synchronization context (which is where Task.Yield causes the task to get queued), you’ll see that the thread gets suspended for one millisecond between each task in the queue, and if the whole queue isn’t processed within a certain time span, then the remaining ones will be processed at some later point in time (whenever that is). So not guaranteed to be equivalent to Time.deltaTime whatsoever.
More perniciously it feels like Chat sometimes give you stuff that actually WOULD sorta work, but then you delve farther and realize the fundamental flaws, such as the one Bunny stated above.
Another frequent and related Chat mishap is removing an anonymous delegate using a fresh one:
// the incorrect way of adding / removing anonymous delegates
{
System.Action foo = null;
foo += () => { Debug.Log( "Hello!"); };
foo -= () => { Debug.Log( "Hello!"); };
// you'll be amazed (if you're ChatGPT)
foo();
}
// the correct way:
{
System.Action foo = null;
System.Action bar = () => { Debug.Log( "Hello!"); };
foo += bar;
foo -= bar;
// now it crashes as expected (nullref)
foo();
}
Awaitable seems to be the right solution for me, but I am using Unity 2022. In case I do not want to use 2023 where Awaitables are available, would something like this be a possible solution?
using System.Threading.Tasks;
using UnityEngine;
public class CoroutineRunner : MonoBehaviour
{
public Task RunCoroutine(IEnumerator coroutine)
{
var tcs = new TaskCompletionSource<bool>();
StartCoroutine(WrapCoroutine(coroutine, tcs));
return tcs.Task;
}
private IEnumerator WrapCoroutine(IEnumerator coroutine, TaskCompletionSource<bool> tcs)
{
yield return StartCoroutine(coroutine);
tcs.SetResult(true);
}
}
public class Example : MonoBehaviour
{
private CoroutineRunner coroutineRunner;
private void Start()
{
coroutineRunner = gameObject.AddComponent<CoroutineRunner>();
RunAsyncMethods();
}
private async void RunAsyncMethods()
{
Debug.Log("Starting async method...");
await SomeAsyncMethod();
Debug.Log("Async method completed.");
}
private async Task SomeAsyncMethod()
{
Debug.Log("Waiting for coroutine...");
await coroutineRunner.RunCoroutine(MyCoroutine());
Debug.Log("Coroutine completed.");
}
private IEnumerator MyCoroutine()
{
yield return new WaitForSeconds(2);
Debug.Log("Coroutine running...");
}
}
If you want to simplify the process of starting coroutines, you could also create a static API that automatically lazily creates a mono behaviour instance behind-the-scenes, so that clients only need to pass an IEnumerator to it and that’s it.
You could even make it possible to await coroutines:
public static class CoroutineExtensions
{
public static TaskAwaiter GetAwaiter(this IEnumerator coroutine)
{
var tcs = new TaskCompletionSource<bool>();
StaticCoroutine.Start(WrapCoroutine(coroutine, tcs));
return tcs.Task.GetAwaiter();
}
}
Thank you all for your help. I have something already working with “awaitable coroutines”. The only thing missing is something I did not really get about Bunny83 comment below (in bold):
I understand the solution you provide, but the main problem I have with my idea is that in addition to the delays for triggering each action I also need to know when each action finishes as well. Let’s say for example that the damage particles stay on screen for 4s. This means that from 2s to 6s, I should not finish the current character turn since its attack particles are still visible. I am not sure how to deal with this without starting all the different actions and then wait for all of them with Task.WhenAll or something similar, but this causes the problem you mention in your comment:
Is it OK doing it my way? Are there any other better options for not finishing the turn until all the async methods / coroutines are completed?
It’s fine doing it your way. I wouldn’t worry too much about the overhead of Task.WhenAll. It’s probably better for readability and maintainability to use it when it intuitively makes sense, rather than trying to manually await only a subset of the tasks based on which ones you know are the longest-running.
It’s very likely that your time will be better spent optimizing what’s inside your methods, rather than the state machine code that gets generated during the lowering phase when you use async/await.
But the main point was that if you have multiple tasks that should be running concurrently, and you know that one of them lasts longer than all the others, then you could start all of them, but only await the one of them that lasts the longest.