20
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unityユーザー向け】 R3入門 1 ~基礎編~

20
Last updated at Posted at 2026-03-26

R3入門シリーズについて

過去にUniRxの普及を目的に「UniRx入門」を執筆したり、UniRx本も出版したりしました。しかし2026年の現在、UniRxはGitHubリポジトリがアーカイブされメンテナンス終了となり、その後継となる「R3」というライブラリが登場しました。

そのため今からライブラリを導入しようとした場合は「UniRx」は非推奨であり、「R3」を使うべきです。しかしR3の情報は点在しているものの、Unityユーザー向けに体系立てて学べる日本語資料はまだ多くありません。

幸いなことに基本的な使い方はR3とUniRxで同じではあるので、UniRxの知識を用いてR3を利用することはできます。ですがUniRxとR3では設計思想が異なる部分があり、UniRxと完全に互換があるわけではありません。またR3でのみ使える便利な新機能も存在するため、使いこなすためにはR3についてあらためて学ぶ必要があります。

そこでこのシリーズでは「R3」の基本的な使い方・覚えておくべき動作仕様・踏み込んだテクニックなどを体系的に学べる内容にできればと考えています。

対象読者および前提条件

  • 主にUnityユーザーを対象とします
    • 「UnityにおけるR3の使い方」を中心に解説をします
    • 純粋なC#erの方で「R3」単体の学習がしたかった人は対象外になります
  • 基本的なC#の言語仕様は把握しておいてください
    • event、LINQ、delegate、ラムダ式、拡張メソッド、async/awaitCancellationToken など…
    • とくにasync/awaitCancellationTokenは重要です
  • UniTaskは導入済みという前提にします

「R3」の解説

どんなライブラリなのか

R3はC#で「リアクティブプログラミング」を可能にするライブラリです。

リアクティブプログラミングは、時間とともに流れるデータ(イベント)を扱うプログラミングスタイルです。データの変化を継続的に観測し、変換・合成しながら、「どのタイミングで何をするか」を宣言的に記述できます。

R3では多くのものを「イベント(ストリーム)」として扱えます。たとえば変数の値更新も、更新通知をイベントとして扱うことが可能です。

そのうえで、R3では次のようなことを行うことができます。

  • データが発行された瞬間に、それに対応する処理を行う
  • 前回の値と比較して、変化があったときだけ処理を行う
  • 複数のデータの流れを組み合わせて、特定の条件を満たしたときだけ処理を行う
  • データの発行間隔を観察し、一定期間データの発行が止まったときだけ処理を行う

とくにUnityのようなゲーム開発では、時間に関係する処理が頻繁に登場します。
たとえば、キャラクター状態の変化検知、数フレーム前との比較、一定時間後の処理、入力の連打・長押し判定、UI更新タイミングの制御などです。

R3では「今この瞬間の値」だけでなく「時間的な変化」も含めて処理を行うことが可能です。

具体的な実装例は、このあとコードとともに紹介します。

なお、R3の導入方法やセットアップ手順については別記事の【Unityユーザー向け】R3入門 ~導入編~で解説しています。インストール方法や最初のセットアップを確認したい場合は、先にそちらを参照してください。

Rx/UniRxとの違い

冒頭でも触れましたが、R3には前身となるライブラリとして「Rx(Reactive Extensions / Rx.NET)」および「UniRx」が存在します。これらのライブラリとR3の違いを一言でまとめるなら、R3はRx/UniRxを現代のC#に合わせて再設計したライブラリです。

R3ではRx/UniRxの根幹となるObservableの概念や振る舞いを見直し、破壊的な変更を加えています。

  • UniRx
    • エラーが発生するとObservableへの購読は自動的に終了する
    • Observableがエラーまたは完了に至ったあとに再購読できる場合がある
    • OnCompletedが発行されるかどうかは実装に委ねられる
    • Schedulerを使って時間の管理を行う
    • async/awaitとの連携は最低限しかできない
  • R3
    • エラーが発生してもObservableは動作を継続する
    • すでに完了したObservableは再利用できず、再度扱う場合は新しく作り直す必要がある
    • Observableは最後に原則としてOnCompletedを発行する
    • Schedulerが廃止、TimeProvider/FrameProviderを代わりに使う
    • async/awaitとの連携を前提に構築されている

違いはいくつかありますが、まずは「エラーが発生してもObservableが止まりにくい」「使い終わったObservableは再利用しない」「時間を扱うときはTimeProvider/FrameProviderを使う」の3点だけ押さえておけば十分です。

これにより「async/awaitとの親和性の改善」「テストの書きづらさの解消」「エラーハンドリングのしやすさの向上」を実現しています。そのためRx/UniRxからR3への乗り換えについては機械的に置き換えられない場合があるので注意してください。

詳細な差分については別の記事にまとめているのでそちらを参照してください。

今後頻繁に登場する概念など

R3に登場する概念やオブジェクトについてざっと紹介します。本記事ではまずR3のサンプルコードを読めるようになることを目標に、それぞれの概念や用語を手短に紹介します(概念を省略したりあえてぼかして解説している部分があります)。

より厳密で詳細な解説は別の記事で行う予定です。

R3の基本的な流れは、まずObservableを用意し、それにOperatorをつないで処理を組み立て、最後にSubscribeして実行する、というものです。以下の用語表は、この流れを理解するための最低限の用語整理だと思ってください。

  • Observable関係
単語 解説
Observable 読み方は「おぶざーばぶる」。時間的に連続して発行される値(イベントストリーム)を抽象的に取り扱うための概念およびその実装(オブジェクト)の名前。R3の中核をなす概念であり、Observableを理解することが重要となる。T型のメッセージを発行するObservableは「Observable<T>」という型で表される。
Operator オペレーター。Observableが発行するメッセージの合成・変換、ものによってはObservable自体の変換や合成を宣言的に行うことができる機能のこと。ObservableOperatorを連結して新しいObservableとして扱うことができる。IEnumerable<T>におけるLINQに近い概念。どんなオペレーターがあるかはこちらの記事を参照。
単語 解説
Message メッセージ。Observableで扱う「イベント通知1回分」のこと。本記事では、Observableから何かが通知されるたびに「メッセージが発行された」と表現する。OnNext/OnErrorResume/OnCompletedの3つの種類がある。
OnNextメッセージ 通常時において「イベント値」を発行するときに使うメッセージ。たとえばObservable<T>においてはこのOnNextメッセージがT型の値を伝達する。OnNext(T)と書いたりもする。
OnErrorResumeメッセージ Observableの途中でエラー(例外)が発生したことを伝えるためのメッセージ。Exception型のみを扱うことができる。OnErrorResumeメッセージは発行されたとしてもObservable自体の動作は止まらない。
OnCompletedメッセージ Observableが「完了」したことを伝えるためのメッセージ。完了したらそのストリームは解体されもう二度と使用できなくなる。メッセージ本文で成功(正常終了)/失敗(例外、異常終了)のどちらかを通知できる。

Observable.jpg

Operator.jpg
(画像はnano bananaで作ったけど言うこと聞かなくてなんか変な図になりました)

  • Observableを作る系
単語 解説
Subject<T> Observable<T>」を作るための機能のひとつ。Observableを手動で作りたいときに用いる。Observable生成においてはもっともシンプルな機能。余談として、R3内部でもこのSubjectを使ってObservableを生成していたりする。
ReactiveProperty<T> Observable<T>」を作るための機能のひとつ。読み書き可能な「変数」をObservableとしても扱えるようにしたものという認識でよい。汎用性が高くR3を使っていて一番お世話になる機能がたぶんこれです。
ファクトリメソッド 特殊な振る舞いを行うObservableを生成するためのメソッド群。目的にぴったりのファクトリメソッドを使えれば、Subject<T>を使って手動で実装するよりもコンパクトに実装をまとめることができる。どんなファクトリメソッドがあるかはこちらの記事を参照。
Triggers UnityにおけるMonoBehaviourComponentの特殊なメソッド(Update()OnTriggerEnter()など)をObservableに変換する機能の総称。
  • 購読関係
単語 解説
Subscribe 読み方は「さぶすくらいぶ」。Observableからメッセージを受信できる状態にすること。コード上ではObservableに対してSubscribe()メソッドを呼び出すこと。日本語で「購読する」と書いたときはこのSubscribe()を呼び出すことを意味します。
Dispose() R3というかC#の機能/概念。IDisposableインタフェースを実装するオブジェクトはDispose()を呼び出すことでそのリソースを解放することができる。Subscribe()の戻り値もこのIDisposableである。そのため購読を中止したいときはSubscribe()に対応したDispose()を呼び出すことで実現できる。
AddTo() GameObjectに対してIDisposableを登録し、OnDestroy()のタイミングで自動的にDispose()を呼び出してくれる便利機能。Observableに対する購読をGameObjectの破棄と同時に終了したいときに使える。めちゃくちゃお世話になる機能です。
RegisterTo() CancellationTokenに対してIDisposableを登録し、Cancel()のタイミングで自動的にDispose()を呼び出してくれる便利機能。非UnityEngineの領域でR3を使う場合に活用できると便利。
  • 時間の管理系
単語 解説
TimeProvider R3で時間経過を扱う際の基準となるオブジェクト。DelayTimestampなど、時間を使う処理で利用される。実装を差し替えることで、実行時とテスト時で時間の進み方を切り替えたりできる。デフォルトではUpdate()基準で計測される。
FrameProvider R3でフレーム経過を扱う際の基準となるオブジェクト。TimeProviderが「時間版」なら、こちらは「フレーム版」となる。デフォルトではUpdate()基準で計測される。
  • その他
単語 解説
Unit型 R3において「意味のない値」を明示する場合に使う型。Observable<Unit>のように使う。メッセージの中身の値そのものに意味はなく、メッセージが発行されたというタイミングのみを使いたい場合に用いる型。

マーブルダイアグラム

R3の動作を解説する上で「マーブルダイアグラム」という図を用いることがあります。

この図はObservableを流れるメッセージを時間軸に並べています。左から右にメッセージが順番に発行されていきます。
図の中の「✕」はOnErrorResumeを、「|」はOnCompletedこのR3解説シリーズでは定義します。

(なお、図の作成にはswirlyを使用しています)

例1: メッセージ発行

m_01.png

たとえば上の図は「a→b→c→OnErrorResumeOnCompleted」という順にメッセージが発行されたと読みます。時間軸としては右の方にいくほど新しく、左に進むほど過去になります。

例2: オペレーター

// observableが Observable<int> だと仮定して
// 10倍にして出力する
observable.Select(x => x*10)

m_02.png

オペレーターを挟む場合、上のタイムラインが入力であり、下のタイムラインが出力を表しています。また縦軸が揃っているメッセージは同じタイミングであるという意味になります。

この図では「SelectによってOnNextの値が10倍されて出力されており、そのタイミングは即時である(遅延しない)」と読み取ることができます。

例3: メッセージの時間差

// メッセージを1秒遅延させる
observable.Delay(TimeSpan.FromSeconds(1))

m_03.png

これはDelayオペレーターに対する入力と出力でメッセージの縦軸がズレています。これはつまり、Delayによってメッセージが遅延していることを表しています。

例4: オペレーターの連鎖

// observableが Observable<bool> だと仮定して
observable
    // 直前の値と差分があったときのみ通過
    .DistinctUntilChanged()
    // それが true なら通過
    .Where(x => x)

m_04.png

オペレーターが連鎖する場合は縦に連ねて書きます。

R3を用いた具体的なコード紹介

ざっと概念や概要を説明してきましたが、コードを読んだほうがわかりやすい部分もあります。単純なコード例から、実際のUnity開発を想定してどんな場面で使えるのかを紹介します。

サンプルコード

今回紹介しているサンプルコード全体は、以下のGitHubリポジトリで公開しています。必要に応じてあわせて参照してください。

基本的な考え方

まずR3の基本的な流れとして、次のような考え方があります。

  1. イベントの「発生源(ストリームソース)」を用意する
  2. 「1.」で作ったストリームソースをObservableとして取り回す
  3. Observableに必要に応じてオペレーターを連結する
  4. 「3.」で組み上がったObservableSubscribe()する
  5. 使い終わったら諸々をDispose()する

最初は「作る」「つなぐ」「購読する」の3段階として捉えれば十分です。細かな例外や応用はあとから覚えていけば問題ありません。

Observableの作り方

Subjectを使ってObservableを作ってみる

R3においてObservableを作るもっともプリミティブかつシンプルな方法はSubjectを使う方法です。

Subject<T>クラスはObservable<T>を継承しています。そのためインスタンス化したSubject<T>はそのままObservable<T>として利用できます。

using R3;
using UnityEngine;

namespace R3Samples.Introduction.CreateObservables
{
    public sealed class FromSubject : MonoBehaviour
    {
        private void Start()
        {
            // int型のSubjectを準備
            var subject = new Subject<int>();

            // Subject<T> は Observable<T> を継承しているので
            // そのままObservableとして扱うことができる
            Observable<int> observable = subject;

            // メッセージが発行されたらログに出す
            var subscription = observable.Subscribe(
                onNext: value => Debug.Log(value),
                onErrorResume: error => Debug.LogException(error),
                onCompleted: _ => Debug.Log("OnCompleted!")
            );

            // 1, 2, 3 を発行
            subject.OnNext(1);
            subject.OnNext(2);
            subject.OnNext(3);

            // OnErrorResumeを発行
            subject.OnErrorResume(new System.ArgumentException("引数が不正です"));

            // OnCompleted(Success)を発行
            subject.OnCompleted();

            // Subjectを破棄
            // (まだOnCompletedが発行されていなかったらこのタイミングで自動発行される)
            subject.Dispose();
            
            // 購読を破棄
            subscription.Dispose();
        }
    }
}

Subject<T>にはOnNext(T)OnErrorResume(Exception)OnCompleted(Result)の3つのメソッドが用意されています。これらを呼び出すことで、自分で好きなタイミングに値/エラー/完了を通知できます。

// 1, 2, 3 を発行
subject.OnNext(1);
subject.OnNext(2);
subject.OnNext(3);

// OnErrorResumeを発行
subject.OnErrorResume(new ArgumentException("引数が不正です"));

// OnCompleted(Success)を発行
subject.OnCompleted();

OnNextおよびOnErrorResumeは(Subjectが生きている間は)何回でも発行できます。ですがOnCompletedは1回しか発行できず、発行した時点でSubjectは動作を停止しそれ以降のメッセージ発行はできなくなります。


ObservableSubscribe()メソッドですが、各種メッセージに対するイベントハンドラーを登録できます。
次のコードはOnNext/OnErrorResume/OnCompletedの3つすべてを購読した場合です。

// OnNext, OnErrorResume, OnCompleted の3つすべてを購読する
observable.Subscribe(
    onNext: value => Debug.Log(value),
    onErrorResume: error => Debug.LogException(error),
    onCompleted: _ => Debug.Log("OnCompleted!")
);

すべてのメッセージを購読する必要がない場合、たとえばOnNextのみで十分である場合は次のように省略できます。

// OnNextのみを受信する
observable.Subscribe(value => Debug.Log(value));

Subscribe()の戻り値はIDisposableです。これをDispose()することでメッセージの待ち受けを中止できます。

// Subscribeの戻り値はIDisposable
var subscription = observable.Subscribe(
    onNext: value => Debug.Log(value),
    onErrorResume: error => Debug.LogException(error),
    onCompleted: _ => Debug.Log("OnCompleted!")
);

// これをDispose()することで購読を中止できる
subscription.Dispose();

なお、ObservableからOnCompletedが発行された場合は、自動的にSubscribeも終了します。

ここで重要なのは、OnCompletedは「発行側がこのObservableの利用終了を通知するもの」であり、Dispose()は「購読側が受信をやめるためのもの」という点です。そのためObservableを使用する場合は、可能な限り次の2つを満たすように全体を設計しておくことを推奨します。

  • 最後にOnCompletedを発行してObservableの完了を通知する
  • 最後にSubscribeDispose()を実行して購読を解除する

このあたりのより踏み込んだ、「Observableのライフサイクル」の話は次回以降の記事で解説予定です。

ReactivePropertyでObservableを作ってみる

Subjectはかなりプリミティブでシンプルな機能でした。
一方でより機能を追加して扱いやすくなっているものがReactivePropertyです。

ReactivePropertyは次の2つの機能を持っています。

  • 普通の変数として値の読み書きができる
  • 値の変化をObservableとして監視できる

ReactivePropertyは定義が簡単で使うのも簡単なため、R3に慣れてない方はまずこのReactivePropertyから触ってみることをオススメします。とくに実務では「まずReactivePropertyで状態を持たせる」ところから始まる場面も多いため、入門段階ではこの機能に慣れておくと後半のサンプルも追いやすくなります。

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

namespace R3Samples.Introduction.CreateObservables
{
    public sealed class FromReactiveProperty : MonoBehaviour
    {
        // フィールドにint型のReactivePropertyを定義、初期値は0にしておく
        private readonly ReactiveProperty<int> _count = new(0);
        private IDisposable _subscription;

        private void Start()
        {
            // ReactivePropertyの現在の値はプロパティから参照可能
            Debug.Log($"初期値は[{_count.CurrentValue}]です。");

            // _count の値の変化を購読
            _subscription = _count.Subscribe(value =>
            {
                // 値が変化したらそれをログに書き出す
                Debug.Log(value);
            });

            // ループ処理を起動
            LoopAsync(destroyCancellationToken).Forget();
        }

        /// <summary>
        /// ループ処理
        /// </summary>
        private async UniTaskVoid LoopAsync(CancellationToken token)
        {
            // 1秒に1回、_countをインクリメントする
            while (!token.IsCancellationRequested)
            {
                _count.Value++;

                await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
            }
        }

        private void OnDestroy()
        {
            // ReactivePropertyを破棄
            // 同時にOnCompletedが発行される
            _count?.Dispose();
            
            // 購読の終了
            _subscription?.Dispose();
        }
    }
}

今回重要な部分を抜き取って書き直すと次のようになります。

// int型のReactivePropertyを準備
// Observable<int>として振る舞える「変数」みたいなもの
ReactiveProperty<int> _count = new(0);

// ReactivePropertyの現在の値はプロパティから参照可能
Debug.Log($"初期値は[{_count.CurrentValue}]です。");

// _countの変化を購読する
_count.Subscribe(value =>
{
    // 値が変化したらそれをログに書き出す
    Debug.Log(value);
});

// _countの値を上書きする
// このタイミングでメッセージが発行される
_count.Value++;

// ---

// ReactivePropertyを破棄
// 同時にOnCompletedが発行される
_count?.Dispose();
  • ReactiveProperty<T>T型の変数でありつつObservable<T>として振る舞う
  • CurrentValueプロパティでいつでも現在の値を参照可能
  • Valueプロパティを上書きすると差分があったときのみ最新の値をOnNextメッセージとして発行する
  • ReactiveProperty<T>IDisposableであり、Dispose()することで破棄される
  • ReactiveProperty<T>Dispose()時にOnCompletedを発行する

補足: ReadOnlyReactiveProperty

ReactivePropertyをクラスの外に公開する場合、そのままでは書き込み可能なためクラス外から値を上書きされてしまうリスクがあります。

そういったときに使えるのがReadOnlyReactivePropertyです。これは名前のとおり「読み取りのみを許可したReactiveProperty」です。

ReactivePropertyReadOnlyReactivePropertyの派生クラスとして定義されています。そのためReadOnlyReactivePropertyに変換するときはそのままアップキャストすればOKです。

public sealed class FromReactiveProperty : MonoBehaviour
{
    // フィールドにint型のReactivePropertyを定義、初期値は0にしておく
    private readonly ReactiveProperty<int> _count = new(0);

    // 読み取りのみが可能にしてクラス外に公開する
    public ReadOnlyReactiveProperty<int> Count => _count;   
}

Update()をObservableにしてみる

ここまではSubjectReactivePropertyを使ってObservableを作る例を見てきましたが、Observableは既存のUnityイベントから作ることもできます。その例として、MonoBehaviourUpdate()Observableに変換する方法を紹介します。

using System;
using R3;
using R3.Triggers;
using UnityEngine;

namespace R3Samples.Introduction.CreateObservables
{
    public sealed class FromUpdate : MonoBehaviour
    {
        private IDisposable _subscription;

        private void Start()
        {
            _subscription = this.UpdateAsObservable()
                .Subscribe(_ => OnUpdateFromR3());
        }

        private void OnUpdateFromR3()
        {
            // 結果として、このメソッドが毎フレーム実行される
        }

        private void OnDestroy()
        {
            // 購読終了
            _subscription?.Dispose();
        }
    }
}

このコードでは「Update()のタイミングでメッセージを発行するObservableを作ってSubscribeし、メッセージ受信時にOnUpdateFromR3()を呼び出す。」という処理が書かれています。

このように、毎フレーム呼ばれる処理もObservableとして扱えるようにしておくと後にオペレーターで挙動を変更したりしやすくなります。

this.UpdateAsObservable()は「自身のComponentに紐づいたGameObjectUpdate()Observable<Unit>に変換する」という機能です。(thisは自分のcomponentを指しています。)

これはR3のUnity拡張モジュールが提供するTriggersと呼ばれる機能です。MonoBehaviour上で扱える多くのUnityイベントを拡張メソッド経由でObservableに変換することができとても便利です。

using R3;
using R3.Triggers;
using UnityEngine;

namespace R3Samples.Introduction.CreateObservables
{
    // Triggersの一例
    public sealed class Triggers : MonoBehaviour
    {
        private void Start()
        {
            // Update()の変換
            this.UpdateAsObservable()
                .Subscribe()
                // AddTo はDispose実行をこのGameObjectのDestroyに連動させる機能
                .AddTo(this);
            
            // FixedUpdate()
            this.FixedUpdateAsObservable()
                .Subscribe()
                .AddTo(this);
            
            // OnTriggerEnter
            this.OnTriggerEnterAsObservable()
                .Subscribe()
                .AddTo(this);
            
            // OnCollisionEnter
            this.OnCollisionEnterAsObservable()
                .Subscribe(collision =>
                {
                    // ぶつかった対象のGameObjectの
                    // Update()にHookする、みたいなこともできる
                    collision.gameObject
                        .UpdateAsObservable()
                        .Subscribe()
                        .AddTo(this);
                })
                .AddTo(this);
        }
    }
}

補足: 便利な AddTo / RegisterTo

R3には「AddTo」「RegisterTo」という便利な拡張メソッドが用意されています。Dispose()を毎回手で管理するのは地味に面倒で、書き漏らしも起こりやすいです。そういったときに寿命管理を簡単にしてくれるのが、これらの拡張メソッドです。

  • AddTo : IDisposable.Dispose()の呼び出しをGameObjectの寿命に連動させる
  • RegisterTo : IDisposable.Dispose()の呼び出しをCancellationTokenに連動させる

たとえば前述のReactivePropertyを用いたサンプルコードですが、AddToを使えばDispose呼び出しを次のように簡略化できます。

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

namespace R3Samples.Introduction.CreateObservables
{
    public sealed class FromReactivePropertyWithAddTo : MonoBehaviour
    {
        // フィールドにint型のReactivePropertyを定義、初期値は0にしておく
        private readonly ReactiveProperty<int> _count = new(0);

        private void Start()
        {
            // OnDestroy()時にReactivePropertyを破棄する
            _count.AddTo(this);
            
            Debug.Log($"初期値は[{_count.CurrentValue}]です。");

            _count.Subscribe(value =>
            {
                Debug.Log(value);
            }).AddTo(this); // OnDestroy時に購読終了

            LoopAsync(destroyCancellationToken).Forget();
        }

        private async UniTaskVoid LoopAsync(CancellationToken token)
        {
            while (!token.IsCancellationRequested)
            {
                _count.Value++;

                await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
            }
        }
    }
}

GameObjectに購読の寿命を紐づけたい」といったときにAddToはかなり便利に使えます。活用してみてください。

オペレーターの使い方

Where/Selectでフィルタリングと変換を行う

オペレーターを使うと、Observableが発行するメッセージを途中で加工できます。

とくによく使うのがWhereSelectです。

  • Where : 条件に一致したメッセージだけを通過させる
  • Select : メッセージの値を別の形に変換する

次の例では、衝突イベントをObservableとして扱い、WhereでPlayerとの衝突だけを通したうえで、SelectRigidbodyだけを取り出しています。

using R3;
using R3.Triggers;
using UnityEngine;

namespace R3Samples.Introduction.Operators
{
    public sealed class WhereSelectSample : MonoBehaviour
    {
        private void Start()
        {
            // 衝突対象がPlayerならRigidbodyを取り出してAddForceする
            this.OnCollisionEnterAsObservable()
                // 衝突対象がPlayerタグであるか?
                .Where(col => col.gameObject.CompareTag("Player"))
                // Rigidbodyを取り出す
                .Select(col => col.rigidbody)
                // Rigidbodyがnullでないか?
                .Where(rig => rig != null)
                // Rigidbodyに力を加える
                .Subscribe(rig =>
                {
                    rig.AddForce(Vector3.up);
                })
                .AddTo(this);
        }
    }
}

このように、Unityのイベントから受け取った情報に対して「対象を絞る」「必要な情報だけを取り出す」という流れは実務でもよく使います。WhereSelectは、多くのObservable処理の基本形となるので覚えておきましょう。

ThrottleFirstで流量を絞る

ThrottleFirstは、一定時間内に最初に来たメッセージだけを通し、それ以降に来たメッセージを無視するオペレーターです。

たとえば「ボタン入力を受け付けるが、短時間で何回も反応してほしくない」といったケースで便利です。

次の例では、スペースキー入力をObservableとして扱い、0.5秒以内の連打を無効にしています。

using System;
using R3;
using R3.Triggers;
using UnityEngine;

namespace R3Samples.Introduction.Operators
{
    public sealed class ThrottleFirstSample : MonoBehaviour
    {
        private void Start()
        {
            this.UpdateAsObservable()
                // スペースキーが押されたフレームだけ通す
                .Where(_ => Input.GetKeyDown(KeyCode.Space))
                // 0.5秒以内の連打は無視
                .ThrottleFirst(TimeSpan.FromSeconds(0.5f))
                .Subscribe(_ =>
                {
                    Debug.Log("スペースキーが押されました");
                })
                .AddTo(this);
        }
    }
}

このようにThrottleFirstを使うと「短時間に集中した入力だけを間引く」といった制御を簡単に書けます。

Observableをawaitしてみる

FirstAsyncで条件を満たすまで待つ

R3のObservableは、Subscribe()するだけでなく、オペレーターで加工したうえでawaitして扱うこともできます。

その代表的なものがFirstAsyncです。これは「条件を満たした最初の1件が流れてくるまで待つ」というものです。

次の例では、プレイヤーが奈落に落下するまで待機し、その条件を満たしたタイミングでGameObjectを削除しています。

using System.Threading;
using Cysharp.Threading.Tasks;
using R3;
using R3.Triggers;
using UnityEngine;

namespace R3Samples.Introduction.AwaitObservable
{
    public sealed class FirstAsyncSample : MonoBehaviour
    {
        private void Start()
        {
            // async/awaitの待機を開始
            WaitPlayerFallenAsync(destroyCancellationToken).Forget();
        }

        // 奈落(yが-5m以下)に落ちるまで待機する
        private async UniTaskVoid WaitPlayerFallenAsync(CancellationToken ct)
        {
            // 毎フレームチェック
            await this.UpdateAsObservable()
                // 座標に変換
                .Select(_ => transform.position)
                // y座標が-5以下という条件を最初に満たすまで待機
                .FirstAsync(
                    pos => pos.y <= -5f, cancellationToken: ct);
            
            // 奈落に落ちたので破棄する
            Destroy(gameObject);
        }
    }
}

このようにFirstAsyncを使うと、毎フレーム状態を監視してif文で分岐するのではなく、「条件を満たすまで待つ」という形で処理を書けるようになります。

R3を用いたより実践的なコード紹介

ここまでがR3の基礎的な使い方の確認です。ここからは、実際のゲーム実装の中でR3がどう活きるかを、もう少し実務寄りの例で見ていきます。

サンプルゲーム

SampleGame1.jpg

せっかくなので解説のためにR3を使ったちょっとしたゲームを用意しました。このゲームでの実装を例に説明します。

今回のサンプルゲーム全体の実装コードは、以下のGitHubリポジトリで公開しています。記事内では要点を抜粋して紹介しているため、全体の構成や前後の実装も見たい場合はあわせて参照してください。

ゲームの仕様

  • プレイヤーキャラクターを操作して敵を倒すゲーム
  • 「コマンド入力 + 攻撃ボタン」でプレイヤーは多様な技が使える
    • 以下はプレイヤーが右を向いている時
      • コマンド無し:シンプルなパンチ
      • ↓↘→: 火炎球を発射
      • →↓↘ or →↓↘* :アッパーパンチ
      • ←↙↓↘→:スピンアタック
  • プレイヤーは敵に触れるとダメージを受ける
  • プレイヤーは体力がゼロになるとやられてゲームオーバー
  • 敵は倒すたびに自動生成される
  • 敵を倒すごとに敵の数と強さが増す

設計思想

  • classは細くわけて作る
  • それぞれのclassがR3によってリアクティブに動作することで全体の動作を作るようにする
  • async/awaitで書ける部分はそちらを優先し、R3を使う必要が無い場面でR3は使わない
  • DIコンテナは使わずにEditor上で全部紐づける
  • 多少の実装の粗は許容する(ガチガチの最適化は諦める)

例:「状態」と「演出」をR3で連動させる

ReactivePropertyを使うことで「状態の変化を外部に通知する」ことができます。

これを活用することで状態の管理のみを責務としたクラスと、それに応じた演出を行うクラスを分けて定義するような設計が簡単に実現できます。

このサンプルゲームでは次のように責務を分けてクラスを定義し、それらをR3で繋いでいます。

  • 状態を管理するPlayerCore
  • アニメーションを再生するPlayerAnimation
  • 効果音の再生を行うPlayerSoundEffectPlayer

PlayerCore.jpg

この例で重要なのは、「状態を管理する責務」と「見た目や演出を更新する責務」を分けている点です。ダメージ判定や体力管理は状態管理クラスが担当し、その結果を別クラスが購読してアニメーションや演出に反映します。こうしておくことで、ゲームロジックと演出の関心を分離しやすくなり、テストや差し替えもしやすくなります。


たとえば「ダメージを受けたときにアニメーションを再生して効果音の再生を行う」は次のような実装になっています。

PlayerDamage.gif

まずPlayerDamageHandler側で「いまプレイヤーがどのダメージ状態にあるか」を管理します。そしてその状態をPlayerCoreというファサードクラスを介してReadOnlyReactivePropertyとして公開し、別のクラスがそれを購読してアニメーションや演出を切り替えます。つまり、状態を作る側と状態を使う側を分離しています。

// ダメージ状態(プレイヤーの操作に影響する)
public enum PlayerDamageState
{
    /// <summary>
    /// 通常状態、攻撃や操作可能
    /// </summary>
    None,

    /// <summary>
    /// ダメージで吹っ飛び状態、操作不能
    /// </summary>
    Damaged,

    /// <summary>
    /// 気絶、操作不能
    /// </summary>
    Fainted
}
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;

namespace R3Samples.Introduction.SampleGame
{
    // プレイヤーの状態を提供するFacade的なクラス
    public sealed class PlayerCore : MonoBehaviour, IDamageable
    {
        /* (省略) */

        // ダメージ処理の委譲先
        private PlayerDamageHandler _playerDamageHandler;

        // 現在のプレイヤーのダメージ状態を公開する
        public ReadOnlyReactiveProperty<PlayerDamageState> DamageState => _playerDamageHandler.DamageState;

        /* (省略) */

        // ダメージを受けたときに実行される処理
        public void OnDamaged(Damage damage)
        {
            // PlayerDamageHandlerに実際の処理は委譲
            // 無敵時間とかいろいろ確認して最終的にダメージを受けたら
            // DamageStateに反映されてイベントが飛ぶ
            var result = _playerDamageHandler.ApplyDamage(damage, CancelAttack);
            if (!result.Accepted) return;

            ApplyDirection(result.NextDirection);
        }

        /* (省略) */

    }
}
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;
using Random = UnityEngine.Random;

namespace R3Samples.Introduction.SampleGame
{
    /// <summary>
    /// プレイヤーの見た目とアニメーション遷移を担当
    /// 向き、歩行、空中状態、被ダメージ状態をAnimatorに反映
    /// </summary>
    public sealed class PlayerAnimation : MonoBehaviour
    {
        [SerializeField] private Transform _spriteRoot;

        private PlayerCore _playerCore;
        private PlayerMover _playerMover;
        private Animator _animator;
        private AnimationEventDetector _animationEventDetector;
        private SpriteRenderer _spriteRenderer;

        private readonly string IsWalking = "IsWalking";
        private readonly string JumpUpTrigger = "JumpUp";
        private readonly string IsFalling = "IsFalling";
        private readonly string IsGrounded = "IsGrounded";

        private void Start()
        {
            _animationEventDetector = GetComponentInChildren<AnimationEventDetector>();
            _spriteRenderer = _spriteRoot.GetComponentInChildren<SpriteRenderer>();
            _playerCore = GetComponent<PlayerCore>();
            _animator = GetComponentInChildren<Animator>();
            _playerMover = GetComponent<PlayerMover>();

            /* (省略) */

            // Playerのダメージ状態に応じてAnimatorのステートを変更する
            _playerCore.DamageState
                .Subscribe(x =>
                {
                    switch (x)
                    {
                        case PlayerDamageState.None:
                            ResetAnimation();
                            break;
                        case PlayerDamageState.Damaged:
                            _animator.Play("PlayerDamaged");
                            break;
                        case PlayerDamageState.Fainted:
                            _animator.Play("PlayerFainted");
                            break;
                        default:
                            throw new ArgumentOutOfRangeException(nameof(x), x, null);
                    }
                })
                .AddTo(this);
        }

        /* (省略) */

        public void ResetAnimation()
        {
            _animator.Rebind();
            _animator.Update(0);
        }
    }
}
using R3;
using UnityEngine;

namespace R3Samples.Introduction.SampleGame
{
    /// <summary>
    /// プレイヤーの行動イベントを監視し、効果音再生を一元管理する。
    /// </summary>
    public sealed class PlayerSoundEffectPlayer : MonoBehaviour
    {
        [SerializeField] private AudioSource _audioSource;
        [SerializeField] private AudioClip _damageClip;
        [SerializeField] private AudioClip _faintedClip;

        private PlayerCore _playerCore;

        /* (省略) */


        private void Awake()
        {
            _playerCore = GetComponent<PlayerCore>();
        }

        private void Start()
        {
            /* (省略) */

            // ダメージを受けて状態が変化したらそれに応じた効果音を再生
            _playerCore.DamageState
                .DistinctUntilChanged()
                .Subscribe(damageState =>
                {
                    switch (damageState)
                    {
                        case PlayerDamageState.Damaged:
                            PlayOneShot(_damageClip);
                            break;
                        case PlayerDamageState.Fainted:
                            PlayOneShot(_faintedClip);
                            break;
                    }
                })
                .AddTo(this);
        }

        private void PlayOneShot(AudioClip clip)
        {
            if (_audioSource == null || clip == null)
            {
                return;
            }

            _audioSource.PlayOneShot(clip);
        }
    }
}

このように状態をReactiveProperty経由で公開しておくと、演出側は毎フレーム状態を確認しなくても、変化が起きたタイミングだけを受け取って処理できます。状態の変化に応じて必要な処理だけを実行できるため、実装の意図も読みやすくなります。

例: UIの制御

R3を使うとModel-View-(Reactive)Presenterパターン(MV(R)Pパターン)という設計パターンでUIの制御を構築できます。

詳しくはこちらの記事で解説しています。UniRx用の記事ですがR3でもほぼ同じ考え方で応用できます。

MV(R)Pパターンをかなり雑に説明すると、「Modelが状態を持ち」「Presenterがその状態を受け取って」「Viewに表示を反映する」という役割分担です。R3を使うと、この「状態の変化を受け取って表示を更新する」という流れを自然に書きやすくなります。

今回のサンプルゲームでは「スコア表示」「レベル表示」「プレイヤーの体力表示」「ゲームオーバー画面」でMV(R)Pパターンを使用しています。

スコアや体力のような値は、ゲーム中に変化したタイミングでそのままUIへ反映したい情報です。そのためR3との相性がよく、「状態の更新」と「表示の更新」を素直に結びつけることができます。

UI.jpg

たとえば体力の表示は次です。

// PlayerCore.cs

using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;
using UnityEngine.Serialization;

namespace R3Samples.Introduction.SampleGame
{
    /// <summary>
    /// プレイヤー全体の公開状態を集約し、各プレイヤー用コンポーネントを束ねる責務を持つ
    /// 向き、行動状態、被ダメージ窓口を統合して、外部から参照しやすい形で公開する
    /// </summary>
    public sealed class PlayerCore : MonoBehaviour, IDamageable
    {
        // ダメージ処理の委譲先
        private PlayerDamageHandler _playerDamageHandler;

        // プレイヤーの体力値(0~3)をReadOnlyなReactivePropertyで公開する
        public ReadOnlyReactiveProperty<int> PlayerHealth => _playerDamageHandler.PlayerHealth;
    }
}

ここではプレイヤーの体力をReadOnlyReactiveProperty<int>として公開しています。これにより外部からは値の変化を監視できる一方で、値の書き換えは内部実装に閉じ込めることができます。

UI側はこのReadOnlyReactiveProperty<int>を購読することで、体力値が変化したタイミングで表示を更新できます。状態の保持はModel側に任せ、表示更新だけをUI側が担当できるのがポイントです。

// PlayerPresenter.cs
using R3;
using UnityEngine;
using UnityEngine.UI;

namespace R3Samples.Introduction.SampleGame
{
    // プレイヤーの状態をUIに反映する
    public class PlayerPresenter : MonoBehaviour
    {
        [SerializeField] private PlayerCore _playerCore;
        [SerializeField] private GameObject _playerHearts;

        // ハートの画像たち
        private Image[] _heartImages;

        private void Start()
        {
            _heartImages = _playerHearts.GetComponentsInChildren<Image>();

            // プレイヤー体力の変動に応じて実行
            _playerCore.PlayerHealth
                .Subscribe(health =>
                {
                    for (var i = 0; i < _heartImages.Length; i++)
                    {
                        // 残りの体力数に合わせてハートを黒塗りする
                        _heartImages[i].color = i < health ? Color.white : Color.black;
                    }
                })
                .AddTo(this);
        }
    }
}

PlayerPresenterPlayerCoreを監視し、状態変化に応じて即座にUIを更新しています。

例: AnimationEventをawaitできるようにする

UnityにはAnimationEventという機能があります。これはアニメーションの特定のタイミングに合わせてメソッドを呼び出してくれる機能です。今回はこの「メソッド呼び出し」を「Observableのメッセージ発行」に変換して扱います。こうしておくことで、後続の処理をR3のオペレーターやasync/awaitの流れに統一して書きやすくなります。

たとえば「アニメーションの特定タイミングを待ってから次の処理をしたい」という場面を、コールバックの入れ子ではなくasync/awaitを用いた上から順番に読めるコードで書けるようになります。


今回のサンプルゲームでは次のように、「大砲が敵を発射するとき、アニメーションのいい感じのタイミングで敵を生成する」ということをしています。

Cannon.gif

これは大砲のAnimationClipいい感じのフレームAnimationEventを定義し、これを使ってスクリプト側からアニメーションのタイミングを知ることができるようにしています。

AnimationEvent.jpg

(アニメーション再生時にこのフレームに差し掛かるとOnAnimationEvent(勝手に定義したメソッド)が自動で実行される)


今回のサンプルゲームではこのAnimationEventを使った処理がいくつかあります。そのAnimationEventの受け口をオブジェクトごとに毎回個別実装していると、似たような処理が散らばってしまいます。そこで今回は、AnimationEventObservable化する役割だけを持つ汎用クラスを作り、各所で使い回せるようにしています。

using R3;
using UnityEngine;

namespace R3Samples.Introduction.SampleGame
{
    public class AnimationEventDetector : MonoBehaviour
    {
        private readonly Subject<Unit> _animationEventSubject = new Subject<Unit>();
        private readonly Subject<Unit> _animationEndSubject = new Subject<Unit>();

        // OnAnimationEvent が実行されたら通知する
        public Observable<Unit> AnimationEvent => _animationEventSubject;

        // OnAnimationEnded が実行されたら通知する
        public Observable<Unit> AnimationEnded => _animationEndSubject;

        private void Start()
        {
            _animationEndSubject.AddTo(this);
            _animationEventSubject.AddTo(this);
        }

        // Animation再生中に実行される
        // 命名は適当にしたものなのでこの名前でなくてもいい
        public void OnAnimationEvent()
        {
            _animationEventSubject?.OnNext(Unit.Default);
        }

        // Animationが再生し終わったときに実行される
        // これもAnimationEventを使って自身で定義したもの
        public void OnAnimationEnded()
        {
            _animationEndSubject?.OnNext(Unit.Default);
        }
    }
}

AnimationEventDetector.jpg

(例:アニメーションの最終フレームにAnimationEventを定義して、AnimationEventDetector.OnAnimationEnded()を呼び出す)

こうしてAnimationEventObservable<Unit>として扱えるようにしておくと、「特定のタイミングが来た」という事実だけをシンプルに受け取れるようになります。ここでは値そのものに意味はなく、「通知が来たこと」だけが重要なのでUnit型を用いています。

AnimationEventDetectorによってAnimationEventを単なるObservable<Unit>に変換できたので、あとはこれを使ってスクリプトを書きます。

// CannonCore.cs より
// 関係ない実装は省いて今回の中核だけ抜粋

/// <summary>
/// 敵の発射ロジック
/// </summary>
private async UniTask CreateEnemyAsync(CancellationToken ct)
{
    // CannonShotのアニメーション再生開始
    _animator.Play("CannonShot");

    // OnAnimationEventのタイミングまで待つ
    await _animationEventDetector
            .AnimationEvent
            .FirstOrDefaultAsync(cancellationToken: ct);
    
    // 敵を発射!
    InstantiateEnemy();

    // Animationが完了する(OnAnimationEnded)まで待つ
    await _animationEventDetector
            .AnimationEnded
            .FirstAsync(ct);

    // Animatorを初期化しておわり
    _animator.Rebind();
    _animator.Update(0);
}

AnimationClipAnimationEventを設定するのがやや面倒くさいですが、アニメーションとスクリプトの連動をasync/awaitで見通しよく書くことができます。

例: async/awaitと組み合わせて状態の変動を監視する

AnimationEventの例とほぼ同じですが、ObservableReactivePropertyawaitすることで状態の変動を待つことができます。

たとえば次はプレイヤーが地面に触れているかを表す「ReadOnlyReactiveProperty<bool> IsGrounded」を用いて、プレイヤーの空中での状態を判定している処理です。

ここで使っているFirstAsyncは、「条件を満たす値が流れてくるまで待つ」と考えると分かりやすいです。毎フレーム状態を監視するのではなく、必要な状態変化が起きたタイミングで処理を再開できます。

// Playerの接地・上昇中・下降中の判定を行う
private async UniTaskVoid CheckPlayerAerialAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        // 着地まで待つ
        await IsGrounded.FirstAsync(x => x, ct);

        // 着地したのでGrounded
        _playerAerial.Value = PlayerAerial.Grounded;

        // 地面から離れるまで待つ
        await IsGrounded.FirstAsync(x => !x, ct);

        // 離れたときの速度が上向きなら
        if (CurrentVelocity.y > 0f)
        {
            // 上昇中扱いにする
            _playerAerial.Value = PlayerAerial.Rising;

            // 落下し始めるまで待つ
            await UniTask.WaitWhile(IsStillRising, cancellationToken: ct);

            // 着地判定が発生していたら終了
            if (IsGrounded.CurrentValue)
            {
                continue;
            }
        }

        // 落下開始
        _playerAerial.Value = PlayerAerial.Falling;
    }

    return;

    bool IsStillRising()
    {
        return CurrentVelocity.y >= 0f
                && !IsGrounded.CurrentValue;
    }
}

PlayerAir.gif

(空中でのアニメーション切り替えに使ってます)

この処理では、接地したかどうかの状態変化を順番に待つことで、プレイヤーが「地上にいる」「空中にいる」「着地した」といった流れを手続き的に記述しています。状態遷移をif文で毎フレーム追うのではなく、「async/awaitを使って状態が変化するまで待つ」という形で書けるのがポイントです。

このように状態をReactivePropertyで管理しておくことでR3のオペレーターも使える上に、async/awaitに自然に接続して扱うことができるようになります。

例: 他のGameObjectで発火したOnTriggerEnterなどを検知する

Unityの通常のイベント関数は、どのGameObjectComponentがアタッチされているかで受け取れるイベントが変化する場合があります。

そのため、責務を分けたいときや、別のクラスでイベントを扱いたいときには少し取り回しづらさがあります。ここでTriggersを使うと、必要な場所からイベントをObservableとして受け取りやすくなります。

今回のサンプルゲームでは攻撃処理でのOnTriggerEnter2Dの待ち受け処理がこれに該当しています。

というのも、コライダー衝突系イベントは「そのコライダーがアタッチされているGameObjectに届く」という仕様になっています。そのため「親のGameObjectにスクリプトがアタッチされており、その子にコライダーがある」といった場合に、親のスクリプト側でOnTriggerEnter2Dを受け取ることができません。

Collider2D.jpg

(攻撃のヒットボックスをネストしたGameObjectで管理している)

この問題をcomponent.OnTriggerEnter2DAsObservable()で解決しています。

// PlayerAttack.cs

// Hitboxコライダーの衝突イベントの登録
// settings.Collider にヒットボックスのコライダーが入っている
private void RegisterAttackHitbox(AttackHitboxSettings settings)
{
    // 対象のColliderから発行された OnTriggerEnter2D を購読する
    settings.Collider.OnTriggerEnter2DAsObservable()
        .Subscribe(c =>
        {
            /* (ダメージを与える処理、省略) */
        })
        .AddTo(this);
}

Triggers対象となるComponentがアタッチされたGameObject上のイベントをObservable化するという機能です。これを使うことで、親や子どころかヒエラルキー上でまったく関係ないGameObjectのイベントもTriggersを使うことで取得できるようになります。

これにより、「イベントを発火するオブジェクト」と「イベントを処理するオブジェクト」を無理に密結合にせずに済みます。依存関係の向きを崩さずにイベントを扱えるのが、設計上の大きな利点です。

まさに今回のような「外からOnTriggerEnter2Dを受け取る」といったケースに最適です。


ObservableTrigger.jpg

(仕組みはかなり単純で、対象のGameObjectにComponentをアタッチしてそれを仲介してイベントを受け取ってるだけです)

例:Inputの管理を一元化する

R3を用いることで、ユーザーからの操作入力を受け付ける機構の実装をひとつのクラスに集約できます。

まず次のようなインタフェース定義および実装を準備します。

using R3;

namespace R3Samples.Introduction.SampleGame.Utils
{
    /// <summary>
    /// ユーザーの入力操作を提供する
    /// </summary>
    public interface IInputEventProvider
    {
        // 8方向入力
        public ReadOnlyReactiveProperty<InputDirection> Direction { get; }
        public ReadOnlyReactiveProperty<bool> IsJumpButton { get; }
        public ReadOnlyReactiveProperty<bool> IsAttackButton { get; }
    }
    
    // 8方向入力
    public enum InputDirection
    {
        None,
        Up,
        UpRight,
        Right,
        Down,
        DownRight,
        DownLeft,
        Left,
        UpLeft,
    }
}

インタフェースの定義を行ったらこれを実装するクラスを用意します。今回はInputSystemを用いた実装とし、これをシングルトンとしてシーンに配置して使用します。

using UnityEngine;
using UnityEngine.InputSystem;
using R3;

namespace R3Samples.Introduction.SampleGame
{
    /// <summary>
    /// プレイヤー入力を読み取り、方向入力、ジャンプ、攻撃の状態を公開する責務を持つ
    /// キーボードとゲームパッドの差異はここで吸収する
    /// </summary>
    public class InputSystemInputEventProvider : MonoBehaviour, IInputEventProvider
    {
        [SerializeField] private float _deadZone = 0.3f;

        private readonly ReactiveProperty<InputDirection> _direction = new(InputDirection.None);
        private readonly ReactiveProperty<bool> _isJump = new(false);
        private readonly ReactiveProperty<bool> _isAttack = new(false);
        
        private InputAction _moveAction;
        private InputAction _jumpAction;
        private InputAction _attackAction;

        public ReadOnlyReactiveProperty<InputDirection> Direction => _direction;
        public ReadOnlyReactiveProperty<bool> IsJumpButton => _isJump;
        public ReadOnlyReactiveProperty<bool> IsAttackButton => _isAttack;

        private void Awake()
        {
            _moveAction = CreateMoveAction();
            _jumpAction = CreateJumpAction();
            _attackAction = CreateAttackAction();

            _moveAction.performed += OnMovePerformed;
            _moveAction.canceled += OnMoveCanceled;
            _jumpAction.performed += OnJumpPerformed;
            _jumpAction.canceled += OnJumpCanceled;
            _attackAction.performed += OnAttackPerformed;
            _attackAction.canceled += OnAttackCanceled;
        }

        private void OnEnable()
        {
            _moveAction.Enable();
            _jumpAction.Enable();
            _attackAction.Enable();
        }

        private void OnDisable()
        {
            _moveAction.Disable();
            _jumpAction.Disable();
            _attackAction.Disable();
        }

        private void OnDestroy()
        {
            _moveAction.performed -= OnMovePerformed;
            _moveAction.canceled -= OnMoveCanceled;
            _jumpAction.performed -= OnJumpPerformed;
            _jumpAction.canceled -= OnJumpCanceled;
            _attackAction.performed -= OnAttackPerformed;
            _attackAction.canceled -= OnAttackCanceled;

            _moveAction.Dispose();
            _jumpAction.Dispose();
            _attackAction.Dispose();
        }

        private static InputAction CreateMoveAction()
        {
            var action = new InputAction("Move", InputActionType.Value);
            action.AddCompositeBinding("2DVector")
                .With("Up", "<Keyboard>/w")
                .With("Down", "<Keyboard>/s")
                .With("Left", "<Keyboard>/a")
                .With("Right", "<Keyboard>/d");
            action.AddCompositeBinding("2DVector")
                .With("Up", "<Keyboard>/upArrow")
                .With("Down", "<Keyboard>/downArrow")
                .With("Left", "<Keyboard>/leftArrow")
                .With("Right", "<Keyboard>/rightArrow");
            action.AddBinding("<Gamepad>/leftStick");
            return action;
        }

        private static InputAction CreateJumpAction()
        {
            var action = new InputAction("Jump", InputActionType.Button);
            action.AddBinding("<Keyboard>/space");
            action.AddBinding("<Gamepad>/buttonSouth");
            return action;
        }

        private static InputAction CreateAttackAction()
        {
            var action = new InputAction("Attack", InputActionType.Button);
            action.AddBinding("<Keyboard>/z");
            action.AddBinding("<Gamepad>/buttonWest");
            return action;
        }

        private void OnMovePerformed(InputAction.CallbackContext context)
        {
            var input = context.ReadValue<Vector2>().normalized;
            _direction.OnNext(CheckDirection(input, _deadZone));
        }

        private void OnMoveCanceled(InputAction.CallbackContext _)
        {
            _direction.OnNext(InputDirection.None);
        }

        private void OnJumpPerformed(InputAction.CallbackContext _)
        {
            _isJump.OnNext(true);
        }

        private void OnJumpCanceled(InputAction.CallbackContext _)
        {
            _isJump.OnNext(false);
        }

        private void OnAttackPerformed(InputAction.CallbackContext _)
        {
            _isAttack.OnNext(true);
        }

        private void OnAttackCanceled(InputAction.CallbackContext _)
        {
            _isAttack.OnNext(false);
        }

        private static InputDirection CheckDirection(Vector2 input, float deadZone)
        {
            if (input.magnitude < deadZone)
            {
                return InputDirection.None;
            }

            var angle = Mathf.Atan2(input.y, input.x) * Mathf.Rad2Deg;
            if (angle < 0f) angle += 360f;

            if (angle >= 337.5f || angle < 22.5f) return InputDirection.Right;
            if (angle < 67.5f) return InputDirection.UpRight;
            if (angle < 112.5f) return InputDirection.Up;
            if (angle < 157.5f) return InputDirection.UpLeft;
            if (angle < 202.5f) return InputDirection.Left;
            if (angle < 247.5f) return InputDirection.DownLeft;
            if (angle < 292.5f) return InputDirection.Down;
            return InputDirection.DownRight;
        }
    }
}

各クラスで入力イベントが必要になったときはこのIInputEventProviderから入力値を受け取る形にすることで、入力イベントに対する実装が散らかってしまうのを防ぐことができます。

今回のようなInputSystemへの依存を一箇所にまとめておくと、ゲームロジック側は「入力をどう受け取るか」だけに集中でき、InputSystem固有の実装詳細を知らずに済みます。これにより責務分離がしやすくなり、テスト用実装にも差し替えやすくなります。

またインタフェース定義を挟むことで、実行時はInputSystem由来の入力を流し、テスト時は任意の入力を流す、といった差し替えが簡単にできます。後にテストコードの解説を行いますが、テストで扱いやすいのもこの抽象化を挟んでいるためです。

例:コマンド入力の判定を実装する

このサンプルゲームではコマンド入力でプレイヤーはさまざまな技を出すことができます。これは「方向入力の履歴と攻撃ボタンの入力タイミングの組み合わせより、発生する技を変更する」という機能です。格闘ゲームでよくあるやつですね。

Attacks.gif

  • プレイヤーが右を向いている時
    • コマンドなし + 攻撃:シンプルなパンチ
    • ↓↘→ + 攻撃: 火炎球を発射
    • (→↓↘ or →↓↘*) + 攻撃 :アッパーパンチ
    • ←↙↓↘→ + 攻撃:スピンアタック

(遊んだときにアッパーパンチと火炎球の出し分けが難しかったので、アッパーパンチの判定だけ少し緩めています。具体的にはコマンドの最後に余計な入力が混ざってもOKにしています。)

さてこのコマンド入力ですが、実はもう少し細かい仕様があります。

  • それぞれのコマンドには入力猶予フレーム(60fps換算)がある
  • コマンド成立から攻撃ボタンが押されるまでも入力猶予フレームがある
  • 入力猶予フレームを超えた入力は無効とする

たとえば「アッパーパンチ攻撃は20F以内にコマンド入力を成立させないといけない」といった具合です。

手続き的にこれを実装するとかなり面倒くさいのですが、R3を使うとかなりコンパクトに書くことができます。

方向キー入力からのコマンド判定部分

次のコードは、「方向入力の変化を履歴として保持し、その履歴が特定のならびになったときにコマンド成立とみなす」という処理を行っています。

ただしコマンド入力では、どの方向が入力されたかだけでなく、「どの順番で」「どのくらいの間隔で」入力されたかも重要です。そのため、入力を単に受け取るだけではなく、時刻情報を付けたうえで履歴としてまとめて扱う必要があります。

そこでメッセージをフィルタリング、時刻情報を付与、それをさらにまとめる、といった一連の処理を、R3のオペレーターを使って実現しています。

// PlayerCommandDetector.cs
private void Initialize(IInputEventProvider inputEventProvider, TimeProvider timeProvider)
{
    _timeProvider = timeProvider;

    // ゼロ埋めに使うデータ定義
    var zeroPaddingData 
        = Enumerable.Repeat(new TimedDirection(0, InputDirection.None), 5)
            .ToArray();
    
    // 8方向入力の変化のみを取り出す
    var directionChanges = inputEventProvider.Direction
        // 差分のみを検知
        .DistinctUntilChanged()
        // 無入力は無視する
        .Where(direction => direction != InputDirection.None);

    // 入力にタイムスタンプを付与する
    var timedDirections = directionChanges
        .Timestamp(timeProvider)
        .Select(x => new TimedDirection(x.Timestamp, x.Value));

    // 直近の5回分の入力を保持する
    var commandWindows = timedDirections
        // Chunkを満たして即コマンド判定できるようにしておく
        .Prepend(zeroPaddingData)
        // 5件ずつ取り出しつつ1件ずつ前にずらして判定できるようにする
        .Chunk(5, 1);

    // 直近の5回分の入力をもとにコマンドの判定処理を行う
    commandWindows
        .Subscribe(CheckCommands)
        .RegisterTo(_cts.Token);
}

/// <summary>
/// 入力コマンドの配列を比較して技が成立したかを見る
/// </summary>
/// <param name="commands"></param>
private void CheckCommands(TimedDirection[] commands)
{
    var commandSpan = commands.AsSpan();

    if (TryDetectSpinAttack(commandSpan)) return;
    if (TryDetectUpperPunchWithTrailingInput(commandSpan)) return;
    if (TryDetectFireShotOrUpperPunch(commandSpan)) return;
}

m_03.jpg

なおこの判定は「コマンド入力が成立したか」しか確認しておらず、最終的に「コマンド成立直後に攻撃ボタンを押したか」という判定も行う必要があります。

まず今回の実装では「コマンドの成立状態」を表すCommandMoveを定義し、コマンド成立時にObservable<CommandMove>で成立状況を通知するようにしています。

// コマンド入力一覧
public enum CommandMove
{
    // コマンド不成立
    // 初期状態として利用
    None,

    // アッパーパンチ
    UpperPunch,

    // スピンアタック
    SpinAttack,

    // 火炎球
    FireShot
}
public sealed class PlayerCommandDetector : IDisposable
{
    private readonly Subject<CommandMove> _commandMoveSubject = new();
    
    // コマンドが成立したときに通知する
    // 購読時にかならず初期値として None を発行することで
    // 技が暴発することを防ぐ
    public Observable<CommandMove> CommandMove => _commandMoveSubject.Prepend(SampleGame.CommandMove.None);

    /* (省略) */
}

攻撃ボタンを押したタイミングでコマンドの判定を行う

ここでは攻撃ボタンが押された瞬間に「直前までにどのコマンドが成立していたか」を参照し、通常攻撃にするのか必殺技にするのかを判定しています。

入力履歴の判定結果(Observable<CommandMove>)と実際の攻撃入力(IInputEventProvider.IsAttackButton)をここで合流させています。

// PlayerAttackController.csより抜粋

private void SetUpAttack()
{
    var timeProvider = UnityTimeProvider.Update;

    var detectedCommands = _playerCommandDetector.CommandMove
        .Where(move => move != CommandMove.None)
        // コマンド入力が成立した瞬間、コマンド内容とそのときの時刻を保持する
        .Timestamp(timeProvider)
        .Select(x => new DetectedCommand(x.Value, x.Timestamp))
        .Prepend(DetectedCommand.None);

    var attackPressed = inputSystemInputEventProvider.IsAttackButton
        .DistinctUntilChanged()
        .Where(x => x)
        // 攻撃ボタンが押された時刻を付与
        .Timestamp(timeProvider)
        .Select(x => new AttackInput(x.Timestamp));

    var attackRequests = attackPressed
        // 攻撃開始は通常操作可能かつ接地中のときだけ許可
        .Where(_ => CanStartAttack())
        // 攻撃ボタンが押されたら直近のコマンド成立状態をもってくる
        .WithLatestFrom(detectedCommands, (input, command) => new AttackRequest(input, command));

    var attackTypes = attackRequests
        // コマンド入力および入力時刻から
        // 発生する技を決定する
        .Select(request => ResolveAttackType(request, timeProvider));

    attackTypes
        // 技が完遂するからキャンセルされるまで次の攻撃操作は無視(AwaitOperation.Drop)
        .SubscribeAwait(async (attackType, _) =>
        {
            // 新しい攻撃を始める前に、前回の攻撃処理を中断できるトークンを作り直す。
            using var attackCancellation = CreateAttackCancellationTokenSource();

            switch (attackType)
            {
                case PlayerAttackType.Punch:
                    await NormalPunchAsync(attackCancellation.Token);
                    break;
                case PlayerAttackType.UpperPunch:
                    await UpperPunchAsync(attackCancellation.Token);
                    break;
                case PlayerAttackType.SpinAttack:
                    await SpinAttackAsync(attackCancellation.Token);
                    break;
                case PlayerAttackType.FireShot:
                    await FireShotAsync(attackCancellation.Token);
                    break;
                default:
                    throw new ArgumentOutOfRangeException();
            }
        }, AwaitOperation.Drop)
        .AddTo(this);
}

private static PlayerAttackType ResolveAttackType(AttackRequest request, TimeProvider timeProvider)
{
    var elapsed = 
        timeProvider.GetElapsedTime(request.Command.Timestamp, request.Input.Timestamp);
    
    // 指定の技が入力猶予フレーム以内に入力できていたか?
    // できてないならただのパンチ
    if (elapsed > CommandAttackWindow)
    {
        return PlayerAttackType.Punch;
    }

    // 入力猶予フレーム以内ならそれぞれの技の発動を許可する
    return request.Command.Move switch
    {
        CommandMove.UpperPunch => PlayerAttackType.UpperPunch,
        CommandMove.SpinAttack => PlayerAttackType.SpinAttack,
        CommandMove.FireShot => PlayerAttackType.FireShot,
        _ => PlayerAttackType.Punch
    };
}

ここで肝となるオペレーターがWithLatestFromです。

WithLatestFromは、メイン側でメッセージが発行されたときに、サブ側の直近の値を組み合わせるオペレーターです。この動作が「攻撃ボタンが押された瞬間に直近のコマンド成立の値を取り出してくる」にマッチしています。

今回はattackPressed側をメインとし、detectedCommands側をサブとしています。つまり、「攻撃ボタンが押された瞬間」にだけ判定を走らせたいので、このオペレーターがちょうど噛み合っています。

M_04.jpg

このように「メッセージの発行順序」や「発行タイミング」を用いてロジックを組み立てるのにR3はとても便利に使えます。

例: SubscribeAwaitを使ったObservableとasync/awaitの連携

Observableの購読時、SubscribeAwaitを使うことでメッセージハンドリング時にasync/awaitを使うことができます。通常のSubscribeはコールバックを同期的に処理しますが、SubscribeAwaitは購読時の処理そのものをawaitしながら扱えるのが特徴です。

// PlayerAttackController.cs

// コマンド判定の結果、最終的にいまから実行する「技」が
// なにであるかが流れてくるObservable
attackTypes
    .SubscribeAwait(async (attackType, _) =>
    {
        // 攻撃処理を中断できるトークンを作っておく
        // ダメージ時の攻撃キャンセルに使う
        using var attackCancellation = CreateAttackCancellationTokenSource();

        // 技を発動してそれが完了するまで待機する
        switch (attackType)
        {
            case PlayerAttackType.Punch:
                await NormalPunchAsync(attackCancellation.Token);
                break;
            case PlayerAttackType.UpperPunch:
                await UpperPunchAsync(attackCancellation.Token);
                break;
            case PlayerAttackType.SpinAttack:
                await SpinAttackAsync(attackCancellation.Token);
                break;
            case PlayerAttackType.FireShot:
                await FireShotAsync(attackCancellation.Token);
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    // Dropはいま実行中の非同期処理を優先。
    // 非同期処理の実行中に次のメッセージが来ても無視する。
    //  → いまの技が完了するまで次の技の発動をブロック
    }, AwaitOperation.Drop)
    .AddTo(this);

このサンプルで重要なのは「攻撃アニメーションや処理が終わる前に次の攻撃を始めたくない」という点です。SubscribeAwaitAwaitOperation.Dropを使うことで、非同期処理の進行中に来た入力をどう扱うかまで含めて制御できます。

例: 時間経過を含めたテストコードの作成

さきほどのコマンド入力の判定クラスPlayerCommandDetectorのテストコードを紹介します。

R3では時間の経過判定はTimeProviderまたはFrameProviderに依存しています。これらは、DelayTimestampのような「時間を使う処理の基準になるオブジェクト」で使用されています。詳しくは次回以降の記事にて解説する予定ですので、今回は「R3の時間を外から差し替えるための仕組み」くらいの理解で読み進めてください。

TimeProviderは任意の実装に差し替えることができます。そこでPlayerCommandDetectorTimeProviderを外から注入することで、ゲーム実行時とテスト時で時間の挙動を差し替えられるようにしています。

namespace R3Samples.Introduction.SampleGame
{

    public sealed class PlayerCommandDetector : IDisposable
    {
        /* (省略) */
        
        // TimeProvider未指定の場合はUnityの経過時間を使う
        public PlayerCommandDetector(IInputEventProvider inputEventProvider)
        {
            Initialize(inputEventProvider, UnityTimeProvider.Update);
        }

        // TimeProviderが注入された場合はそちらを使う
        public PlayerCommandDetector(IInputEventProvider inputEventProvider, TimeProvider timeProvider)
        {
            Initialize(inputEventProvider, timeProvider);
        }
    }
}

テスト用のTimeProvider実装として、Microsoft.Extensions.Time.Testingが提供するFakeTimeProviderを使用しています。

これが便利なのは、実際に時間が経過するのを待たなくても時間依存のロジックを決定論的にテストできる点です。入力タイミングや経過時間をこちらで正確に制御できるため、コマンド判定のような処理も安定して検証できます。

またIInputEventProviderもテスト用の実装に差し替えて渡しています。

private sealed class FakeInputEventProvider : IInputEventProvider
{
    private readonly ReactiveProperty<InputDirection> _direction = new(InputDirection.None);
    private readonly ReactiveProperty<bool> _isJumpButton = new(false);
    private readonly ReactiveProperty<bool> _isAttackButton = new(false);

    public ReadOnlyReactiveProperty<InputDirection> Direction => _direction;
    public ReadOnlyReactiveProperty<bool> IsJumpButton => _isJumpButton;
    public ReadOnlyReactiveProperty<bool> IsAttackButton => _isAttackButton;

    // 好きなタイミングでイベント発行できる
    public void EmitDirection(InputDirection direction)
    {
        _direction.OnNext(direction);
    }
}

このように「ユーザーからの入力」と「時間の経過」の両方を外からコントロールできるようにすることで、オペレーターを連鎖させたObservableでも安定してテストを書けるようになります。時間依存・入力依存のロジックをまとめて検証しやすくなるのが大きな利点です。

// PlayerCommandDetectorSpec.cs
// 長いのでテストコードは一部抜粋
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Time.Testing;
using NUnit.Framework;
using R3;

namespace R3Samples.Introduction.SampleGame.Tests
{
    public class PlayerCommandDetectorSpec
    {
        private static readonly TimeSpan Frame = TimeSpan.FromSeconds(1d / 60d);

        [Test]
        public void FireShot_右向きコマンドを時間内に入力すると検知する()
        {
            using var context = new DetectorTestContext();

            // 猶予フレームに収まるいい感じの時間を計算する
            var intervalFrames = MaxFramesWithinWindow(PlayerCommandDetector.FireShotOrUpperPunchWindow, 2);

            // ↓ 入力
            context.EmitDirection(InputDirection.Down);
            // 時間をちょっと進める
            context.AdvanceFrames(intervalFrames);
            // ↘ 入力
            context.EmitDirection(InputDirection.DownRight);
            // 時間をちょっと進める
            context.AdvanceFrames(intervalFrames);
            // → 入力
            context.EmitDirection(InputDirection.Right);

            // FireShot成立
            CollectionAssert.AreEqual(new[] { CommandMove.FireShot }, context.DetectedMoves);
        }

        [Test]
        public void FireShot_入力間隔が長すぎると検知しない()
        {
            using var context = new DetectorTestContext();

            // 猶予フレームからオーバーするだけの長めの時間を計算する
            var intervalFrames = FramesExceedingWindow(PlayerCommandDetector.FireShotOrUpperPunchWindow, 2);

            context.EmitDirection(InputDirection.Down);
            context.AdvanceFrames(intervalFrames);
            context.EmitDirection(InputDirection.DownRight);
            context.AdvanceFrames(intervalFrames);
            context.EmitDirection(InputDirection.Right);

            CollectionAssert.IsEmpty(context.DetectedMoves);
        }

        /// <summary>
        /// コマンド成立時間内に収まる待ち時間を作る
        /// </summary>
        private static int MaxFramesWithinWindow(TimeSpan totalWindow, int intervalCount)
        {
            var safeWindow = TimeSpan.FromTicks((long)(totalWindow.Ticks * 0.7d));
            return (int)(safeWindow.Ticks / (Frame.Ticks * intervalCount));
        }

        /// <summary>
        /// コマンド成立時間を超える待ち時間を作る
        /// </summary>
        private static int FramesExceedingWindow(TimeSpan totalWindow, int intervalCount)
        {
            return (int)(totalWindow.Ticks / (Frame.Ticks * intervalCount)) + 1;
        }

        /// <summary>
        /// テスト用の状態保存
        /// </summary>
        private sealed class DetectorTestContext : IDisposable
        {
            private readonly FakeInputEventProvider _inputEventProvider = new();
            private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.UnixEpoch);
            private readonly IDisposable _subscription;
            private readonly PlayerCommandDetector _detector;

            public DetectorTestContext()
            {
                _detector = new PlayerCommandDetector(_inputEventProvider, _timeProvider);
                _subscription = _detector.CommandMove
                    .Where(x => x != CommandMove.None)
                    .Subscribe(DetectedMoves.Add);
            }

            // 判定結果
            public List<CommandMove> DetectedMoves { get; } = new();

            // コマンド入力
            public void EmitDirection(InputDirection direction)
            {
                _inputEventProvider.EmitDirection(direction);
            }

            // フレーム経過
            public void AdvanceFrames(int frames)
            {
                _timeProvider.Advance(TimeSpan.FromTicks(Frame.Ticks * frames));
            }

            public void Dispose()
            {
                _subscription.Dispose();
                _detector.Dispose();
            }
        }
    }
}

サンプルゲームのまとめ

サンプルゲームを通して見てきたように、R3は単にイベントを購読するための仕組みではなく、「時間とともに変化する値や状態」を整理して扱うための基盤として活用できます。

今回はまず使い方のイメージをつかむことを目的として、具体的な実装例を中心に紹介してきました。こうした使い方を見ていくと、R3がどのような考え方や仕組みの上で成り立っているのかも、あわせて気になってくるかもしれません。

まとめ

今回の要点

  • R3では、時間とともに流れるイベントや値をObservableとして扱う
  • Observableにオペレーターをつなぐことで、イベントの流れを加工・合成できる
  • Subscribeは、組み立てたObservableを実際に受け取って処理を実行する入口になる
  • 状態管理にはReactivePropertyが便利で、UIや演出との連携にも使いやすい
  • R3はイベント処理だけでなく、入力管理、状態監視、時間依存の処理にも応用できる
  • async/awaitと組み合わせることで、イベント待ちや状態変化待ちを自然に記述できる
  • すべてをR3だけで書こうとせず、async/awaitと使い分けることが大切

おわりに

今回は、Unityユーザー向けにR3の概要と基本的な使い方を紹介しました。

R3は、時間とともに発生するイベントや状態変化をObservableとして扱い、それらをオペレーターでつなぎながら処理を組み立てていくライブラリです。

最初は難しく見えるかもしれませんが、「状態の変化を通知する」「イベントの流れを連結する」「必要なタイミングで購読する」という考え方に慣れると、ゲーム内のさまざまな処理を見通しよく整理しやすくなります。

R3の導入方法やセットアップについては、別記事としてまとめています。インストール手順や導入時の設定を確認したい場合は、そちらを参照してください。

次回はObservableの作り方や購読の方法についてもうすこし深堀りする予定です。

20
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?