【Unity】 ボタンなどの長押しと短押しを区別して判定する

# やりたいこと

ボタンなどの操作のうち、「長押し」と「短押し」を区別して判定したいときにどうすればいいかの紹介です。


## 仕様

* 長押しは「一定時間ボタンを押し続けていたら発動」とする
* 短押しは「長押し発動前にボタンを離したときに発動」とする

# 実装方法

## コルーチン

まずコルーチンで実装する方法です。
毎フレーム、入力値をチェックして経過時間をカウントしています。


```cs
using System.Collections;
using UnityEngine;

namespace Sample
{
    public class CoroutineCheck : MonoBehaviour
    {
        // ボタンの入力状態
        private bool IsButton => Input.GetKey(KeyCode.A);

        // 長押しと判定されるまでの時間[秒]
        private float LongPressThreshold => 1.0f;

        void Start()
        {
            // コルーチンの起動
            StartCoroutine(CheckCoroutine());
        }

        private IEnumerator CheckCoroutine()
        {
            var isLongPressFired = false; // 長押し検知済みかどうか
            var pressSeconds = 0.0f; // ボタンが押されている時間
            var lastValue = IsButton; // 1F前の状態

            while (true)
            {
                if (IsButton)
                {
                    // 一度長押しが検知されたらボタンが離されるまで何もしない
                    if (!isLongPressFired)
                    {
                        // ボタンが押されていないかつ、まだ発火していない場合
                        // 押された時間を記録し、しきい値を超えたら発火
                        pressSeconds += Time.deltaTime;
                        if (pressSeconds >= LongPressThreshold)
                        {
                            OnLongPress();
                            isLongPressFired = true;
                        }
                    }
                }
                // IsButton=false && lastValue=true
                // すなわち「ボタンがこの瞬間に離された」を検知
                else if (lastValue)
                {
                    if (pressSeconds < LongPressThreshold)
                    {
                        // LongPressThreshold未満で離された場合は短押し
                        OnShortPress();
                    }

                    isLongPressFired = false;
                    pressSeconds = 0.0f;
                }

                lastValue = IsButton;
                yield return null;
            }
        }

        private void OnLongPress()
        {
            Debug.Log("Long Pressed");
        }

        private void OnShortPress()
        {
            Debug.Log("Short Pressed");
        }
    }
}
```

## async/await + UniTask

`async/await` + `UniTask` を併用する場合。
コルーチンと文法が異なるだけでロジック自体は同じです。

```cs
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Sample
{
    public class AsyncAwaitCheck : MonoBehaviour
    {
        // ボタンの入力状態
        private bool IsButton => Input.GetKey(KeyCode.A);

        // 長押しと判定されるまでの時間[秒]
        private float LongPressThreshold => 1.0f;

        void Start()
        {
            // チェック開始
            CheckLoopAsync(destroyCancellationToken).Forget();
        }

        private async UniTask CheckLoopAsync(CancellationToken token)
        {
            var isLongPressFired = false; // 長押し検知済みかどうか
            var pressSeconds = 0.0f; // ボタンが押されている時間
            var lastValue = IsButton; // 1F前の状態

            while (!token.IsCancellationRequested)
            {
                if (IsButton)
                {
                    // 一度長押しが検知されたらボタンが離されるまで何もしない
                    if (!isLongPressFired)
                    {
                        // ボタンが押されていないかつ、まだ発火していない場合
                        // 押された時間を記録し、しきい値を超えたら発火
                        pressSeconds += Time.deltaTime;
                        if (pressSeconds >= LongPressThreshold)
                        {
                            OnLongPress();
                            isLongPressFired = true;
                        }
                    }
                }
                // IsButton=false && lastValue=true
                // すなわち「ボタンがこの瞬間に離された」を検知
                else if (lastValue)
                {
                    if (pressSeconds < LongPressThreshold)
                    {
                        // LongPressThreshold未満で離された場合は短押し
                        OnShortPress();
                    }

                    isLongPressFired = false;
                    pressSeconds = 0.0f;
                }

                lastValue = IsButton;
                await UniTask.Yield();
            }
        }

        private void OnLongPress()
        {
            Debug.Log("Long Pressed");
        }

        private void OnShortPress()
        {
            Debug.Log("Short Pressed");
        }
    }
}
```

### 別解

別の方法で書くとこうなります。若干テクニカル。

```cs
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Sample
{
    public class AsyncAwaitCheck : MonoBehaviour
    {
        // ボタンの入力状態
        private bool IsButton => Input.GetKey(KeyCode.A);

        // 長押しと判定されるまでの時間[秒]
        private float LongPressThreshold => 1.0f;

        void Start()
        {
            // チェック開始
            CheckLoopAsync(destroyCancellationToken).Forget();
        }

        private async UniTask CheckLoopAsync(CancellationToken token)
        {
            while (!token.IsCancellationRequested)
            {
                // ボタンが押されるまで待つ
                await UniTask.WaitUntil(this, x => x.IsButton, cancellationToken: token);

                var lcts = CancellationTokenSource.CreateLinkedTokenSource(token);


                UniTask WaitForReleaseAsync(CancellationToken ct)
                {
                    // ボタンが離されるまで待つ
                    return UniTask.WaitUntil(this, x => !x.IsButton, cancellationToken: ct);
                }

                UniTask WaitForHoldPressAsync(CancellationToken ct)
                {
                    // ボタンが押されたままの状態がLongPressThreshold秒続くまで待つ
                    return UniTask.Delay(TimeSpan.FromSeconds(LongPressThreshold), cancellationToken: ct);
                }
                
                // ここに到達したタイミングではボタンは「押されている」
                // この状態で「ボタンが離される」または「LongPressThreshold秒このまま経過する」のどちらか先に終わる方を待機する
                var index = await UniTask.WhenAny(
                    WaitForReleaseAsync(lcts.Token),
                    WaitForHoldPressAsync(lcts.Token));

                // 結果が出たので余計な方をキャンセルをしておく
                lcts.Cancel();

                if (index == 0)
                {
                    // ボタンが離された方が先に終わった
                    OnShortPress();
                }
                else
                {
                    // ボタンが押されたままだった
                    OnLongPress();
                    // ボタンが離されるまで待つ
                    await UniTask.WaitUntil(this, x => !x.IsButton, cancellationToken: token);
                }
            }
        }

        private void OnLongPress()
        {
            Debug.Log("Long Pressed");
        }

        private void OnShortPress()
        {
            Debug.Log("Short Pressed");
        }
    }
}
```


## R3

[R3](https://github.com/Cysharp/R3)を使う方法。コード自体は手続き的に書くよりは短くなります。
ですがオペレーターの挙動を正確に把握してないと理解できない状態になってしまう点に注意です。

```cs
using System;
using R3;
using UnityEngine;

namespace Sample
{
    public class R3Check : MonoBehaviour
    {
        // ボタンの入力状態
        private bool IsButton => Input.GetKey(KeyCode.A);

        // 長押しと判定されるまでの時間[秒]
        private float LongPressThreshold => 1.0f;

        void Start()
        {
            // ボタンの状態をReactivePropertyに変換
            // （ObservableでさえあればいいのでReactivePropertyである必要はない）
            // イベントはボタンのON/OFFが変わった瞬間に発行される
            var isButton =
                Observable.EveryValueChanged(this, x => x.IsButton, destroyCancellationToken)
                    .ToReadOnlyReactiveProperty();

            // 短押し判定
            // ボタンのON/OFFが入れ替わったイベントをもとにする
            isButton
                // 購読直後に発火するのを防止
                .Skip(1)
                // 最後のイベント発行からの経過時間を挿入
                .TimeInterval()
                // 経過時間がしきい値未満で、かつOFFになった場合に短押しと判定
                .Where(x => x.Interval.TotalSeconds <= LongPressThreshold && !x.Value)
                .Subscribe(_ => OnShortPress())
                .AddTo(this);

            // 長押し判定
            // ボタンのON/OFFが入れ替わったイベントをもとにする
            isButton
                // 購読直後に発火するのを防止
                .Skip(1)
                // 最後のイベント発行から一定時間経過したら、その値を取得する
                .Debounce(TimeSpan.FromSeconds(LongPressThreshold))
                // その値がtrueだったら長押し成立
                .Where(x => x)
                .Subscribe(_ => OnLongPress())
                .AddTo(this);
        }

        private void OnLongPress()
        {
            Debug.Log("Long Pressed");
        }

        private void OnShortPress()
        {
            Debug.Log("Short Pressed");
        }
    }
}
```

### 別解

@su10さんから頂いた方法をベースにしたものです。
`SelectAwait`を使うことで`async/await`を`Observable`内に持ち込んで実装したもの。

上記のオペレーターだけで表現したものと比べるとちょっとテクニカルです。

```cs
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;

namespace Sample
{
    public class R3Check : MonoBehaviour
    {
        // ボタンの入力状態
        private bool IsButton => Input.GetKey(KeyCode.A);

        // 長押しと判定されるまでの時間[秒]
        private float LongPressThreshold => 1.0f;

        void Start()
        {
            // ボタンの状態をReactivePropertyに変換
            // （ObservableでさえあればいいのでReactivePropertyである必要はない）
            // イベントはボタンのON/OFFが変わった瞬間に発行される
            var isButton =
                Observable.EveryValueChanged(this, x => x.IsButton, destroyCancellationToken)
                    .ToReadOnlyReactiveProperty();

            isButton
                .Where(x => x)
                // ボタンが押された瞬間に非同期処理を実行開始
                .SelectAwait(async (_, ct) =>
                {
                    using var lcts = CancellationTokenSource.CreateLinkedTokenSource(ct);

                    // ボタンが離されるか、LongPressThreshold秒経過するか、どちらが先に終わるか
                    var index = await UniTask.WhenAny(
                        WaitForReleaseAsync(lcts.Token),
                        WaitForHoldPressAsync(lcts.Token)
                    );

                    lcts.Cancel();
                    
                    return index != 0; // false: ShortPress, true: LongPress

                    UniTask WaitForReleaseAsync(CancellationToken token)
                    {
                        // ボタンが離されるまで待つ
                        return UniTask.WaitUntil(this, x => !x.IsButton, cancellationToken: token);
                    }

                    UniTask WaitForHoldPressAsync(CancellationToken token)
                    {
                        // ボタンが押されたままの状態がLongPressThreshold秒続くまで待つ
                        return UniTask.Delay(TimeSpan.FromSeconds(LongPressThreshold), cancellationToken: token);
                    }
                    
                }, AwaitOperation.Switch)
                .Subscribe(x =>
                {
                    if (x)
                    {
                        OnLongPress();
                    }
                    else
                    {
                        OnShortPress();
                    }
                })
                .AddTo(this);
        }

        private void OnLongPress()
        {
            Debug.Log("Long Pressed");
        }

        private void OnShortPress()
        {
            Debug.Log("Short Pressed");
        }
    }
}
```


## UniRx

UniRxを使う方法。R3とだいたい一緒です。

```cs
using System;
using UniRx;
using UnityEngine;

namespace Sample
{
    public class UniRxCheck : MonoBehaviour
    {
        // ボタンの入力状態
        private bool IsButton => Input.GetKey(KeyCode.A);

        // 長押しと判定されるまでの時間[秒]
        private float LongPressThreshold => 1.0f;

        void Start()
        {
            // ボタンの状態をReactivePropertyに変換
            // （ObservableでさえあればいいのでReactivePropertyである必要はない）
            // イベントはボタンのON/OFFが変わった瞬間に発行される
            var isButton =
                this.ObserveEveryValueChanged(x => x.IsButton)
                    .ToReadOnlyReactiveProperty();

            // 短押し判定
            // ボタンのON/OFFが入れ替わったイベントをもとにする
            isButton
                // 購読直後に発火するのを防止
                .SkipLatestValueOnSubscribe()
                // 最後のイベント発行からの経過時間を挿入
                .TimeInterval()
                // 経過時間がしきい値未満で、かつOFFになった場合に短押しと判定
                .Where(x => x.Interval.TotalSeconds <= LongPressThreshold && !x.Value)
                .Subscribe(_ => OnShortPress())
                .AddTo(this);

            // 長押し判定
            // ボタンのON/OFFが入れ替わったイベントをもとにする
            isButton
                // 購読直後に発火するのを防止
                .SkipLatestValueOnSubscribe()
                // 最後のイベント発行から一定時間経過したら、その値を取得する
                .Throttle(TimeSpan.FromSeconds(LongPressThreshold))
                // その値がtrueだったら長押し成立
                .Where(x => x)
                .Subscribe(_ => OnLongPress())
                .AddTo(this);
        }

        private void OnLongPress()
        {
            Debug.Log("Long Pressed");
        }

        private void OnShortPress()
        {
            Debug.Log("Short Pressed");
        }
    }
}
```

# まとめ

---

* **応用性・拡張性**

`async/await` > R3 > コルーチン > UniRx

* **コードのスッキリさ（≠可読性）**

R3 > UniRx > `async/await` ≒ コルーチン


* **初心者向け度**

コルーチン > `async/await` > R3 ≒ UniRx

---

## 以下、ポエム

初心者から上級者まで、万人にオススメなのは「`async/await` + `UniTask`」です。手続き的に書けるので自由度が高くどんなロジックも実装できます。
愚直にフラグや変数を用意して処理をベタ書きできるのは、煩雑になるという欠点がありますが、「初心者でも（時間をかければ）理解できるコードになる」という大きな利点があります。
（少なくともいきなり`Obseravble`に触れるよりかはハードルは低いです）

その論点からいうとコルーチンも初心者向けではありますが、`async/await`と比べると応用性が低く、`MonoBehaviour`に依存してしまう欠点があります。
とりあえずコルーチンで書いてみて非同期処理に慣れてきたら`async/await`に乗り換える、くらいのつもりでいるとよいでしょう。

UniRx/R3は上級者向けです。
オペレーターの挙動を完全に把握していないと扱いきれないためかなりハードルは高いです。複雑なロジックであっても、キレイにハマればかなり洗練されたコードにはできる利点はあります。
ですがそのためにオペレーターのパズルを解くのに時間を使うなら`async/await`で愚直に書いたほうが早いという場合が多いです。
（そして愚直に`async/await`で書いたコードであればR3/UniRxがわからない人でも読めるという利点がある）