【Unity】Input SystemのInteractive RebindingをUniTaskで実装する

こじゃらこじゃら

Input Systemでキーコンフィグを作っていて、コントローラーの入力を待つ処理をasync/awaitで実装したいの。

このはこのは

UniTaskを使ってこの辺を実装していく方法を解説していくね。

Input Systemのキーコンフィグでユーザーからの入力を待つような処理を実装する場合、次のようにInteractive Rebindingで実装する形になるでしょう。

InputActionRebindingExtensions.RebindingOperation rebindOperation;
InputAction action;
int bindingIndex = なんらかの方法でBindingのインデックスを決定;

・・・(中略)・・・

rebindOperation = action
    .PerformInteractiveRebinding(bindingIndex)
    .OnComplete(_ =>
    {
        // リバインドが完了した時の処理
    })
    .OnCancel(_ =>
    {
        // リバインドがキャンセルされた時の処理
    })
    .Start(); // ここでリバインドを開始する

参考:Class InputActionRebindingExtensions.RebindingOperation | Input System | 1.6.3

このように、ユーザーからの入力決定やキャンセルなどはコールバックとして通知を受け取る設計になっています。特にこのような非同期処理をシーケンシャルに書きたい場合async/awaitで実装したほうコードの見通しが良くなる場合があります。

本記事では、UniTaskを用いて次のようにasync/await構文でInteractive Rebindingを扱えるようにする方法を解説します。

// awaitで入力があるまで待機
await action.PerformInteractiveRebindingAsync(bindingIndex);

また、Interactive RebindingのキャンセルキーCancellationTokenによるキャンセルに対応させるものとします。

// キャンセルBindingの指定
await action.PerformInteractiveRebindingAsync(bindingIndex, "<keyboard>/escape");

// CancellationTokenの指定
CancellationToken cts = this.GetCancellationTokenOnDestroy();
await action.PerformInteractiveRebindingAsync(bindingIndex, cts);
動作環境
  • Unity 2023.1.8f1
  • Input System 1.6.3

スポンサーリンク

前提条件

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

ここまでの手順が分からない方は、以下記事を参考にセットアップを進めてください。

また、UniTaskをインストールしているものとします。インストール方法は幾つか存在しますが、UPM経由でインストールすることが出来ます。

UniTaskのインストールがまだの方は、以下手順でインストールを実施してください。

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.

本記事の解説は、Interactive Rebindingによるキーコンフィグの応用編となるため、キーコンフィグの実装方法の基本を押さえておくと理解がスムーズです。

Interactive Rebindingによるキーコンフィグの実装方法は以下記事で解説しています。

コールバックをawaitに置き換える流れ

UniTaskのUniTaskCompletionSourceクラスを用いてコールバックを置き換えます。

参考:UniTaskCompletionSource Class | UniTask

まず、次のように待機の開始前などでインスタンス化しておきます。

var completionSource = new UniTaskCompletionSource();

そして、Interactive Rebindingを実行する時に、OnCompleteOnCancelコールバックでUniTaskCompletionSourceインスタンスに対して結果の確定やキャンセルを通知します。

var rebindOperation = action.PerformInteractiveRebinding(bindingIndex)
    .OnCancel(_ =>
    {
        // UniTaskCompletionSourceにキャンセルを通知
        completionSource.TrySetCanceled();
    })
    .OnComplete(_ =>
    {
        // UniTaskCompletionSourceの結果を確定
        completionSource.TrySetResult();
    })
    .Start();

TrySetResultメソッド結果の確定TrySetCanceledメソッドキャンセル通知を行います。

参考:UniTaskCompletionSource Class | UniTask

したがって、拡張メソッドとして実装すると、次のようなコードになります。キャンセル処理については後述します。

public static UniTask PerformInteractiveRebindingAsync(
    this InputAction action,
    int bindingIndex
)
{
    // オペレーションを予め作成しておく
    var rebindOperation = action.PerformInteractiveRebinding(bindingIndex);

    var completionSource = new UniTaskCompletionSource();

    // インタラクティブなリバインドを開始
    rebindOperation
        .OnCancel(_ =>
        {
            // UniTaskCompletionSourceにキャンセルを通知
            completionSource.TrySetCanceled();

            // オペレーションを作成したら、Disposeしないとメモリリークする
            rebindOperation.Dispose();
            rebindOperation = null;
        })
        .OnComplete(_ =>
        {
            // UniTaskCompletionSourceの結果を確定
            completionSource.TrySetResult();

            // オペレーションを作成したら、Disposeしないとメモリリークする
            rebindOperation.Dispose();
            rebindOperation = null;
        })
        .Start();

    // リバインドが完了するまで待機するTaskを返す
    return completionSource.Task;
}

キャンセルキーへの対応

Interactive Rebindingでは、特定入力があったらリバインドをキャンセルさせる挙動が実現可能です。

前述の拡張メソッドに対して、次のように引数と設定を追加すれば良いです。

public static UniTask PerformInteractiveRebindingAsync(
    this InputAction action,
    int bindingIndex,
    string cancelBinding // キャンセル用のBindingを引数に追加
)
{
    // オペレーションを予め作成しておく
    var rebindOperation = action.PerformInteractiveRebinding(bindingIndex);

    // キャンセル用のBindingを設定
    if (!string.IsNullOrEmpty(cancelBinding))
        rebindOperation.WithCancelingThrough(cancelBinding);

WithCancelingThrough拡張メソッドキャンセル用のControl Pathを指定すれば、このControl Pathの入力があった時にキャンセル扱いになります。

参考:Class InputActionRebindingExtensions.RebindingOperation | Input System | 1.6.3

CancellationTokenへの対応

Interactive Rebindingのキャンセルに加えて、例えば画面遷移などその他の要因でキャンセルさせたい場合CancellationTokenに対応させる必要があります。

これを行いたい場合、次のようにCancellationToken構造体を引数に追加し、CancellationToken.Registerメソッドでキャンセル要求が来た時にInteractive Rebindingをキャンセル(Cancelメソッド実行)させるようにすれば良いです。

public static UniTask PerformInteractiveRebindingAsync(
    this InputAction action,
    int bindingIndex,
    string cancelBinding,
    CancellationToken ct = default // CancellationTokenを引数に追加
)
{

・・・(中略)・・・

    // CancellationTokenがキャンセルされたら、リバインドをキャンセルさせる
    ct.Register(rebindOperation.Cancel);

    // リバインドが完了するまで待機するTaskを返す
    return completionSource.Task;
}

InputActionクラスの拡張メソッドとして実装する

ここまでの流れを踏まえ、Interactive RebindingをUniTaskに変換するInputActionクラスの拡張メソッドの完成形の例を示します。

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

public static class InputActionExtensions
{
    /// <summary>
    /// インタラクティブなリバインドを開始する
    /// </summary>
    /// <param name="action">リバインド対象のAction</param>
    /// <param name="bindingIndex">Bindingのインデックス</param>
    /// <param name="ct">CancellationToken</param>
    public static UniTask PerformInteractiveRebindingAsync(
        this InputAction action,
        int bindingIndex,
        CancellationToken ct = default
    )
    {
        return PerformInteractiveRebindingAsync(action, bindingIndex, "", ct);
    }

    /// <summary>
    /// インタラクティブなリバインドを開始する(キャンセルキーあり)
    /// </summary>
    /// <param name="action">リバインド対象のAction</param>
    /// <param name="bindingIndex">Bindingのインデックス</param>
    /// <param name="cancelBinding">キャンセルキー</param>
    /// <param name="ct">CancellationToken</param>
    /// <returns></returns>
    public static UniTask PerformInteractiveRebindingAsync(
        this InputAction action,
        int bindingIndex,
        string cancelBinding,
        CancellationToken ct = default
    )
    {
        // リバインド中はInputActionをDisableにする
        action.Disable();

        var rebindOperation = action.PerformInteractiveRebinding(bindingIndex);

        // キャンセル用のBindingを設定
        if (!string.IsNullOrEmpty(cancelBinding))
            rebindOperation.WithCancelingThrough(cancelBinding);

        // リバインドオペレーションを破棄するローカル関数
        void CleanUpOperation()
        {
            // オペレーションを作成したら、Disposeしないとメモリリークする
            rebindOperation.Dispose();
            rebindOperation = null;

            // リバインドが完了したらInputActionをEnableにする
            action.Enable();
        }

        var completionSource = new UniTaskCompletionSource();

        // インタラクティブなリバインドを開始
        rebindOperation
            .OnCancel(_ =>
            {
                // UniTaskCompletionSourceにキャンセルを通知
                completionSource.TrySetCanceled();
                CleanUpOperation();
            })
            .OnComplete(_ =>
            {
                // UniTaskCompletionSourceの結果を確定
                completionSource.TrySetResult();
                CleanUpOperation();
            })
            .Start();

        // CancellationTokenがキャンセルされたら、リバインドをキャンセルさせる
        ct.Register(rebindOperation.Cancel);

        return completionSource.Task;
    }
}

上記をInputActionExtensions.csとしてUnityプロジェクトに保存すると、拡張メソッドが使えるようになります。

使用例

以下、実際の使用例です。StartRebindingメソッドをボタン押下時など外部から呼び出すことでInteractive Rebindingを開始できるようにしています。

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

public class AwaitExample : MonoBehaviour
{
    // リバインド対象のInputAction
    [SerializeField] private InputActionProperty _targetAction;

    // リバインド対象のBinding
    [SerializeField] private int _bindingIndex = 0;

    // キャンセル用のControl Path
    [SerializeField] private string _cancelBinding = "<keyboard>/escape";

    // リバインド中に表示するマスク
    [SerializeField] private GameObject _mask;

    // タイムアウト時間[s]
    [SerializeField] private float _timeout = 5f;

    private InputAction _action;
    private CancellationTokenSource _cts;

    /// <summary>
    /// リバインドを開始する
    /// </summary>
    public void StartRebinding()
    {
        if (_cts != null) return;

        StartRebindingAsync().Forget();
    }

    private void Awake()
    {
        if (_targetAction.action == null) return;

        _action = _targetAction.action;
        _action.performed += OnPerformed;

        // マスクは最初非表示にしておく
        if (_mask != null)
            _mask.SetActive(false);
    }

    private void OnDestroy()
    {
        // キャンセル用のCancellationTokenSourceを破棄
        if (_cts != null)
        {
            _cts.Cancel();
            _cts.Dispose();
        }

        // InputActionを破棄
        if (_action != null)
        {
            _action.performed -= OnPerformed;
            _action.Dispose();
        }
    }

    private void OnEnable()
    {
        _action?.Enable();
    }

    private void OnDisable()
    {
        _action?.Disable();
    }

    private async UniTaskVoid StartRebindingAsync()
    {
        if (_action == null) return;

        // マスクを表示
        if (_mask != null)
            _mask.SetActive(true);

        // タイムアウトのCancellationTokenSourceを作成
        _cts = new CancellationTokenSource();
        var timeout = _cts.CancelAfterSlim(TimeSpan.FromSeconds(_timeout));

        try
        {
            // リバインドを開始する
            await _action.PerformInteractiveRebindingAsync(_bindingIndex, _cancelBinding, _cts.Token);

            print("リバインド完了!");
        }
        catch (OperationCanceledException)
        {
            // キャンセルされた場合はリバインド中断とみなす
            print("キャンセルされた!");
            throw;
        }
        finally
        {
            // マスクを非表示
            if (_mask != null)
                _mask.SetActive(false);

            // キャンセル用のCancellationTokenSourceを破棄
            _cts.Dispose();
            _cts = null;
            
            // タイムアウト処理を中断
            timeout?.Dispose();
        }
    }

    private void OnPerformed(InputAction.CallbackContext context)
    {
        print("Performed!");
    }
}

上記をAwaitExample.csという名前でUnityプロジェクトに保存し、適当なゲームオブジェクトにアタッチし、インスペクターより各種パラメータの設定を行います。

例では、Actionにキーボードの「1」キーがデフォルトで割り当てられ、キャンセルキーに「Esc」キーが割り当てられ、リバインド中に表示するマスクにオーバーレイの画像を指定し、タイムアウト時間を5秒に設定することとします。

また、UIのボタンなどが押されたときにStartRebindingメソッドを実行させることとします。

実行結果

ボタン押下でInteractive Rebindingを行うと、キーが変更されていることが確認できます。

CancellationTokenでタイムアウトを設定していますが、こちらも機能するようになっています。

スクリプトの説明

CancellationTokenによるInteractive Rebindingのキャンセルを行えるようにするため、CancellationTokenSourceをフィールドとして定義します。

private CancellationTokenSource _cts;

これは、ゲームオブジェクトが破棄されたタイミングなどでキャンセル可能にするためです。

private void OnDestroy()
{
    // キャンセル用のCancellationTokenSourceを破棄
    if (_cts != null)
    {
        _cts.Cancel();
        _cts.Dispose();
    }

    // InputActionを破棄
    if (_action != null)
    {
        _action.performed -= OnPerformed;
        _action.Dispose();
    }
}

Interactive Rebindingの開始は、次のようにpublicメソッドを公開し、外部から呼び出せるような形にしました。

/// <summary>
/// リバインドを開始する
/// </summary>
public void StartRebinding()
{
    if (_cts != null) return;

    StartRebindingAsync().Forget();
}

Interactive Rebindingの処理の開始時点では、次のコードでマスク用画像を表示したり、CancellationTokenSourceインスタンスを作成したりしています。

private async UniTaskVoid StartRebindingAsync()
{
    if (_action == null) return;

    // マスクを表示
    if (_mask != null)
        _mask.SetActive(true);

    // タイムアウトのCancellationTokenSourceを作成
    _cts = new CancellationTokenSource();
    var timeout = _cts.CancelAfterSlim(TimeSpan.FromSeconds(_timeout));

タイムアウト値は、CancellationTokenSource.CancelAfterSlimメソッドで指定しています。

参考:CancellationTokenSourceExtensions Class | UniTask

そして、次のようにawaitでInteractive Rebindingを待機しています。

try
{
    // リバインドを開始する
    await _action.PerformInteractiveRebindingAsync(_bindingIndex, _cancelBinding, _cts.Token);

    print("リバインド完了!");
}
catch (OperationCanceledException)
{
    // キャンセルされた場合はリバインド中断とみなす
    print("キャンセルされた!");
    throw;
}
finally
{
    // マスクを非表示
    if (_mask != null)
        _mask.SetActive(false);

    // キャンセル用のCancellationTokenSourceを破棄
    _cts.Dispose();
    _cts = null;

    // タイムアウト処理を中断
    timeout?.Dispose();
}

キャンセルキーが押されるか、タイムアウトになると非同期処理はキャンセル扱いとなり、OperationCanceledExceptionがスローされるため、ここで決定かキャンセルかを判断しています。

処理が一通り終わったら、finallyブロックでマスク表示を消したりCancellationTokenSourceを破棄したりしています。

注意

CancellationTokenSource.CancelAfterメソッドを使ってタイムアウトを実装する場合、メインスレッド以外から実行されるため、Unityのゲームオブジェクトなどにアクセスする際は次のようにメインスレッドに戻す必要があります。

// タイムアウトされたときは別スレッドなので、メインスレッドに戻す
await UniTask.SwitchToMainThread();

また、CancellationTokenSourceはIDisposableインタフェースを実装しているため、Disposeを忘れずに行う必要があります。

// キャンセル用のCancellationTokenSourceを破棄
_cts.Dispose();
_cts = null;

RebindingOperationクラスの拡張メソッドとして実装する

ここまで解説した方法は、InputActionクラスの拡張メソッドとして実装していました。

しかし、この方法ではRebindingOperationに対して次のように自由にメソッドチェイン出来ないといった欠点が存在します。

rebindOperation = action
    .PerformInteractiveRebinding(bindingIndex)
    .OnComplete(_ =>
    {
        // リバインドが完了した時の処理
    })
    .OnCancel(_ =>
    {
        // リバインドがキャンセルされた時の処理
    })
    // キャンセルキーを設定する
    .WithCancelingThrough("<Keyboard>/escape")
    .OnMatchWaitForAnother(0.2f) // 次のリバインドまでの待機時間を設ける
    .Start(); // ここでリバインドを開始する

この場合、呼び元でAction無効化などの準備処理が増えますが、RebindingOperationに対する拡張メソッドとして実装すれば解決できます。

InputAction action;

・・・(中略)・・・

// リバインド中はInputActionをDisableにする
action.Disable();

// Interactive Rebindingを開始
// StartAsyncがRebindingOperationの拡張メソッド
await action
    .PerformInteractiveRebinding(0)
    .WithCancelingThrough("<keyboard>/escape")
    .StartAsync();

完成形の拡張メソッドは以下の通りです。

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

public static class RebindingOperationExtensions
{
    /// <summary>
    /// インタラクティブなリバインドを開始する
    /// </summary>
    /// <param name="rebindOperation">Interactive Rebindingのオペレーション</param>
    /// <param name="ct">CancellationToken</param>
    public static UniTask StartAsync(
        this InputActionRebindingExtensions.RebindingOperation rebindOperation,
        CancellationToken ct = default
    )
    {
        // リバインドオペレーションを破棄するローカル関数
        void CleanUpOperation()
        {
            // オペレーションを作成したら、Disposeしないとメモリリークする
            rebindOperation.Dispose();
            rebindOperation = null;
        }

        // インタラクティブなリバインドを開始
        var completionSource = new UniTaskCompletionSource();

        rebindOperation
            .OnCancel(_ =>
            {
                // UniTaskCompletionSourceにキャンセルを通知
                completionSource.TrySetCanceled();
                CleanUpOperation();
            })
            .OnComplete(_ =>
            {
                // UniTaskCompletionSourceの結果を確定
                completionSource.TrySetResult();
                CleanUpOperation();
            })
            .Start();

        // CancellationTokenがキャンセルされたら、リバインドをキャンセルさせる
        ct.Register(rebindOperation.Cancel);

        return completionSource.Task;
    }
}

上記をRebindingOperationExtensions.csという名前でUnityプロジェクトに保存すれが拡張メソッドが使用可能になります。

使用例

以下、RebindingOperationの拡張メソッドを使用するように書き換えた例です。

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

public class AwaitExample2 : MonoBehaviour
{
        // リバインド対象のInputAction
    [SerializeField] private InputActionProperty _targetAction;

    // リバインド対象のBinding
    [SerializeField] private int _bindingIndex = 0;

    // キャンセル用のControl Path
    [SerializeField] private string _cancelBinding = "<keyboard>/escape";

    // リバインド中に表示するマスク
    [SerializeField] private GameObject _mask;

    // タイムアウト時間[s]
    [SerializeField] private float _timeout = 5f;

    private InputAction _action;
    private CancellationTokenSource _cts;

    /// <summary>
    /// リバインドを開始する
    /// </summary>
    public void StartRebinding()
    {
        if (_cts != null) return;

        StartRebindingAsync().Forget();
    }

    private void Awake()
    {
        if (_targetAction.action == null) return;

        _action = _targetAction.action;
        _action.performed += OnPerformed;

        // マスクは最初非表示にしておく
        if (_mask != null)
            _mask.SetActive(false);
    }

    private void OnDestroy()
    {
        // キャンセル用のCancellationTokenSourceを破棄
        if (_cts != null)
        {
            _cts.Cancel();
            _cts.Dispose();
        }

        // InputActionを破棄
        if (_action != null)
        {
            _action.performed -= OnPerformed;
            _action.Dispose();
        }
    }

    private void OnEnable()
    {
        _action?.Enable();
    }

    private void OnDisable()
    {
        _action?.Disable();
    }

    private async UniTaskVoid StartRebindingAsync()
    {
        if (_action == null) return;

        // マスクを表示
        if (_mask != null)
            _mask.SetActive(true);

        // タイムアウトのCancellationTokenSourceを作成
        _cts = new CancellationTokenSource();
        var timeout = _cts.CancelAfterSlim(TimeSpan.FromSeconds(_timeout));

        try
        {
            // リバインド中はInputActionをDisableにする
            _action.Disable();
            
            // リバインドを開始する
            await _action
                .PerformInteractiveRebinding(_bindingIndex)
                .WithCancelingThrough(_cancelBinding)
                .StartAsync(_cts.Token);

            print("リバインド完了!");
        }
        catch (OperationCanceledException)
        {
            // キャンセルされた場合はリバインド中断とみなす
            print("キャンセルされた!");
            throw;
        }
        finally
        {
            // マスクを非表示
            if (_mask != null)
                _mask.SetActive(false);

            // リバインドが完了したらInputActionを有効化
            _action.Enable();

            // キャンセル用のCancellationTokenSourceを破棄
            _cts.Dispose();
            _cts = null;
            
            // タイムアウト処理を中断
            timeout?.Dispose();
        }
    }

    private void OnPerformed(InputAction.CallbackContext context)
    {
        print("Performed!");
    }
}

使用方法は一つ目の使用例と一緒です。実行結果も一緒のため割愛します。

スクリプトの説明

主な相違点は以下部分です。

// リバインド中はInputActionをDisableにする
_action.Disable();

// リバインドを開始する
await _action
    .PerformInteractiveRebinding(_bindingIndex)
    .WithCancelingThrough(_cancelBinding)
    .StartAsync(_cts.Token);

PerformInteractiveRebindingメソッドは、InputActionRebindingExtensionsクラスが提供する拡張メソッドです。

public static InputActionRebindingExtensions.RebindingOperation PerformInteractiveRebinding(
    this InputAction action,
    int bindingIndex = -1
)

参考:Class InputActionRebindingExtensions | Input System | 1.6.3

PerformInteractiveRebindingメソッドを呼ぶときは、対象のInputActionが無効化されている必要があります。

Interactive Rebindingが完了したら、InputActionが無効化されたままなので、次の処理で有効化しています。

// リバインドが完了したらInputActionを有効化
_action.Enable();

RebindingOperationインスタンスは最終的にDisposeメソッドで破棄する必要がありますが、これは独自実装したStartAsync拡張メソッド内で行うようにしたため不要です。

// リバインドオペレーションを破棄するローカル関数
void CleanUpOperation()
{
    // オペレーションを作成したら、Disposeしないとメモリリークする
    rebindOperation.Dispose();
    rebindOperation = null;
}

この辺は開発するアプリケーションや状況に応じて設計してください。

さいごに

Input Systemのキーコンフィグでユーザー入力を待機するInteractive Rebindingは、コールバックで結果を非同期的に受け取るものですが、これはUniTaskのasync/awaitに変換できます。

拡張メソッドを実装する必要がありますが、一度実装したら使いまわしが効いて便利になるでしょう。

CancellationTokenやInteractive Rebindingそのもののキャンセルにも対応できることも示しました。

関連記事

参考サイト

スポンサーリンク