
「メッセージを出して、読み終わるまで待って、次のメッセージ」「プレイヤーがボタンを押すまで待って、選んだコマンドで攻撃」。RPGのバトルのように、待つ処理がいくつも続くゲームでは、コルーチンだけで書くと「待ったあとの結果を受け取る」ところで少し苦労します。今回は、C#のasync/awaitと、Unity 6で使えるようになったAwaitableを使って、待つ処理を上から順番にすっきり書く方法をご紹介します!
この記事で学べること
- async/awaitとは何か、コルーチンとどこが違うのか
Awaitable.WaitForSecondsAsyncなどの、よく使う待ち方- オブジェクトを消しても止まらない問題と、
destroyCancellationTokenでの止め方 async Awaitable<T>で、待ったあとに結果を受け取る方法AwaitableCompletionSourceで、ボタンが押されるまで待つ方法
前提知識
- 時間を扱う処理:コルーチン・Invoke・一時停止:この記事は、コルーチンを知っている前提で進めます
- メソッドを作ろう:処理に名前を付けてまとめる
- 選択肢に名前を付けよう:enum(列挙型)とswitch文:コマンドの種類に使います
- テキスト、ボタン、画像の基本UI要素・TextMeshProで美しいテキストを表示しよう:後半のコマンドバトルで使います
プロジェクトの確認
- Unityのバージョンは6.3 LTS
- テンプレートはUniversal 2D(2Dでも3Dでも同じように使えます)
AwaitableはUnity 2023.1で入った機能で、LTSではUnity 6から使えます。2022 LTS以前では使えません
async/awaitってどんなもの?
6-11で作った「3、2、1、スタート!」のコルーチンを思い出してみましょう。
IEnumerator CountdownRoutine()
{
for (int i = 3; i > 0; i--)
{
Debug.Log(i);
yield return new WaitForSeconds(1f);
}
Debug.Log("スタート!");
}
同じことをasync/awaitで書くと、次のようになります。
async void Countdown()
{
for (int i = 3; i > 0; i--)
{
Debug.Log(i);
await Awaitable.WaitForSecondsAsync(1f);
}
Debug.Log("スタート!");
}
見た目はほとんど同じですね。yield returnがawaitに、IEnumeratorがasyncに変わっただけです。違いが出てくるのは、次のような場面です。
| コルーチン | async/await | ||
|---|---|---|---|
| 呼び方 | StartCoroutine(CountdownRoutine()); | Countdown();(普通のメソッドと同じ) | |
| 待ったあとに値を受け取る | 出来ない(変数を外に置いて工夫する) | ==green | int damage = await RollDamage();のように受け取れる== |
| 自分で作った待つ処理を待つ | yield return StartCoroutine(…); | await ShowMessage(…); | |
| オブジェクトを消したとき | 一緒に止まる | 止まらない(自分で止める必要がある) | |
| エラーが出たとき | どこで止まったか分かりにくい | try〜catchで受け止められる |
ざっくり言うと、async/awaitは「待って、結果を受け取って、次へ進む」処理が得意です。その代わり、止めるのは自分の仕事になります。順番に見ていきましょう。
カウントダウンをasyncで書いてみよう
まずは一番簡単な形から。Projectウインドで右クリック>Create>MonoBehaviour Script で「AsyncCountdown」を作り、中身を次のように書き換えます。
using UnityEngine;
// 3、2、1、スタート!を async/await で書く
public class AsyncCountdown : MonoBehaviour
{
private async void Start()
{
for (int i = 3; i > 0; i--)
{
Debug.Log(i);
await Awaitable.WaitForSecondsAsync(1f); // 1秒待つ
}
Debug.Log("スタート!");
}
}
- async
- 「このメソッドの中で
awaitを使います」という印。メソッドの型の前に付ける
- 「このメソッドの中で
- await Awaitable.WaitForSecondsAsync(1f);
- ここで1秒待ってから、次の行に進む。待っている間もゲームは止まらず、ほかのUpdateなどは動き続ける
- Awaitable
- Unityが用意している「待てるもの」。
using UnityEngine;だけで使える
- Unityが用意している「待てるもの」。
- StartCoroutineが要らない
- Startの頭に
asyncを付けるだけで、Startの中で待てる
- Startの頭に
空のGameObjectにAsyncCountdownを付けて再生すると、Consoleに1秒おきに「3」「2」「1」「スタート!」が出ます。

よく使う待ち方
| 書き方 | 待つもの | コルーチンでいうと |
|---|---|---|
await Awaitable.WaitForSecondsAsync(1f); | 1秒(ゲームの時間) | WaitForSeconds |
await Awaitable.NextFrameAsync(); | 次のフレームまで | yield return null |
await Awaitable.FixedUpdateAsync(); | 次のFixedUpdateまで | WaitForFixedUpdate |
await Awaitable.EndOfFrameAsync(); | そのフレームの描画が終わるまで | WaitForEndOfFrame |
await SceneManager.LoadSceneAsync("Town"); | シーンの読み込みが終わるまで | yield return SceneManager.LoadSceneAsync(…) |
「条件がtrueになるまで待つ」(コルーチンのWaitUntil)は、Awaitableには用意されていません。NextFrameAsyncをループで回せば同じことが出来ます。
// isReady が true になるまで、1フレームずつ待つ
while (!isReady)
{
await Awaitable.NextFrameAsync();
}
WaitForSecondsAsyncはゲームの時間で待つので、Time.timeScale = 0f;で一時停止している間は進みません(Unity 6.3で確認)。WaitForSecondsRealtimeにあたるものは無いので、一時停止中も待ちたいときはTime.unscaledDeltaTimeを足しながらNextFrameAsyncのループで待ちます。
気を付けて!オブジェクトを消しても止まらない
async/awaitを使うときに、一番気を付けてほしいのがここです。コルーチンは、動かしているオブジェクトが消えると一緒に止まりました。ところがasyncは、オブジェクトを消しても続きが動き続けます。
実際に試してみましょう。「AsyncDestroyTest」を作り、次のように書きます。
using UnityEngine;
// 2.5秒後に消えるオブジェクトで、async の続きがどうなるか試す
public class AsyncDestroyTest : MonoBehaviour
{
private async void Start()
{
// 2.5秒後に、このオブジェクトを消す(敵が倒されたつもり)
Destroy(gameObject, 2.5f);
for (int i = 5; i > 0; i--)
{
Debug.Log($"{i}:高さ {transform.position.y}");
transform.position += Vector3.up;
await Awaitable.WaitForSecondsAsync(1f);
}
Debug.Log("最後まで来た");
}
}
空のGameObjectに付けて再生すると、「5」「4」「3」まで出たあと、オブジェクトが消えた後の続きで赤いエラーが出ます。

- 2.5秒の時点でオブジェクトは消えている
- でも、
awaitの続き(3.0秒の時点)は普通に動いて、消えたオブジェクトのtransformにさわってしまう - その結果、MissingReferenceException(消えたものにさわった)のエラーになる
ゲームでいうと、「倒した敵の攻撃演出が、敵が消えた後も動いてエラーになる」「シーンを切り替えたのに、前のシーンのメッセージ送りが動いている」のようなことが起きます。
destroyCancellationTokenで止めよう
これを防ぐのがdestroyCancellationTokenです。MonoBehaviourが最初から持っていて、オブジェクトが消えるときに「止まって!」の合図を出してくれます。待つところに渡しておくと、オブジェクトが消えたタイミングで待つのをやめてくれます。
AsyncDestroyTestのStartを、次のように書き換えます。
private async void Start()
{
Destroy(gameObject, 2.5f);
try
{
for (int i = 5; i > 0; i--)
{
Debug.Log($"{i}:高さ {transform.position.y}");
transform.position += Vector3.up;
await Awaitable.WaitForSecondsAsync(1f, destroyCancellationToken);
}
Debug.Log("最後まで来た");
}
catch (System.OperationCanceledException)
{
// オブジェクトが消えて待つのが止まったら、ここに来る
Debug.Log("オブジェクトが消えたので、止めました");
}
}
- WaitForSecondsAsync(1f, destroyCancellationToken)
- 2つ目に
destroyCancellationTokenを渡す。オブジェクトが消えると、待っている途中でも止まる
- 2つ目に
- try 〜 catch (System.OperationCanceledException)
- 止まったときは「キャンセルされた」というエラー(OperationCanceledException)が飛んでくる。それを
catchで受け止めて、静かに終わらせる tryの中で止まったら、残りの行は飛ばしてcatchに移る
- 止まったときは「キャンセルされた」というエラー(OperationCanceledException)が飛んでくる。それを
再生すると、「3」のあとに「オブジェクトが消えたので、止めました」が出て、赤いエラーは出なくなります。

catchを書き忘れると、Consoleに赤い「OperationCanceledException: coroutine canceled」が出ます(Unity 6.3で確認)。トークンを渡すときは、try〜catchとセットで書きましょう。
スライムawaitを書くたびにdestroyCancellationTokenを渡すの、ちょっと面倒です・・・



最初は面倒に感じるけど「オブジェクトの上で待つときは必ず渡す」って決めちゃえば迷わないよ!エラーが出てから探すより、ずっと楽だからね
ちなみに、Unityエディタの再生を止めたときは、待っている途中のasyncも止まります(Unity 6.3で確認)。困るのはゲームの中でオブジェクトを消したり、シーンを切り替えたりしたときです。
入口はasync void、自分で作るメソッドはasync Awaitable
asyncのメソッドの型は、2つを使い分けます。
| 型 | 使うところ |
|---|---|
async void | 入口。StartやボタンのonClickなど、Unityやボタンから呼ばれるメソッド |
async Awaitable / async Awaitable<T> | 自分で作る待つメソッド。ほかのメソッドからawaitで待ってもらうもの |
async voidのメソッドは、awaitで待つことが出来ません(呼んだら呼びっぱなし)async Awaitableのメソッドは、awaitで待てます。終わるまで待ってから、次の行に進めます
Startをasync Awaitable Start()と書くことも出来ますが、Unity 6.3で試したところ、中でエラーが起きてもConsoleに何も出ませんでした。どこで止まったのか分からなくなるので、この記事ではStartやボタンから呼ばれる入口はasync voidにして、中でtry〜catchを書く形にしています。
メッセージを順番に出してみよう
ここからは、RPGのバトルの一部を作りながら、asyncの便利なところを見ていきます。まずは画面の準備です。
- Hierarchyで右クリック>UI (Canvas)>Text – TextMeshPro で文字を作り、名前を「MessageText」にする
- 画面の下の方に置き、Width:1200、Height:200、Font Size:48ぐらい
- 日本語を出すので、日本語のフォントアセットを設定しておく(9-3の記事)
- 空のGameObjectを作り、名前を「Battle」にする


「CommandBattle」というスクリプトを作り、次のように書きます。
using TMPro;
using UnityEngine;
// メッセージを1つずつ出して、読み終わるまで待つ
public class CommandBattle : MonoBehaviour
{
[SerializeField] private TMP_Text messageText;
[SerializeField] private float messageSeconds = 1f;
private async void Start()
{
try
{
await ShowMessage("コウモリがあらわれた!");
await ShowMessage("コウモリは ようすを みている。");
await ShowMessage("さあ、どうする?");
}
catch (System.OperationCanceledException)
{
// オブジェクトが消えたら、そこで終わり
}
}
// メッセージを出して、messageSeconds 秒待つ
private async Awaitable ShowMessage(string message)
{
messageText.text = message;
await Awaitable.WaitForSecondsAsync(messageSeconds, destroyCancellationToken);
}
}
- private async Awaitable ShowMessage(string message)
- 「メッセージを出して待つ」をメソッドにまとめた。型が
Awaitableなので、呼ぶ側がawaitで待てる
- 「メッセージを出して待つ」をメソッドにまとめた。型が
- await ShowMessage(“…”);
- ShowMessageが終わる(1秒待ち終わる)まで、次の行に進まない。だからメッセージが台本のように上から順番に出る
- destroyCancellationToken
- ShowMessageの中で渡しておけば、呼ぶ側では何もしなくてOK。止まるとエラーがStartまで伝わってきて、Startの
catchで受け止められる
- ShowMessageの中で渡しておけば、呼ぶ側では何もしなくてOK。止まるとエラーがStartまで伝わってきて、Startの
BattleにCommandBattleを付け、Message TextにMessageTextをドラッグして再生してみましょう。




コルーチンで同じことをすると、yield return StartCoroutine(ShowMessage("…"));と毎回StartCoroutineで包む必要があります。asyncならawait ShowMessage("…");だけなので、メッセージが増えても読みやすいままです。
待ったあとに結果を受け取ろう
asyncの一番の強みが、待ったあとに値を返せることです。型をAwaitable<int>にすると、「待ってからintを返す」メソッドになります。
攻撃のダメージを、ルーレットのように数字をくるくる変えてから決めるメソッドを、CommandBattleに足してみましょう。
// 数字をくるくる変える演出をしてから、ダメージを返す
private async Awaitable<int> RollDamage()
{
int damage = 0;
for (int i = 0; i < 12; i++)
{
damage = Random.Range(5, 13); // 5〜12
messageText.text = $"ダメージ・・・{damage}";
await Awaitable.WaitForSecondsAsync(0.05f, destroyCancellationToken);
}
return damage;
}
- async Awaitable
<>の中が、待ち終わったあとに返す値の型。ここではダメージのint
- return damage;
- 普通のメソッドと同じように
returnで返す
- 普通のメソッドと同じように
呼ぶ側では、次のように受け取れます。
int damage = await RollDamage();
await ShowMessage($"コウモリに{damage}のダメージ!");
演出が終わるまで待ってから、決まったダメージを受け取り、そのままメッセージに使えます。コルーチンでこれをやろうとすると、ダメージを入れておく変数をクラスの外側に作って、コルーチンが終わってから読む・・・のような回り道が必要でした。
ボタンが押されるまで待とう
RPGのバトルでは、「プレイヤーがコマンドを選ぶまで待つ」場面が必ず出てきます。これは時間で待つのではなく、「ボタンが押されたら終わる」待ち方です。そんなときに使うのがAwaitableCompletionSourceです。
- AwaitableCompletionSource:「あとで結果が入る箱」のようなもの
await source.Awaitable;:箱に結果が入るまで待つsource.TrySetResult(値);:箱に結果を入れる。待っていた側が、その値を受け取って動き出すsource.Reset();:箱を空に戻す(もう一度待つ前に呼ぶ)
画面にボタンを2つ足します。
- Hierarchyで右クリック>UI (Canvas)>Button – TextMeshPro を2つ作り、名前を「AttackButton」「RunButton」にする
- 中の文字はそれぞれ「たたかう」「にげる」
- メッセージ欄の上に並べる


CommandBattleを、次のように書き換えます。
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// コマンドの種類
public enum BattleCommand
{
Attack, // たたかう
Run // にげる
}
// コマンドを選ぶまで待って、選んだコマンドで戦う
public class CommandBattle : MonoBehaviour
{
[SerializeField] private TMP_Text messageText;
[SerializeField] private Button attackButton;
[SerializeField] private Button runButton;
[SerializeField] private float messageSeconds = 1f;
[SerializeField] private int enemyHp = 30;
// 選んだコマンドが入る箱
private AwaitableCompletionSource<BattleCommand> commandSource = new AwaitableCompletionSource<BattleCommand>();
private void Awake()
{
// ボタンが押されたら、箱にコマンドを入れる
attackButton.onClick.AddListener(() => commandSource.TrySetResult(BattleCommand.Attack));
runButton.onClick.AddListener(() => commandSource.TrySetResult(BattleCommand.Run));
SetButtonsActive(false);
}
private async void Start()
{
try
{
await Battle();
}
catch (System.OperationCanceledException)
{
// オブジェクトが消えたら、そこで終わり
}
}
// バトルの流れ
private async Awaitable Battle()
{
await ShowMessage("コウモリがあらわれた!");
while (enemyHp > 0)
{
BattleCommand command = await WaitForCommand();
if (command == BattleCommand.Run)
{
await ShowMessage("うまく にげきれた!");
return;
}
int damage = await RollDamage();
enemyHp -= damage;
await ShowMessage($"コウモリに{damage}のダメージ!");
}
await ShowMessage("コウモリを たおした!");
}
// ボタンが押されるまで待って、選ばれたコマンドを返す
private async Awaitable<BattleCommand> WaitForCommand()
{
messageText.text = "どうする?";
commandSource.Reset();
SetButtonsActive(true);
BattleCommand command = await commandSource.Awaitable;
SetButtonsActive(false);
return command;
}
private void SetButtonsActive(bool isActive)
{
attackButton.gameObject.SetActive(isActive);
runButton.gameObject.SetActive(isActive);
}
// メッセージを出して、messageSeconds 秒待つ
private async Awaitable ShowMessage(string message)
{
messageText.text = message;
await Awaitable.WaitForSecondsAsync(messageSeconds, destroyCancellationToken);
}
// 数字をくるくる変える演出をしてから、ダメージを返す
private async Awaitable<int> RollDamage()
{
int damage = 0;
for (int i = 0; i < 12; i++)
{
damage = Random.Range(5, 13); // 5〜12
messageText.text = $"ダメージ・・・{damage}";
await Awaitable.WaitForSecondsAsync(0.05f, destroyCancellationToken);
}
return damage;
}
}
- AwaitableCompletionSource
<>の中が、箱に入れる値の型。ここではコマンド(enum)
- onClick.AddListener(() => commandSource.TrySetResult(…))
- ボタンが押されたら、押されたボタンのコマンドを箱に入れる。
() =>は「押されたときにやること」をその場で書く書き方
- ボタンが押されたら、押されたボタンのコマンドを箱に入れる。
- WaitForCommand
Resetで箱を空にしてからボタンを出し、await commandSource.Awaitableで押されるまで待つ。押されたらボタンを隠して、コマンドを返す
- Battle
- 「メッセージ → コマンドを待つ → ダメージ → メッセージ」が、上から読んだとおりの順番で動く。これがasyncで書く一番のうれしさです
BattleのInspectorで、Attack ButtonとRun Buttonにそれぞれのボタンをドラッグして再生してみましょう。




- 「たたかう」を押すと、数字がくるくる変わってからダメージのメッセージが出る
- 何度か「たたかう」を押して、合計30以上のダメージでコウモリを倒せる
- 「にげる」を押すと、その場でバトルが終わる
- コマンドを待っている間だけ、ボタンが表示されている
TrySetResultの「Try」は、「もう結果が入っていたら何もしない」という意味です。ボタンを素早く2回押しても、2回目は無視されるので安心です。
コルーチンとasync、どっちを使う?
どちらも「待つ処理」を書けるので、迷ったら次のように考えるのがオススメです。
| こんなとき | オススメ |
|---|---|
| 一定時間ごとに何かをする、点滅させる、など待つだけの処理 | コルーチン(オブジェクトと一緒に止まってくれて楽) |
| メッセージ送り・ターンの流れなど、待つ処理が何段も続くもの | async/await |
| 演出が終わってから結果を受け取りたい | async/await(Awaitable<T>) |
| プレイヤーの選択を待つ | async/await(AwaitableCompletionSource) |
どちらが正解ということはなく、得意なところで使い分ければOKです。ただ、1つのゲームの中で同じような処理をバラバラの書き方にすると読みにくくなるので、「バトルの流れはasync」のように、場所ごとに決めておくと迷いません。



デッキ構築カードゲームのシリーズはコルーチン、ターン制RPGのシリーズはasyncで書く予定です。両方読むと、違いがよく分かるかも・・・
よくあるエラーと対処法
エラー:The ‘await’ operator can only be used within an async method
- 原因:
asyncを付けていないメソッドの中でawaitを使っている - 対処法:メソッドの型の前に
asyncを付ける(private async void Start()、private async Awaitable ShowMessage(…))
エラー:MissingReferenceException: The object of type ‘○○’ has been destroyed
- 原因:オブジェクトが消えたあとに、awaitの続きが動いて、消えたものにさわった
- 対処法:待つところに
destroyCancellationTokenを渡し、入口のメソッドでtry〜catch (System.OperationCanceledException)を書く
エラー:OperationCanceledException: coroutine canceled
- 原因:
destroyCancellationTokenで止まったのに、catchで受け止めていない - 対処法:入口のメソッド(
async voidのStartなど)にtry〜catch (System.OperationCanceledException)を書く
何も起きない・途中で止まったのにエラーが出ない
- 原因:入口を
async Awaitable Start()にしていて、中で起きたエラーがConsoleに出ていない - 対処法:入口は
async voidにする。または、怪しいところにDebug.Logを入れて、どこまで動いたか確かめる
一時停止中にメッセージが進まない
- 原因:
Time.timeScale = 0f;の間は、WaitForSecondsAsyncも止まる - 対処法:一時停止中も動かしたいものは、
Time.unscaledDeltaTimeを足しながらNextFrameAsyncのループで待つ
まとめ
asyncを付けたメソッドの中で、awaitを使って待てる。Awaitable.WaitForSecondsAsync・NextFrameAsyncが基本- asyncはオブジェクトを消しても止まらない。
destroyCancellationTokenを渡して、try〜catchで受け止める - 入口(Start・ボタン)は
async void、自分で作る待つメソッドはasync Awaitable async Awaitable<T>で、待ったあとに結果を受け取れるAwaitableCompletionSourceで、ボタンが押されるまで待てる
今回作ったコマンドバトルは、ターン制RPGの作り方シリーズで、本格的なバトルに育てていく予定です。お楽しみに!
コメント