R3時代の連打・同時押し制御

# 0. Introduction ――「連打」と「同時押し」
まず最初に断っておきますが、この記事の内容そのものはUnityに依存した話ではありません。

サンプルのソースコードはUnityのものを提示しますが、**R3**を導入しているC#環境であれば受容できる話をしていきます。

R3は汎用Rxライブラリなので、Unityでもそれ以外でも利用していきたいですねー。


https://github.com/Cysharp/R3

さて、この記事ではR3を利用した「連打」及び「同時押し」の制御実装について私見を述べていきます。

この連打・同時押し問題というのはUnity界隈では定番の話であり、UniRxで解決を試みる実装もいくつか産出されてきました。[^1]

[^1]: 以下の記事など。
[Unity/UniRx - 複数ボタンの同時押し/連打制御](https://qiita.com/azumagoro/items/a3b7b52f87c1b68b43e4)

その中でも `AsyncReactiveCommand` を利用する記述は面白い実装だったのですが、`IObservable` を返さないといけない仕様のためにちょっと小難しい書き方になるという、取り回しが苦しい部分もありました。[^2]

[^2]: `AsyncReactiveCommand` については、詳細は以下の記事を参照。
[【UniRx】 ReactiveCommand/AsyncReactiveCommandについて](https://qiita.com/toRisouP/items/c6fba9f01e6d15dabd79)

R3を利用すれば、連打・同時押しの制御はかなり楽にできてしまいます。

# 1. 連打対策
## 1-1. ThrottleFirst
これはUniRx時代でも使われていたオペレーターですが、R3でも使用することができます。

```cs:ThrottleFirstSample.cs
using UnityEngine;
using UnityEngine.UI;
using System;

public class ThrottleFirstSample : MonoBehaviour
{
    [SerializeField]
    private Button button;

    private void Start()
    {
        // 任意の秒数間隔で一度だけクリックを受け付ける
        button
            .OnClickAsObservable()
            .ThrottleFirst(TimeSpan.FromSeconds(0.3))
            .Subscribe(_ => Debug.Log("Clicked!"))
            .RegisterTo(destroyCancellationToken);
    }
}
```

指定された時間、購読処理発火を跳ねのける `ThrottleFirst` は非常に便利ですが、逆に言えば指定された時間を超えるような処理（Unityでいうところのシーンロードやサーバーとの通信など）を発火する場合には対応できない欠点があります。

それを補うことができるのが、`async / await` 時代のRxであるR3の真骨頂 `SubscribeAwait` です。

## 1-2. SubscribeAwait
R3の新機能である `SubscribeAwait` の第二引数に `AwaitOperation.Drop` を指定しておくと、発火された処理が終わるまで後続の発火を無視することができます。

このオプションの選択は、利用者の好みによって変わりそう...。
自分は `AwaitOperation.Drop` の挙動が非常に好みです。

```cs:SubscribeAwaitSample.cs
using R3;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using Cysharp.Threading.Tasks;

public class SubscribeAwaitSample : MonoBehaviour
{
    [SerializeField]
    private Button button;

    private void Start()
    {
        // awaitで待機している間のクリックを無視する
        button
            .OnClickAsObservable()
            .SubscribeAwait(async (_, ct) =>
            {
                await SceneManager.LoadSceneAsync("SampleScene").WithCancellation(ct);
                Debug.Log("Scene loaded!");
            }, AwaitOperation.Drop)
            .RegisterTo(destroyCancellationToken);
    }
}
```

## 1-3. 最終解：ThrottleFirstとSubscribeAwaitの併用
前項で紹介した `SubscribeAwait` は非同期で待機できる処理が前提となるので、特に `await` できるものがなくても共通で使用しようとなると冗長な実装になりかねません。

なので、発火する処理が同期処理のみの場合は `ThrottleFirst` での対応でいいと思います。

また、非同期処理を含む処理を発火する場合でも、`await` する処理がほかの購読の `ThrottleFirst` で指定している間隔より速く終わったりしたら、統一感が損なわれる結果になります。

これに関するベストアンサーは `ThrottleFirst` と `SubscribeAwait` の併用でしょう。
別に二者一択というものでもないので、`SubscribeAwait` する場合でも `ThrottleFirst` を書いておけばいいと思います。

```cs:BestBarrageControll.cs
using R3;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using Cysharp.Threading.Tasks;
using System;

public class BestBarrageControll : MonoBehaviour
{
    [SerializeField]
    private Button button;

    private void Start()
    {
        // ThrottleFirstとSubscribeAwaitの組み合わせ
        button
            .OnClickAsObservable()
            .ThrottleFirst(TimeSpan.FromSeconds(0.3))
            .SubscribeAwait(async (_, ct) =>
            {
                await SceneManager.LoadSceneAsync("SampleScene").WithCancellation(ct);
                Debug.Log("Scene loaded!");
            }, AwaitOperation.Drop)
            .RegisterTo(destroyCancellationToken);
    }
}
```

以上、連打対策でした。

ここまでのサンプルコードはどれもシンプルな記述でしたが、それでもしっかり連打対策ができているのには感嘆しかない...。

# 2. 同時押し対策
指操作があるモバイルなど、同時に二カ所以上のタップイベントを得る可能性のある環境下においては、この「同時押し」問題は考えておかなければならない課題です。

先述の通り、この問題に関してUniRxの `AsyncReactiveCommand` が便利だったのですが、R3には実装されていません。

ちょっとこの辺悩んでいたのですが、先日アドバイスをいただける機会がありました。

https://github.com/Cysharp/R3/issues/97

結構単純ですが、要件を満たせる閃きをいただきましたので、それを反映した実装が以下になります。

```cs:PushingSimultaneousSample.cs
using R3;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using Cysharp.Threading.Tasks;
using System;

public class PushingSimultaneousSample : MonoBehaviour
{
    [SerializeField]
    private Button button1;

    [SerializeField]
    private Button button2;

    private readonly ReactiveProperty<bool> gate = new ReactiveProperty<bool>(true);

    private void Start()
    {
        button1
            .OnClickAsObservable()
            .ThrottleFirst(TimeSpan.FromSeconds(0.3))
            .Where(_ => gate.Value)
            .SubscribeAwait(async (_, ct) =>
            {
                gate.Value = false;
                await SceneManager.LoadSceneAsync("SampleScene").WithCancellation(ct);
                Debug.Log("Scene loaded!");
                gate.Value = true;
            }, AwaitOperation.Drop)
            .RegisterTo(destroyCancellationToken);

        button2
            .OnClickAsObservable()
            .ThrottleFirst(TimeSpan.FromSeconds(0.3))
            .Where(_ => gate.Value)
            .Subscribe(_ =>
            {
                gate.Value = false;
                Debug.Log("Clicked!");
                gate.Value = true;
            })
            .RegisterTo(destroyCancellationToken);
    }
}
```

安直ですが、共通の `ReactiveProperty<bool>` を発火する処理の起点・終点で変更し、それ以外の発火は跳ねのける、みたいな実装ですね。

これでいちおう要件を満たせています。

ここまでの実装の定型パターンを何度も書くのは面倒なので、拡張メソッド生やして効率化してやりましょう。

```cs:ObservableExtensions.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using R3;

public static class ObservableExtensions
{
    /// <summary>
    /// 連打禁止クール時間定数
    /// </summary>
    static readonly TimeSpan throttleSecs = TimeSpan.FromSeconds(0.3);

    /// <summary>
    /// 連打禁止・同時押し禁止のための排他的なSubscribeAwaitを提供します
    /// </summary>
    public static IDisposable SubscribeLockAwait<T>(this Observable<T> source, ReactiveProperty<bool> gate, Func<T, CancellationToken, ValueTask> onNextAsync)
    {
        return source
            .ThrottleFirst(throttleSecs)
            .Select((gate, onNextAsync), static (arg, param) => (arg, param.gate, param.onNextAsync))
            .Where(static param => param.gate.CurrentValue)
            .SubscribeAwait(static async (param, ct) =>
            {
                param.gate.Value = false;
                await param.onNextAsync(param.arg, ct);
                param.gate.Value = true;
            }, AwaitOperation.Drop);
    }

    /// <summary>
    /// 連打禁止・同時押し禁止のための排他的なSubscribeを提供します
    /// </summary>
    public static IDisposable SubscribeLock<T>(this Observable<T> source, ReactiveProperty<bool> gate, Action<T> onNext)
    {
        return source
            .ThrottleFirst(throttleSecs)
            .Select((gate, onNext), static (arg, param) => (arg, param.gate, param.onNext))
            .Where(static param => param.gate.CurrentValue)
            .Subscribe(static param =>
            {
                param.gate.Value = false;
                param.onNext(param.arg);
                param.gate.Value = true;
            });
    }
}
```

`Select` オペレーターや静的デリゲートなど、いろいろと手を加えてます。

特に `Select` オペレーターの使い方がUniRx時代からかなり変わっていて、クロージャーに甘えない書き方が簡単にできるようになりました。

この拡張メソッドを生やしておくと、以下のように記述することができます。

```cs:SubscribeLockSample.cs
using R3;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using Cysharp.Threading.Tasks;

public class SubscribeLockSample : MonoBehaviour
{
    [SerializeField]
    private Button button1;

    [SerializeField]
    private Button button2;

    private readonly ReactiveProperty<bool> gate = new ReactiveProperty<bool>(true);

    private void Start()
    {
        button1
            .OnClickAsObservable()
            .SubscribeLockAwait(gate, async (_, ct) =>
            {
                await SceneManager.LoadSceneAsync("SampleScene").WithCancellation(ct);
                Debug.Log("Scene loaded!");
            })
            .RegisterTo(destroyCancellationToken);

        button2
            .OnClickAsObservable()
            .SubscribeLock(gate, _ =>
            {
                Debug.Log("Clicked!");
            })
            .RegisterTo(destroyCancellationToken);
    }
}
```

実にシンプル！！

なお、UniRxの `AsyncReactiveCommand` と同様に、明示的にボタンの押下制御表示をしたいのなら、以下の記事に実装のサンプルがあるので、それを参照するのもアリかもしれません。

https://qiita.com/toRisouP/items/4344fbcba7b7e8d8ce16