Unityでのボタンの入力ブロッキングについて

[サムザップ #1 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/sumzap1) の12/16の記事です。

#はじめに
ボタン入力は、画面遷移や通信など何らかの処理が始まるトリガーの一つになります。
アプリケーションの機能が増えると自ずとボタンの数も増えてきて、それらの入力を適切に制御する仕組みが必要になってきます。
最近、Unityを使ったゲーム作りにおいてボタンの入力制御について考える機会があったので、本記事にまとめていきたいと思います。
あらゆるケースに対応できるメソッドとは言えないですが、参考になることが少しでもあれば幸いです。なお、ここではasync/awaitによる非同期処理を使って話を進めています。

#ボタンの入力ブロッキング
本記事では、以下の３つのボタンの入力ブロッキングについて触れていきたいと思います。

- １つのボタンの連打防止
- 複数のボタンのマルチタップ防止
- 複数のボタンの連打防止

#１つのボタンの連打防止
まずは、１つのボタンが短い時間に連続して押されてもコールバック処理がその都度走ってしまわないようにするための対応を考えてみます。
クリックを受け付けるかどうかのフラグなどを使って個別に対応することもできますが、今回はボタンのベースクラスに入力の制御を入れるなどで全体的に対応する方法を考えてみたいと思います。
###【方法１】ある閾値時間より短い時間内に行われた入力は無効にする
例えば、次のようなボタンクラスを見てみます。

```csharp

public class ButtonBase : UIBehavior
{
    /// <summary>
    /// クリック時のコールバック
    /// </summary>
    public Action onClickCallback;

    // クリックされてから次のクリックを受け付けるまでの秒数
    private const float ClickTimeSpanThreshould = 0.05f;
    // ボタンのクリックを受け付けるかどうか
    private bool _clickable = true;
    // カウンタ
    private float _timer = 0f;
    
    private void Awake()
    {
        // 注意：　UIGestureは、EventSystemの各種イベントハンドラを実装したクラス
        var gesture = GetComponent<UIGesture>();
        if(gesture == null) gesture = gameObject.AddComponent<UIGesture>();
        gesture.onClickedCallback += OnClickedHandler;
    }
    
    private void OnClickedHandler()
    {
        if(_clickable == false)
        {
            return;
        }

        _clickable = false;

        // クリックされた時の処理を実行
        onClickCallback?.Invoke();
    }
    
    private void Update()
    {
        if(_clickable == false)
        {
            _timer += Time.deltaTime;
            if(_timer > ClickTimeSpanThreshould)
            {
                _clickable = true;
                _timer = 0f;
            }
        }
    }
}
```
※　UIGestureは、EventSystemのIPointerClickHandlerなどの各種イベントハンドラを実装したクラスで、EventTrigger.OnPointerClickメソッド内で実行されるonClickedCallbackというコールバックを公開しているものとします。

この方法は非常にシンプルですが、大きな問題があります。
それは、閾値時間のチューニングです。クリックされた時の処理にかかる時間は、処理内容によっても内容が同じであったとしても例えばそれが通信処理であればその時々で変わってくるので、一律に決めるのはほぼ不可能です。

###【方法２】コールバックを非同期にして待機し、待機中に行われた入力は無効にする
という訳で、コールバックを非同期にしてコールバックの処理が完了してからクリックを受け付けるようにしてみます。

```csharp

public class ButtonBase : UIBehavior
{
    /// <summary>
    /// クリック時の非同期コールバック
    /// </summary>
    public Func<Task> onClickAsync;

　　// ボタンのクリックを受け付けるかどうか
    private bool _clickable = true;
    
    private void Awake()
    {
        // 注意：　UIGestureは、EventSystemの各種イベントハンドラを実装したクラス
        var gesture = GetComponent<UIGesture>();
        if(gesture == null) gesture = gameObject.AddComponent<UIGesture>();
        gesture.onClickedCallback += OnClickedHandler;
    }

    private void OnClickedHandler()
    {
        if(_clickable == false)
        {
            return;
        }
        
        _Execute().Forget();
        
        async Task _Execute()
        {
            try
            {
                _clickable = false;

                // クリックされた時の処理を実行
                await onClickAsync?.Invoke();
            }
            finally
            {
                _clickable = true;
            }
        }
    }
}

public static class TaskExtensions
{
    /// <summary>
    /// タスクの完了を待たないことを明示的にする
    /// 例外をログに出すためのもの
    /// </summary>
    public static async void Forget(this Task task)
    {
        try
        {
            await task.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
        }
        catch (Exception e)
        {
            UnityEngine.Debug.LogError(e);
        }
    }
}
```
この方法では、クリックされた時の処理にかかる時間によらず、コールバックの処理中に同じコールバックの処理が走ることは無くなります。１つ注意することとしては、非同期コールバックが正常に完了した場合も何らかの理由で中断された場合でも、クリック可能かどうかのフラグをリセットしておくことです。
実は、この非同期コールバックで入力を制限する方法ですが、Windows Presentation Foundation(WPF)というデスクトップアプリケーションを作成するUIフレームワークを使った例と共に[こちらの記事](https://docs.microsoft.com/ja-jp/archive/msdn-magazine/2014/april/async-programming-patterns-for-asynchronous-mvvm-applications-commands)でも似たような方法が示されています。
ただ、Unityプロジェクトでは、クリックのコールバックは同期メソッドとなっていることが多いので、途中からこの方法に乗り換えるというのは、なかなか難しいのではないかと思います。

#複数のボタンのマルチタップ防止
多くの場合、アウトゲームでは複数ボタンのマルチタップを許可することはないかと思います。
マルチタップを一括で無効にするには、以下のコードを仕込めばよいです。

```csharp

Input.multiTouchEnabled = false;
```
もし、一部のボタンではマルチタップを有効にしたい場合は、
IPointerClickHandlerを実装して、OnPointerClickメソッドの引数として受け取るPointerEventDataのpointerIdの値を元にマルチタップを無効にするものとしないものをそれぞれ用意するなどになるかと思います。

#複数のボタンの連打防止
１つのボタンの連打防止の【方法１】と【方法２】で挙げた例では、ある１つのボタンのクリックの連打は防げたが、別のボタンが即座にクリックされた場合、１つ目のボタンがクリックされた時の処理が完了していなくても２つ目のボタンクリック時の処理が走り出してしまうという問題があります。
これを解決するためには、ボタンのクリックを受け付けるかどうかのフラグ_clickableをstaticな変数にしてしまうのも手かもしれないですが、共通の状態を持たせることになり、どのタイミングでその状態が変わるかを追い辛くなってしまいそうです。
そこで、もちろん全てのケースで適用できるとはいえないかと思いますが、例えば次のようにクリックコールバックの処理の前後で入力をブロッキングする透明な板をOn/Offするという方法が考えられます。
ここでも、１つのボタンの連打防止の【方法２】の時のように、コールバックのタスクがキャンセルされたり失敗したりした場合でもボタンを再び押せるようにしています。

```csharp

public class ButtonBase : UIBehavior
{
    /// <summary>
    /// 非同期クリック前のアクション
    /// </summary>
    public static Action onBeforeClick;

    /// <summary>
    /// 非同期クリック後のアクション
    /// </summary>
    public static Action onAfterClick;

    /// <summary>
    /// クリック時の非同期コールバック
    /// </summary>
    public Func<Task> onClickAsync;

    private void Awake()
    {
        // 注意：　UIGestureは、EventSystemの各種イベントハンドラを実装したクラス
        var gesture = GetComponent<UIGesture>();
        if(gesture == null) gesture = gameObject.AddComponent<UIGesture>();
        gesture.onClickedCallback += OnClickedHandler;
    }

    private void OnClickedHandler()
    {
        _Execute().Forget();
        
        async Task _Execute()
        {
            try
            {
                // ここで入力ブロッキングOn
                onBeforeClick?.Invoke();

                // クリックされた時の処理を実行
                await onClickAsync?.Invoke();
            }
            finally
            {
                // ここで入力ブロッキングOff
                onAfterClick?.Invoke();
            }
        }
    }
}
```
#まとめ
ボタンの入力ブロッキングを実現する方法は他にも色々とあるかと思いますが、この辺りのことは開発が始まったらすぐにどういう方針で実装していくかをしっかりと固めておくことが重要だと改めて思いました。
また、今回はuGUIのボタンしか扱いませんでしたが、モバイルゲームの場合はAndroid端末のバックキーの連打防止についても考えておかなければなりません。

#最後に

明日は @TomofumiIwasaki さんの記事です。