Async and Cancellation
Some Commands and Presenters take time: a Command that walks a character across the room, or a Presenter that types a line out. Write them so they still behave correctly when the player hurries the dialogue along, when the game is paused, and when the dialogue is stopped.
YarnTask
Yarn Spinner’s async methods return a YarnTask, or a YarnTask<T> when they return a value. You await a YarnTask the same way as any other task, and you can return one from your own async methods.
A YarnTask wraps one of three kinds of task, depending on your project. Yarn Spinner picks the first of these that’s available:
| Backend | Used when |
|---|---|
| UniTask | The UniTask package is installed. |
| Awaitable | You’re using Unity 2023.1 or later. |
System.Threading.Tasks.Task | Neither of the others is available. |
Most of the time you don’t need to know which one you’re using. A YarnTask converts to and from the backend’s own type, and from the other kinds of task where they’re available, so you can return a Task or an Awaitable where a YarnTask is expected.
YarnTask has static methods for common waits:
| Method | What it does |
|---|---|
YarnTask.Yield() | Waits until the next frame. |
YarnTask.Delay(milliseconds, token) | Waits for a number of milliseconds, or a TimeSpan. |
YarnTask.WaitUntil(predicate, token) | Waits until predicate returns true. |
YarnTask.WaitUntilCanceled(token) | Waits until token is cancelled. |
YarnTask.WhenAll(tasks) | Waits until every task in tasks has finished. |
YarnTask.CompletedTask | A task that has already finished. Return it from a method that has nothing to wait for. |
With UniTask and Awaitable, awaiting something that’s been cancelled throws an OperationCanceledException. To wait without the exception, add .SuppressCancellationThrow(). It returns true if the task was cancelled.
var cancelled = await YarnTask.Delay(2000, token).SuppressCancellationThrow();How the dialogue waits
The dialogue waits for two kinds of things:
- Commands: if a Command returns a
YarnTask, aTask, anAwaitable, aUniTask, aCoroutineor anIEnumerator, the dialogue waits until it’s finished. See Commands. - Presenters: the dialogue waits until every Presenter has finished
RunLineAsync()orRunOptionsAsync(). See Building Your Own Presenter.
The player can ask a line to hurry up or move on, but not a Command.
Hurry up and next content
Every line and every set of options gets a new LineCancellationToken, which is passed to each Presenter’s RunLineAsync() or RunOptionsAsync(). The token tells the Presenters when the line has been asked to finish early. There are two kinds of request:
| Request | What it means | What a Presenter should do |
|---|---|---|
| Hurry up | Finish showing the line now. | Skip to the end of any typing or animation, but keep the line on screen. |
| Next content | Move on. | Tidy up and return as soon as possible. |
When the player presses the continue button, the Line Advancer asks for hurry up if the line is still appearing, and next content if it’s finished. The first press shows the whole line, and the second moves on:
sequenceDiagram participant Player participant Runner as Dialogue Runner participant Presenter as Your Presenter Runner->>Presenter: RunLineAsync(line, token) Note over Presenter: Typing the line Player->>Runner: Continue Runner->>Presenter: token: hurry up Note over Presenter: Shows the whole line Player->>Runner: Continue Runner->>Presenter: token: next content Presenter-->>Runner: returns from RunLineAsync() Note over Runner: The dialogue moves on
You can ask for them from your own code too, with dialogueRunner.RequestHurryUpLine() and dialogueRunner.RequestNextLine(). For options, dialogueRunner.RequestHurryUpOption() asks for hurry up. Next content is requested for options when a Presenter picks one, so the other Presenters know to stop waiting. Stop() asks for next content as well.
A request for next content also counts as hurry up, so IsHurryUpRequested is true after either. Check IsNextContentRequested when you need to tell them apart.
The token has these properties:
| Part of the token | What it does |
|---|---|
IsHurryUpRequested | true once hurry up or next content has been requested. |
IsNextContentRequested | true once next content has been requested. |
HurryUpToken | A CancellationToken that’s cancelled when hurry up or next content is requested. Pass it to anything that should finish early. |
NextContentToken | A CancellationToken that’s cancelled when next content is requested. |
An example
This Presenter fades in a character’s portrait for each line. If the player hurries up, the portrait appears in full at once. It stays up until the dialogue moves on:
using UnityEngine;
using Yarn.Unity;
public class PortraitPresenter : DialoguePresenterBase
{
[SerializeField] CanvasGroup portrait;
public override YarnTask OnDialogueStartedAsync() => YarnTask.CompletedTask;
public override YarnTask OnDialogueCompleteAsync() => YarnTask.CompletedTask;
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
{
portrait.alpha = 0;
while (portrait.alpha < 1 && !token.IsHurryUpRequested)
{
portrait.alpha += Time.deltaTime;
await YarnTask.Yield();
}
portrait.alpha = 1;
await YarnTask.WaitUntilCanceled(token.NextContentToken).SuppressCancellationThrow();
portrait.alpha = 0;
}
}Return promptly
Once next content has been requested, the Dialogue Runner can’t move on until every Presenter has returned. A Presenter that never returns holds up the dialogue for good. This usually means it’s waiting for something that will never happen, like a button press on a button that’s already been hidden.
When a request comes in, return from RunLineAsync() or RunOptionsAsync(). Don’t let an OperationCanceledException escape. If one does, the Dialogue Runner catches it and logs a warning that names the Presenter. Use SuppressCancellationThrow(), or catch the exception yourself, and then return.
Waiting in Commands
Commands don’t get a token. While a Command is running, pressing continue, RequestHurryUpLine() and RequestNextLine() do nothing, and the dialogue waits until the Command has finished. Keep a Command’s waits short, or show the player that something is happening.
To wait for a number of seconds in a Command, return a coroutine that uses WaitForSeconds, or a YarnTask from YarnTask.Delay():
void Awake()
{
dialogueRunner.AddCommandHandler("pause_for_effect", PauseForEffect);
}
IEnumerator PauseForEffect()
{
yield return new WaitForSeconds(2);
}The built-in <<wait>> Command uses WaitForSeconds too.
Pausing the game
When you pause the game by setting Time.timeScale to 0, anything that counts scaled time stops:
- The Line Presenter stops typing, and its fades stop.
<<wait>>, and anything else that usesWaitForSeconds, stops counting.YarnTask.Delay()stops counting with UniTask and Awaitable. WithSystem.Threading.Tasks.Task, it counts real time and carries on.
The Line Advancer still reads input while Time.timeScale is 0, so stop it from advancing the dialogue while your pause menu is open.
In your own Presenters and Commands, count time with Time.deltaTime, WaitForSeconds or YarnTask.Delay() with UniTask or Awaitable, so they pause too.
Stopping the dialogue
Stop() asks the current line or options for next content, and ends the dialogue. Each Presenter’s OnDialogueCompleteAsync() is called, and then the Dialogue Runner invokes On Dialogue Complete. To wait until it’s finished, await it:
await dialogueRunner.Stop();The Dialogue Runner also stops the dialogue when it’s destroyed.
If a Command is waiting when the dialogue stops, the dialogue ends without waiting for it. The Command’s own code carries on to the end, but the dialogue doesn’t continue after it. If the rest of a Command shouldn’t happen once the dialogue has stopped, check dialogueRunner.IsDialogueRunning after each await:
async YarnTask WalkHome()
{
await WalkTo(door.position);
if (!dialogueRunner.IsDialogueRunning)
{
return;
}
await WalkTo(home.position);
}Waiting for something that might already be done
Sometimes you want to wait for something that might have finished before you get round to waiting for it, like an event that’s already been invoked. Use a YarnTaskCompletionSource for this. It records that it has finished, and the value it finished with. Awaiting its Task returns at once if it’s already finished.
This Presenter starts loading a portrait when the dialogue starts, and waits for it in RunLineAsync(). If the portrait has already loaded by then, there’s no wait:
using UnityEngine;
using UnityEngine.UI;
using Yarn.Unity;
public class PortraitLoadingPresenter : DialoguePresenterBase
{
[SerializeField] Image portrait;
YarnTaskCompletionSource<Sprite> portraitLoading;
public override YarnTask OnDialogueStartedAsync()
{
portraitLoading = new YarnTaskCompletionSource<Sprite>();
var request = Resources.LoadAsync<Sprite>("Portraits/Rosa");
request.completed += _ => portraitLoading.TrySetResult((Sprite)request.asset);
return YarnTask.CompletedTask;
}
public override YarnTask OnDialogueCompleteAsync() => YarnTask.CompletedTask;
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
{
if (portrait.sprite == null)
{
portrait.sprite = await portraitLoading.Task;
}
await YarnTask.WaitUntilCanceled(token.NextContentToken).SuppressCancellationThrow();
}
}Await a completion source’s Task once, and keep the result if you need it again. With Awaitable, a task can’t be awaited more than once.
A YarnTaskCompletionSource has:
TrySetResult(value), which finishes it with a value. It returnsfalse, and does nothing, if it has already finished.TrySetCanceled()andTrySetException(exception), which finish it with a cancellation or an error instead.Task, which youawaitto get the value.
YarnTaskCompletionSource without a type finishes without a value.