【Unity】Input Systemの入力をawaitで待機させる

こじゃらこじゃら

Input Systemのコールバックをasync/awaitで待機させたいの…

このはこのは

UniTaskのUniTaskCompletionSourceを使えばこの辺がスマートに実装できるわ。

Input SystemのActionからの入力は、コールバックとして受け取ることが出来ます。

// 対象のAction
[SerializeField] private InputAction _action;

private void Start()
{
    // ここでコールバック登録
    _action.performed += OnPerformed;
}

// ボタンが押されるたびに呼ばれる処理
private void OnPerformed(InputAction.CallbackContext context)
{
    print("Performed");
}

コールバック経由で入力値を受け取る場合、例えばボタンが指定順序通りに押されたか判定するケースでは、ステートマシンの実装やコールバックのネストを行う必要が出てくるかもしれません。

このような問題は、C#のasync/await構文で非同期待機する処理に書き換えれば解決できます。

実現方法は一通りではありませんが、UniTaskのUniTaskCompletionSourceクラスを使うとこの辺が楽に実装できます。

参考:UniTaskCompletionSource Class | UniTask

本記事では、次のコードのようにInput Actionの入力をawaitで非同期待機できるようにする方法を解説します。

// action1の入力待機
await action1.OnPerformedAsync();

// action2の入力待機
await action2.OnPerformedAsync();

// action3の入力待機
await action3.OnPerformedAsync();

また、以下要件を満たすawaitを実現するものとします。

要件一覧
  • startedperformedcanceledコールバックに対してawait可能にする
  • CancellationTokenによるキャンセルにも対応させる
  • InputActionに対して拡張メソッドとして呼び出す形にする
  • 入力値の受取りにも対応する
動作環境
  • Unity 2022.3.0f1
  • Input System 1.5.1
  • UniTask 2.3.3

スポンサーリンク

前提条件

事前にInput Systemパッケージがインストールされ、有効化されているものとします。

ここまでのセットアップ手順については以下記事をご覧ください。

また、本記事ではUniTaskを用いてawaitによる入力待ちを実現するものとします。記事を読み進めるにあたっては、C#のasync/await構文およびUniTaskの基本的な使い方を理解していることが前提となります。

UniTaskはUPM経由でインストール可能です。インストール未実施の場合は以下手順で実施してください。

UniTaskのインストール手順
  • トップメニューのWindow > Package Managerを選択し、Package Managerウィンドウを開く
  • 左上の+アイコン > Add package from git URL…を選択
  • URLの入力欄に以下を入力し、右側の「Add」ボタンをクリック
https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask

参考:Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.

本記事では、Input Actionに存在する3種類のコールバックstartedperformedcanceledに対してawaitで待機させるところを目指して解説を進めます。

それぞれのコールバックの意味は、以下記事で解説しています。

コールバックをawaitに置き換える仕組み

Actionからコールバック経由で入力を受け取る場合、次のようなコードになるでしょう。

// 対象のAction
[SerializeField] private InputAction _action;

private void Start()
{
    // ここでコールバック登録
    _action.performed += OnPerformed;
}

// ボタンが押されるたびに呼ばれる処理
private void OnPerformed(InputAction.CallbackContext context)
{
    print("Performed");
}

上記のコードを例えば、何かボタンが押されたら処理を実行させるフローにしたい場合を考えます(キャンセル処理については後述します)

// 対象のAction
[SerializeField] private InputAction _action;

// ボタンが押されたら何か処理をする
private async UniTaskVoid Start()
{
    // performedが通知されるまで待機
    await _action.OnPerformedAsync();
    
    print("Performed");
    
    // TODO : 何らかの処理を実行
}

このような仕組みは、UniTaskのUniTaskCompletionSourceクラスを使うとスムーズに実現できます。

参考:UniTaskCompletionSource Class | UniTask

UniTaskCompletionSourceクラスは、次のようにnewでインスタンス化して使います。

// performedが通知されたら結果を確定させるUniTaskCompletionSource
var tcs = new UniTaskCompletionSource();

呼び元からは、次のようにTaskプロパティに対してawaitする形で入力を受け取るまで待機します。

// 結果がコールバックなどから返されるまで待機
await tcs.Task;

performedコールバックなどの処理では、次のようにTrySetResultメソッドを実行して結果を確定させます。

// 結果を確定させる
tcs.TrySetResult();

これにより、performedコールバックが実行されたら入力待機側のawait待機が終了し、以降の処理に進むことが出来ます。

例えば拡張メソッドとして実装したい場合は、次のようなコードになるでしょう。

public static class MyExtension
{
    // InputActionのperformedコールバックが実行されるまで待機する
    public static UniTask OnPerformedAsync(this InputAction action)
    {
        // performedが通知されたら結果を確定させるUniTaskCompletionSource
        var tcs = new UniTaskCompletionSource();

        // performedコールバックを登録
        action.performed += OnPerformedCallback;

        // performedコールバックを受け取るローカル関数
        void OnPerformedCallback(InputAction.CallbackContext context)
        {
            // 結果は一度だけ返すため、コールバックを解除
            action.performed -= OnPerformedCallback;
            // 結果を確定させる
            tcs.TrySetResult();
        }

        // await可能なUniTaskを返す
        return tcs.Task;
    }
}

メモ

.NETには、同様の役割を持つTaskCompletionSourceクラスが存在します。メソッドやプロパティなど、使い方は基本的に一緒です。

結果のキャンセル

前述のコードでは、結果が確定するまで永遠にawaitされます。

例えば、キャンセルボタンやその他何らかの要因でawaitを中断したい場合CancellationToken構造体を使って拡張メソッド側を対応させる必要があります。

呼び元では、次のようにCancellationTokenSourceインスタンスを生成し、拡張メソッド側にTokenプロパティ(CancellationToken構造体)を渡します。

// CancellationTokenを生成
var cts = new CancellationTokenSource();

// performedが通知されるまで待機
await _action.OnPerformedAsync(cts.Token);

これにより、呼び元からは好きなタイミングでawaitの処理を中断できるようになります。

拡張メソッド側では、次のコードでCancellationTokenからキャンセル要求が来た時に、UniTaskCompletionSourceに対して結果のキャンセルを行います。

// CancellationTokenがキャンセルされたときの処理
ct.Register(() =>
{
    // performedコールバックを解除
    action.performed -= OnPerformedCallback;
    // UniTaskCompletionSourceをキャンセル
    tcs.TrySetCanceled();
});

キャンセル要求が来た時の処理は、CancellationToken.Registerメソッドで登録できます。

performedコールバックをawaitする最低限のコード

以下、InputActionのperformedコールバックをawaitで待機する拡張メソッドです。CancellationTokenによるキャンセル処理にも対応しています。

InputActionAsyncExtensions.cs
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine.InputSystem;

public static class InputActionAsyncExtensions
{
    /// <summary>
    /// InputActionのPerformedコールバックが実行されるまで待機する
    /// </summary>
    public static UniTask OnPerformedAsync(
        this InputAction inputAction,
        CancellationToken ct = default
    )
    {
        // InputActionのPerformedコールバックが実行されるまで待機
        var tcs = new UniTaskCompletionSource();

        // Performedコールバックを受け取るローカル関数
        void OnPerformedCallback(InputAction.CallbackContext context)
        {
            // Performedコールバックを受け取ったらコールバックを解除
            inputAction.performed -= OnPerformedCallback;
            tcs.TrySetResult();
        }

        // Performedコールバックを登録
        inputAction.performed += OnPerformedCallback;

        // CancellationTokenがキャンセルされたときの処理
        ct.Register(() =>
        {
            // Performedコールバックを解除
            inputAction.performed -= OnPerformedCallback;
            // UniTaskCompletionSourceをキャンセル
            tcs.TrySetCanceled();
        });

        // await可能なUniTaskを返す
        return tcs.Task;
    }
}

上記をInputActionAsyncExtensions.csという名前でUnityプロジェクトに保存しておきます。

実際の使用例は以下のようになります。

UseExample1.cs
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.InputSystem;

public class UseExample1 : MonoBehaviour
{
    // 決定Action
    [SerializeField] private InputActionProperty _submitAction;

    // キャンセルAction
    [SerializeField] private InputActionProperty _cancelAction;

    // ボタンが押されたらログ出力する
    private async UniTaskVoid Start()
    {
        if (_submitAction.action == null || _cancelAction.action == null)
        {
            Debug.LogError("InputActionPropertyが設定されていません。");
            return;
        }

        // キャンセルのためのCancellationTokenSource
        var cts = new CancellationTokenSource();

        // キャンセル時の処理
        void OnCancel(InputAction.CallbackContext context)
        {
            // キャンセル要求
            cts.Cancel();
        }

        // キャンセル時の処理を登録
        _cancelAction.action.performed += OnCancel;

        try
        {
            // 入力待機
            await _submitAction.action.OnPerformedAsync(cts.Token);
            print("決定ボタンが押された!");
        }
        catch (OperationCanceledException)
        {
            // キャンセルされた場合の処理
            print("キャンセルされた!");
        }
        finally
        {
            // キャンセル時の処理を解除
            _cancelAction.action.performed -= OnCancel;
            // CancellationTokenSourceを破棄
            cts.Dispose();
        }
    }

    private void OnDestroy()
    {
        // InputActionを破棄
        _submitAction.action?.Dispose();
        _cancelAction.action?.Dispose();
    }

    private void OnEnable()
    {
        // InputActionを有効化
        _submitAction.action?.Enable();
        _cancelAction.action?.Enable();
    }

    private void OnDisable()
    {
        // InputActionを無効化
        _submitAction.action?.Disable();
        _cancelAction.action?.Disable();
    }
}

決定ボタンが押されたらログ出力します。決定前にキャンセルボタンが押されたら、決定ボタンの入力待機を中断し、キャンセルの旨のログを出力します。

上記をUseExample1.csという名前でUnityプロジェクトに保存し、適当なゲームオブジェクトにアタッチし、インスペクターより決定ボタン、キャンセルボタンそれぞれのActionを設定してください。

例では決定ボタンにキーボードのスペースキー、キャンセルボタンにエスケープキーを割り当てるものとします。

実行結果

決定ボタンを押すと、決定された旨がログ出力されます。

決定後にキャンセルボタンを押しても何も起こりません。

逆にキャンセルボタンを先に押すと、キャンセルされた旨がログ出力されます。

この時、決定ボタンを押しても何も起こりません。

スクリプトの説明

決定とキャンセルのActionは、InputActionProperty構造体としてインスペクターから編集可能にしています。

// 決定Action
[SerializeField] private InputActionProperty _submitAction;

// キャンセルAction
[SerializeField] private InputActionProperty _cancelAction;

使用側では、まずキャンセル処理のための準備を行います。

// キャンセルのためのCancellationTokenSource
var cts = new CancellationTokenSource();

// キャンセル時の処理
void OnCancel(InputAction.CallbackContext context)
{
    // キャンセル要求
    cts.Cancel();
}

// キャンセル時の処理を登録
_cancelAction.action.performed += OnCancel;

CancellationTokenSourceの作成、およびキャンセルActionのコールバックが発火したときに、CancellationTokenSourceからキャンセル要求を出すようにします。

キャンセルを考慮した決定ボタン入力の待機は、try-catch-finallyブロックで実現しています。

try
{
    // 入力待機
    await _submitAction.action.OnPerformedAsync(cts.Token);
    print("決定ボタンが押された!");
}
catch (OperationCanceledException)
{
    // キャンセルされた場合の処理
    print("キャンセルされた!");
}
finally
{
    // キャンセル時の処理を解除
    _cancelAction.action.performed -= OnCancel;
    // CancellationTokenSourceを破棄
    cts.Dispose();
}

上記のコードが成り立つ理由は、キャンセル要求が来た時にOperationCanceledException例外がスローされるためです。

CancellationTokenSourceは最終的にDisposeメソッドで破棄する必要があります。また、キャンセルActionに登録したコールバックも解除したいので、finallyブロックでこれらの処理を行うようにしています。

started、performed、canceledコールバックをawaitする

同様の流れでperformed以外のstartedcanceledコールバックに対しても同様のawaitが実現できます。

拡張クラスの完成形

まず、本記事の要件を満たす完成形の拡張クラスを示します。

started、performed、canceledコールバックのほか、入力値の受取りにも対応しています。

InputActionAsyncExtensions.cs
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine.Events;
using UnityEngine.InputSystem;

public static class InputActionAsyncExtensions
{
    #region 戻り値あり

    /// <summary>
    /// InputActionのstartedコールバックが実行されるまで待機する
    /// </summary>
    public static UniTask<T> OnStartedAsync<T>(
        this InputAction inputAction,
        CancellationToken ct = default
    ) where T : struct
    {
        return OnCallbackAsync<T>(
            ct,
            callback => inputAction.started += callback,
            callback => inputAction.started -= callback
        );
    }

    /// <summary>
    /// InputActionのperformedコールバックが実行されるまで待機する
    /// </summary>
    public static UniTask<T> OnPerformedAsync<T>(
        this InputAction inputAction,
        CancellationToken ct = default
    ) where T : struct
    {
        return OnCallbackAsync<T>(
            ct,
            callback => inputAction.performed += callback,
            callback => inputAction.performed -= callback
        );
    }

    /// <summary>
    /// InputActionのcanceledコールバックが実行されるまで待機する
    /// </summary>
    public static UniTask<T> OnCanceledAsync<T>(
        this InputAction inputAction,
        CancellationToken ct = default
    ) where T : struct
    {
        return OnCallbackAsync<T>(
            ct,
            callback => inputAction.canceled += callback,
            callback => inputAction.canceled -= callback
        );
    }

    #endregion

    #region 戻り値なし

    /// <summary>
    /// InputActionのstartedコールバックが実行されるまで待機する
    /// </summary>
    public static UniTask OnStartedAsync(
        this InputAction inputAction,
        CancellationToken ct = default
    )
    {
        return OnCallbackAsync(
            ct,
            callback => inputAction.started += callback,
            callback => inputAction.started -= callback
        );
    }

    /// <summary>
    /// InputActionのperformedコールバックが実行されるまで待機する
    /// </summary>
    public static UniTask OnPerformedAsync(
        this InputAction inputAction,
        CancellationToken ct = default
    )
    {
        return OnCallbackAsync(
            ct,
            callback => inputAction.performed += callback,
            callback => inputAction.performed -= callback
        );
    }

    /// <summary>
    /// InputActionのcanceledコールバックが実行されるまで待機する
    /// </summary>
    public static UniTask OnCanceledAsync(
        this InputAction inputAction,
        CancellationToken ct = default
    )
    {
        return OnCallbackAsync(
            ct,
            callback => inputAction.canceled += callback,
            callback => inputAction.canceled -= callback
        );
    }

    #endregion

    #region 共通ロジック

    private static UniTask<T> OnCallbackAsync<T>(
        CancellationToken ct,
        UnityAction<Action<InputAction.CallbackContext>> onAdd,
        UnityAction<Action<InputAction.CallbackContext>> onRemove
    ) where T : struct
    {
        // performedが通知されたら結果を確定させるUniTaskCompletionSource
        var tcs = new UniTaskCompletionSource<T>();

        // コールバックを受け取るローカル関数
        void OnCallback(InputAction.CallbackContext context)
        {
            // コールバックを解除
            onRemove?.Invoke(OnCallback);
            // UniTaskCompletionSourceに結果を設定
            tcs.TrySetResult(context.ReadValue<T>());
        }

        // コールバックを登録
        onAdd?.Invoke(OnCallback);

        // CancellationTokenがキャンセルされたときの処理
        ct.Register(() =>
        {
            // コールバックを解除
            onRemove?.Invoke(OnCallback);
            // UniTaskCompletionSourceをキャンセル
            tcs.TrySetCanceled();
        });

        // await可能なUniTaskを返す
        return tcs.Task;
    }

    private static UniTask OnCallbackAsync(
        CancellationToken ct,
        UnityAction<Action<InputAction.CallbackContext>> onAdd,
        UnityAction<Action<InputAction.CallbackContext>> onRemove
    )
    {
        // performedが通知されたら結果を確定させるUniTaskCompletionSource
        var tcs = new UniTaskCompletionSource();

        // コールバックを受け取るローカル関数
        void OnCallback(InputAction.CallbackContext context)
        {
            // コールバックを解除
            onRemove?.Invoke(OnCallback);
            // UniTaskCompletionSourceに結果を設定
            tcs.TrySetResult();
        }

        // コールバックを登録
        onAdd?.Invoke(OnCallback);

        // CancellationTokenがキャンセルされたときの処理
        ct.Register(() =>
        {
            // コールバックを解除
            onRemove?.Invoke(OnCallback);
            // UniTaskCompletionSourceをキャンセル
            tcs.TrySetCanceled();
        });

        // await可能なUniTaskを返す
        return tcs.Task;
    }

    #endregion
}

上記をInputActionAsyncExtensions.csという名前でUnityプロジェクトに保存します。既に前述の例のInputActionAsyncExtensions.csが存在する場合は、上書きしてください。

使用例のスクリプト

上記拡張メソッドの使用例です。

UseExample2.cs
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.InputSystem;

public class UseExample2 : MonoBehaviour
{
    // 決定Action
    [SerializeField] private InputActionProperty _submitAction;

    // キャンセルAction
    [SerializeField] private InputActionProperty _cancelAction;

    // ボタンが押されたらログ出力する
    private async UniTaskVoid Start()
    {
        if (_submitAction.action == null || _cancelAction.action == null)
        {
            Debug.LogError("InputActionPropertyが設定されていません。");
            return;
        }

        // キャンセルのためのCancellationTokenSource
        var cts = new CancellationTokenSource();

        // キャンセル時の処理
        void OnCancel(InputAction.CallbackContext context)
        {
            // キャンセル要求
            cts.Cancel();
        }

        // キャンセル時の処理を登録
        _cancelAction.action.performed += OnCancel;

        try
        {
            // startedコールバックの入力を待機し、結果を取得
            var valueStarted = await _submitAction.action.OnStartedAsync<float>(cts.Token);
            print($"started - 入力値: {valueStarted}");
            
            // performedコールバックの入力を待機し、結果を取得
            var valuePerformed = await _submitAction.action.OnPerformedAsync<float>(cts.Token);
            print($"performed - 入力値: {valuePerformed}");
            
            // canceledコールバックの入力を待機し、結果を取得
            var valueCanceled = await _submitAction.action.OnCanceledAsync<float>(cts.Token);
            print($"canceled - 入力値: {valueCanceled}");
            
            print("決定ボタンが押された!");
        }
        catch (OperationCanceledException)
        {
            // キャンセルされた場合の処理
            print("キャンセルされた!");
        }
        finally
        {
            // キャンセル時の処理を解除
            _cancelAction.action.performed -= OnCancel;
            // CancellationTokenSourceを破棄
            cts.Dispose();
        }
    }

    private void OnDestroy()
    {
        // InputActionを破棄
        _submitAction.action?.Dispose();
        _cancelAction.action?.Dispose();
    }

    private void OnEnable()
    {
        // InputActionを有効化
        _submitAction.action?.Enable();
        _cancelAction.action?.Enable();
    }

    private void OnDisable()
    {
        // InputActionを無効化
        _submitAction.action?.Disable();
        _cancelAction.action?.Disable();
    }
}

上記をUseExample2.csという名前でUnityプロジェクトに保存し、ゲームオブジェクトにアタッチし、インスペクターより各種Actionを設定してください。

実行結果

started、performed、canceledコールバックが順番に呼ばれ、受け取った入力値がログ出力されていることが確認できます。

スクリプトの説明

使用例で異なる部分は以下コードです。

// startedコールバックの入力を待機し、結果を取得
var valueStarted = await _submitAction.action.OnStartedAsync<float>(cts.Token);
print($"started - 入力値: {valueStarted}");

// performedコールバックの入力を待機し、結果を取得
var valuePerformed = await _submitAction.action.OnPerformedAsync<float>(cts.Token);
print($"performed - 入力値: {valuePerformed}");

// canceledコールバックの入力を待機し、結果を取得
var valueCanceled = await _submitAction.action.OnCanceledAsync<float>(cts.Token);
print($"canceled - 入力値: {valueCanceled}");

started、performed、canceledコールバックの順に待機し、その入力値を受け取っています。

拡張クラス側では、次のように入力値を返す版返さない版の拡張メソッドをそれぞれ実装しています。

public static UniTask<T> OnPerformedAsync<T>(
    this InputAction inputAction,
    CancellationToken ct = default
) where T : struct
{
    return OnCallbackAsync<T>(
        ct,
        callback => inputAction.performed += callback,
        callback => inputAction.performed -= callback
    );
}

・・・(中略)・・・

public static UniTask OnPerformedAsync(
    this InputAction inputAction,
    CancellationToken ct = default
)
{
    return OnCallbackAsync(
        ct,
        callback => inputAction.performed += callback,
        callback => inputAction.performed -= callback
    );
}

3種類のコールバックに対する処理は共通化できるため、OnCallbackAsyncメソッドにまとめています。

performedプロパティなどに対してコールバックを登録・解除する処理ですが、このプロパティはgetをサポートしていないため、次のようにするとコンパイルエラーになってしまいます。

return OnCallbackAsync(
    ct,
    inputAction.performed // ここでコンパイルエラーになる
);

そのため、遠回りに感じるかもしれませんが、それぞれ登録と解除のメソッドを共通メソッドに渡す必要があります。

参考:Class InputAction | Input System | 1.5.1

共通メソッドは、以下のようにコールバック登録と解除をそれぞれonAdd、onRemoveのdelegate呼び出しという形で実装しています。

private static UniTask<T> OnCallbackAsync<T>(
    CancellationToken ct,
    UnityAction<Action<InputAction.CallbackContext>> onAdd,
    UnityAction<Action<InputAction.CallbackContext>> onRemove
) where T : struct
{
    // performedが通知されたら結果を確定させるUniTaskCompletionSource
    var tcs = new UniTaskCompletionSource<T>();

    // コールバックを受け取るローカル関数
    void OnCallback(InputAction.CallbackContext context)
    {
        // コールバックを解除
        onRemove?.Invoke(OnCallback);
        // UniTaskCompletionSourceに結果を設定
        tcs.TrySetResult(context.ReadValue<T>());
    }

    // コールバックを登録
    onAdd?.Invoke(OnCallback);

    // CancellationTokenがキャンセルされたときの処理
    ct.Register(() =>
    {
        // コールバックを解除
        onRemove?.Invoke(OnCallback);
        // UniTaskCompletionSourceをキャンセル
        tcs.TrySetCanceled();
    });

    // await可能なUniTaskを返す
    return tcs.Task;
}

それ以外の処理は、前述の拡張メソッドの例と一緒です。

さいごに

Input SystemのActionから受け取るコールバックは、UniTaskCompletionSourceを使うと比較的スムーズに実装できます。

拡張クラスや拡張メソッドを自分で実装しないといけない点は面倒に感じるかもしれませんが、一度実装してしまえば使いまわしが効いて便利になるでしょう。

CancellationTokenによる入力待機の中断も実現できることを示しました。キャンセルボタンによる中断のほか、画面遷移など何らかの要因で処理を中止せざるを得ない場面でも活用できるでしょう。

関連記事

参考サイト

スポンサーリンク