土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#unity #Rx R3でダブルクリック検出処理を実装する

 Rxの勉強を兼ねて、UniRxで書かれたコードをR3に移植してみることにしました。
 対象のコードは、こちらのサイトのダブルクリック検出コードを参考にさせていただきました。
qiita.com
 Rxやジェネリックに慣れてない人向けに丁寧に解説しています(そもそも自分の勉強が目的なので)。間違いなどありましたらお気軽に指摘してもらえると助かります。

 なお、「Rxってそもそも何?」って型は前回の記事をどうぞ。
someiyoshino.info

注意

 Unity及びR3は以下のverを使用しています。

  1. Unity2022.3.19f1
  2. R3 1.1.0(コアモジュール&Unity)

特に、R3が1.1.0で採用されたChunk(count,step)を利用しているため、それ以前のverでは動かないので注意してください。。

 R3のインストールについてはこちらの方法を使わせてもらいました。
zenn.dev
 なおパッケージマネージャーのUIが2022と2023で異なっています。2022だとこんな。

前提:ダブルクリック検出とは

 ダブルクリックを検出するには、ユーザーがマウスをクリックするタイミングを「2回」取得して、その2回のクリックが時間的に短い間隔で行われているかを判定します。

 マウスのクリック自体の取得は簡単なのですが、時間間隔を計測するためには一個前のクリックイベントの発生時間を常時キャッシュしておく必要があります。

 また、「カチカチカチ」と3連続以上でクリックされた場合、これはできればダブルクリック1回として検出したいです。その場合、直前のクリックだけキャッシュするのでは足りない事になります。

 このように、発行される複数個のイベント(ここではOnClick)を確認して処理を決めるのは非常に複雑な処理です、R3(Rx)はこのようなイベントストリーム処理を行うライブラリです。

サンプルコード1(動作確認用)

 今回のサンプルコードはこちらです。先述のサイトにあるUniRxコードをR3コードに変換しています。また、元コードは処理が3つに分かれていましたが、わかりやすさの為1本にしてあります。

//R3sample.cs
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;

public class R3sample : MonoBehaviour
{
    private void Start()
    {
        var DoubleClickStream = 
            Observable
                .EveryUpdate()
                .Where(_ => Input.GetMouseButtonDown(0))
                .TimeInterval()
                .Select(t => t.Interval.TotalMilliseconds)
                .Chunk(2, 1)
                .Where(list => list[0] >  250d)
                .Where(list => list[1] <= 250d)
                .Subscribe(_ => { Debug.Log("double click"); });
    }
}

 こちらのコードを、シーン内に配置したGameObjectにアタッチしてゲームを開始した後、マウスの左ボタンをカチカチ押すとダブルクリック時を検出してコンソールに出力します。


サンプルコード2(解説用)

 サンプルコード1を見ると、このコードはObservableクラスを起点にメソッドをいくつもドットで繋いだ、たった1行のコードであることが分かります。
 このようにメソッドを連結して処理を記述する事を「メソッドチェーン」と呼びます。また、メソッドチェーンで要素をクエリする手法をLINQと呼びます*1
 LINQ(≒メソッドチェーン)形式だと伝搬される値が掴みにくいので、一旦チェーンをメソッド単位で一行ずつ分割する事にします。
 以下のコードは先ほどのコードと同じ意味になります。わかりやすさのため、本来は省略出来る記述もできる限り書き起こしています。

//R3sample2.cs
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;

public class R3sample2 : MonoBehaviour
{
    void Start()
    {
        Observable<Unit> updateStream = R3.Observable.EveryUpdate();
        Observable<Unit> clickStream = updateStream.Where<Unit>(_ => Input.GetMouseButtonDown(0));
        Observable<(System.TimeSpan Interval, Unit Value)> TimeIntervalStream = clickStream.TimeInterval<Unit>();
        Observable<double> SelectStream = TimeIntervalStream.Select<(System.TimeSpan Interval, Unit Value), double>(t => t.Interval.TotalMilliseconds);
        Observable<double[]> ChunkStream = SelectStream.Chunk<double>(2, 1);
        Observable<double[]> DoubleClickStream = ChunkStream
           .Where<double[]>(list => list[0] >  250d)
           .Where<double[]>(list => list[1] <= 250d);
        DoubleClickStream.Subscribe<double[]>(_ => { Debug.Log("double click"); });
    }
}

 以降はこちらのコードを使って、イベントストリームを使ったダブルクリック検出のロジックを確認します。

EveryUpdate():イベントを発行する。

Observable<Unit> updateStream = R3.Observable.EveryUpdate();

 ObservableはR3の起点になるstaticクラスです。何をするにしてもまずObservableから書き始めることになります(多分)。
 EveryUpdate()は、イベントを発行するファクトリメソッドの1つで、起動するとUpdate()が実行されるたびにイベントを発行してストリームに流します。
 EveryUpdate()はObservableオブジェクトを返します。この値(ここではupdateStream)にメソッドで処理を追加し、その戻り値にまたメソッドで~と繰り返す事で、イベントストリームの挙動を制御します。
 ここで重要なのは、このコードは「Update()ごとにイベントを発行するストリーム」を「生成」しただけで、「起動」はしてないという点です。この時点ではまだイベントは発行されません。
 EveryUpdate()の戻り値の型はObservable<Unit>です。Observable<T>のTの部分は、イベントストリーム上を流れるイベントが持つパラメータの型を示します。Unitはイベントが「パラメータを持たない」事を表す特殊な型です。
 Unit型については長くなるので別記事にしました。こちらも参照ください(先に読んでおいた方が以降の説明がわかりやすいかもしれません)。

someiyoshino.info

 なお、ObservableとObservable<T>は実装上は別のクラスです。関数オーバーロードのジェネリック版だと考えてください(FuncやActionをイメージするとわかりやすいかも?)。

Where():クリックタイミングを抽出

Observable<Unit> clickStream = updateStream.Where<Unit>(_ => Input.GetMouseButtonDown(0));

 Where()はオペレーターの1つです。オペレータは、ストリームに流れてくるイベントを、指定した条件でフィルタリングしたり、別の値に変換したりします。
 Where()は、流れてきたイベントごとに条件(ここではラムダ式)を実行し、falseならそのイベントを廃棄します。Where<T>のTにはジェネリックではイベントが持つパラメータの型を指定します。現在流れているイベントはパラメータを持たないのでUnitを設定しています。
 EveryUpdate()によって、イベントストリームにはupdate()ごとにイベントが発行されています。Where()ではそれらのイベントごとにGetMouseButtonDown(0)を実行し、左ボタンダウンがあればイベントをそのまま流し、そうでなければカットします。
 これにより、毎フレーム機械的に発行されていたイベントが、「マウスをクリックしたフレームのみ発行されたイベント」に変換されるのです。

TimeInterval():時間間隔に変換

Observable<(System.TimeSpan Interval, Unit Value)> TimeIntervalStream = clickStream.TimeInterval<Unit>();

 TimeInterval()はオペレータの1つで、直前のイベント発行からの経過時間を算出し、パラメータに追加します。追加されるパラメータは組み込みのTimeSpan型になります。
 パラメータが追加されことで、戻り値のジェネリック型が"(System.TimeSpan Interval, Unit Value)"になっています。丸括弧はタプル型の定義です。第1要素(Interval)が追加されたパラメータ、第2要素(Value)はこれまで持っていたパラメータ(ただしパラメータは無いのでUnit)です。

Select():必要な値のみに変換

Observable<double> SelectStream = TimeIntervalStream.Select<(System.TimeSpan Interval, Unit Value), double>(t => t.Interval.TotalMilliseconds);

 Select()は、流れてきたイベントを別のイベントに変換するオペレータです。Tは受け取るイベントのパラメータ、TResultは新しいイベントのパラメータの型(ここではdouble)になります。
 "t.Interval"でさっきのタプルの第一要素にアクセスし、"TotalMilliseconds"でミリ秒を取得して戻り値(double型)にします。
 ダブルクリック検出に必要なのはイベント間(つまり、クリックとクリックの間)の経過時間のみなので、TimeSpanからその値だけを抽出してイベントを発行し直しているわけです。

Chunk():イベント群をグルーピング

Observable<double[]> ChunkStream = SelectStream.Chunk<double>(2, 1);

 Chunkは、複数のイベントを配列としてグループ化するオペレーターです*2
 Chunk(count, step)はcountが配列の個数。stepはグループ化する単位を表します。
 ここではChunk(2,1)なので、ABCDEFGHとイベントが発行された場合「AB」「BC」「CD」「DE」「EF」「FG」「GH」という風にイベントが配列としてグループ化され、再びイベントストリームに流れます。パラメータが配列になったので、戻り値のジェネリック型が"double"から"double[]"になっています。
 この要素2の配列は、それぞれダブルクリックの1回目と2回目のクリックに相当します。ダブルクリックの後もカチカチ連打された時に、3回目以降の入力を無視するためにこうしています。
 余談ですが、Chunk(2)とした場合、ABCDEFGHのイベントは「AB」「CD」「EF」「GH」とグループ化されます。この場合「BC」や「FG」のペアがダブルクリック検出から無視されてしまうので、サンプルコードのようにstepを設定する必要があります。

Where():クリック間隔判定

Observable<double[]> DoubleClickStream = ChunkStream
   .Where<double[]>(list => list[0] >  250d)
   .Where<double[]>(list => list[1] <= 250d);

 Whereオペレーターが2連続で出てきます。
 2個目のWhereから先に説明します。Chunk()によってイベントは要素2の配列で流れてきます。list[1]には2回目のクリックと1回目のクリックとの時間差分が格納されているので、その値が250msより短ければ「ダブルクリックが行われた」と判断し、そのイベントを通過させます。
 一方、1個目のWhereは、ダブルクリックの1回目の押下が、その更に前のクリックから十分時間が空いている事を確認しています。これにより、トリプル以上のクリックがあってもそれらを無視するようにしています。

Subscribe():ストリームを起動

DoubleClickStream.Subscribe<double[]>(_ => { Debug.Log("double click"); });

 Subscribe()が実行されるとそのストリームが起動します*3
 これによりEveryUpdate()による毎Update()ごとのイベント発行が行われ、そこから続く一連の処理が走り続けるわけです。

おわりに

 R3によるダブルクリック検出のロジックを見て来ました。一行ずつ分割したサンプルコード2を見ると、凄く複雑なコードに思えますが、メソッドチェーン形式にしたサンプルコード1では多くの記述が省略され、イベントストリームに対して行いたい処理に注目してシンプルに記述出来るのが分かります。

 Rxの理解にはオペレータとファクトリーメソッドの把握が不可欠のようなので、今後も折を見て記事に出来ればと思っています。

 ちなみに、当初は各オペレータについてマーブルダイヤグラムを作図するつもりだったんですが、作業コストが高すぎて諦めました。

余談:"using R3;"が省略できなかった話。

 コードを行単位で分割した時、ついでに"using R3;"も省略できないかと思ったんですが、残念ながら下記のコードはコンパイルが通りませんでした。

R3.Observable<Unit> clickStream = R3.updateStream.Where<Unit>(_ => Input.GetMouseButtonDown(0));

 以下のように書けば大丈夫です。

R3.Observable<R3.Unit> clickStream2 = R3.ObservableExtensions.Where<R3.Unit>(updateStream, _ => Input.GetMouseButtonDown(0));

 ObservableExtensionsクラスへのスコープが無いので、Where()拡張メソッドが解析出来ないんですね。これ拡張メソッド形式のまま記述する方法あるのかな?

*1:正確にはLINQはマイクロソフトの用語だけど、今はみんなLINQって呼んでる。

*2:UniRxでのBufferから改名

*3:正確には「購読開始」と言うべきなのかもしれないけど違いが良く分かって無い