待つ処理をすっきり書こう:async/awaitとAwaitable【Unityスタートガイド6-16】

「コウモリがあらわれた!」のあと「どうする?」と「たたかう」「にげる」のボタンが出る。「たたかう」を押すと数字がくるくる変わって「コウモリに11のダメージ!」。2回たたかったあと「にげる」を押して「うまく にげきれた!」で終わる

「メッセージを出して、読み終わるまで待って、次のメッセージ」「プレイヤーがボタンを押すまで待って、選んだコマンドで攻撃」。RPGのバトルのように、待つ処理がいくつも続くゲームでは、コルーチンだけで書くと「待ったあとの結果を受け取る」ところで少し苦労します。今回は、C#のasync/awaitと、Unity 6で使えるようになったAwaitableを使って、待つ処理を上から順番にすっきり書く方法をご紹介します!

目次

この記事で学べること

  • async/awaitとは何か、コルーチンとどこが違うのか
  • Awaitable.WaitForSecondsAsyncなどの、よく使う待ち方
  • オブジェクトを消しても止まらない問題と、destroyCancellationTokenでの止め方
  • async Awaitable<T>で、待ったあとに結果を受け取る方法
  • AwaitableCompletionSourceで、ボタンが押されるまで待つ方法

前提知識

プロジェクトの確認

  • 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();(普通のメソッドと同じ)
待ったあとに値を受け取る出来ない(変数を外に置いて工夫する)==greenint 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;だけで使える
  • StartCoroutineが要らない
    • Startの頭にasyncを付けるだけで、Startの中で待てる

空のGameObjectにAsyncCountdownを付けて再生すると、Consoleに1秒おきに「3」「2」「1」「スタート!」が出ます。

AsyncCountdownを付けたCountdownを選んで再生。Consoleに「3」「2」「1」「スタート!」が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」まで出たあと、オブジェクトが消えた後の続きで赤いエラーが出ます。

Consoleに「5:高さ 0」「4:高さ 1」「3:高さ 2」のあと、赤いMissingReferenceException(The object of type 'AsyncDestroyTest' has been destroyed but you are still trying to access it.)が出ている
  • 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を渡す。オブジェクトが消えると、待っている途中でも止まる
  • try 〜 catch (System.OperationCanceledException)
    • 止まったときは「キャンセルされた」というエラー(OperationCanceledException)が飛んでくる。それをcatchで受け止めて、静かに終わらせる
    • tryの中で止まったら、残りの行は飛ばしてcatchに移る

再生すると、「3」のあとに「オブジェクトが消えたので、止めました」が出て、赤いエラーは出なくなります。

Consoleに「5:高さ 0」「4:高さ 1」「3:高さ 2」「オブジェクトが消えたので、止めました」が出て、赤いエラーは無い

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」にする
HierarchyにCanvas・MessageText・EventSystem・Battle。MessageTextはWidth 1200・Height 200・Font Size 48で、Gameビューの下の方に「New Text」が出ている

「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で受け止められる

BattleにCommandBattleを付け、Message TextにMessageTextをドラッグして再生してみましょう。

BattleのCommand BattleのMessage TextにMessageTextが入り、Message Secondsは1
メッセージ欄が「コウモリがあらわれた!」→「コウモリは ようすを みている。」→「さあ、どうする?」と1秒ずつ切り替わる

コルーチンで同じことをすると、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」にする
    • 中の文字はそれぞれ「たたかう」「にげる」
    • メッセージ欄の上に並べる
HierarchyのCanvasの下にAttackButton・RunButtonが増え、Gameビューのメッセージ欄の上に「たたかう」「にげる」のボタンが並んでいる

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にそれぞれのボタンをドラッグして再生してみましょう。

BattleのCommand BattleのMessage Text・Attack Button・Run Buttonに、MessageText・AttackButton・RunButtonが入り、Message Secondsは1、Enemy Hpは30
「どうする?」でボタンが出て、「たたかう」を押すとボタンが消えて数字がくるくる変わり「コウモリに11のダメージ!」。最後に「にげる」を押して「うまく にげきれた!」で終わる
  • 「たたかう」を押すと、数字がくるくる変わってからダメージのメッセージが出る
  • 何度か「たたかう」を押して、合計30以上のダメージでコウモリを倒せる
  • 「にげる」を押すと、その場でバトルが終わる
  • コマンドを待っている間だけ、ボタンが表示されている

TrySetResultの「Try」は、「もう結果が入っていたら何もしない」という意味です。ボタンを素早く2回押しても、2回目は無視されるので安心です。

コルーチンとasync、どっちを使う?

どちらも「待つ処理」を書けるので、迷ったら次のように考えるのがオススメです。

こんなときオススメ
一定時間ごとに何かをする、点滅させる、など待つだけの処理コルーチン(オブジェクトと一緒に止まってくれて楽)
メッセージ送り・ターンの流れなど、待つ処理が何段も続くものasync/await
演出が終わってから結果を受け取りたいasync/await(Awaitable<T>)
プレイヤーの選択を待つasync/await(AwaitableCompletionSource)

どちらが正解ということはなく、得意なところで使い分ければOKです。ただ、1つのゲームの中で同じような処理をバラバラの書き方にすると読みにくくなるので、「バトルの流れはasync」のように、場所ごとに決めておくと迷いません。

Awaitableは、一度awaitしたものをもう一度awaitしないようにしてください。Unityは軽くするためにAwaitableを使い回しているので、同じものを2回待つと、おかしな動きになることがあります。待つたびにAwaitable.WaitForSecondsAsync(…)などを呼び直せばOKです。

スライム

デッキ構築カードゲームのシリーズはコルーチン、ターン制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の作り方シリーズで、本格的なバトルに育てていく予定です。お楽しみに!

次のステップ

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次