gracetory’s blog

東池袋にある合同会社グレストリのエンジニアブログです

初めてのUniRx(Unity 2017.1.0) Part3

f:id:grshizawa:20170804194210p:plain:w800

まえがき

こんにちは、プログラマのshizawaです。
前回の記事からだいぶ経ってしまいました。
あっという間に寒くなりましたね。
台風一過、天気も清々しい今日この頃。みなさんいかがお過ごしでしょうか。

導入

今回もUniRxについて書いていきたいと思いますが、一旦UniRxについては今回で一区切りにしようかと 思っています。

今回はHotとColdを超噛み砕いて、理解した後に、ちょっとしたゲームを作ってみたいと思います。
ここではUnityの設定などの説明はしていません。

前回までの記事URL
初めてのUniRx + MVP(Model View Presenter)(Unity 2017.1.0) Part1 - gracetory’s blog
初めてのUniRx(Unity 2017.1.0) Part2 - gracetory’s blog

HotとColdの説明

こちらのサイトがわかりやすいので引用させていただきます。
【Reactive Extensions】 Hot変換はどういう時に必要なのか? - Qiita
RxのHotとColdについて - Qiita

●Cold Observable

 自発的に何もしない受動的なObservable
 Observerが登録されて(Subscribeされて)初めて仕事を始める
 ストリームの前後をただつなぐだけ。ストリームを枝分かれさせる機能は無い。

●Hot Observable

 自分から値を発行する能動的なObservable
 後続のObserverの存在に関係なしにメッセージを発行する
 自分より上流のCold Observableを起動し、値の発行を要求する機能を持つ
 下流のObserverを全て束ね、まとめて同じ値を発行する(ストリームを枝分かれさせる)

つまり、一つのストリームに対して、複数のsubscribeをする場合は
Hot変換をする必要があるということです。
変換しない場合はColdなObservableです。

そして、複数回SubscribeしたColdなObservableは分岐はできず、
subscribeした時にストリームを内部で複製しているようです。
この場合、余計なストリームを複製しているので、無駄にメモリを消費していることになります。

Hot変換の例(上記URLから)

// 入力された文字列を監視する
var keyBufferStream  
  = Observable.FromEvent<KeyEventHandler, KeyEventArgs>(  
       h => (sender, e) => h(e),  
       h => KeyDown += h,  
       h => KeyDown -= h)  
       .Select(x => x.Key.ToString())  
       .Buffer(4, 1)  
       .Select(x => x.Aggregate((p, c) => p + c))  
       .Publish() //PublishでHot変換(Publishが代表してSubscribeしてくれる)  
       .RefCount(); //RefCountはObserverが追加された時に自動Connectしてくれるオペレータ  

// 特定の文字が入力されたら、コンソールにログ表示
keyBufferStream  
    .Where(x => x == "HOGE")  
    .Subscribe(_ => Console.WriteLine("Input HOGE"));  
  
keyBufferStream  
    .Where(x => x == "FUGA")  
    .Subscribe(_ => Console.WriteLine("Input FUGA"));

// 2回Subscribeしている 

.Publish() //PublishでHot変換(Publishが代表してSubscribeしてくれる)
.RefCount(); //RefCountはObserverが追加された時に自動Connectしてくれるオペレータ
の部分がHot変換している部分になります。

つまり

ストリームに流れるイベントを分岐させるのが、Hot変換です。
一つのイベントに対して、違うオペレータ(上記の例だとwhere句)を差し込みたい場合など、
Hot変換をして、別の処理としてsubscribeできます。

以上がストリームのHot変換の説明です。

ちょっとしたゲームを作成

今までのことを振り返りながら、ゲームチックなものを作りました。 f:id:grshizawa:20171024151348g:plain
ルールはシンプル。
制限時間までにコインを集めればGame Clear表示、集められなければGameOver表示。

ソースコード

GameManager.cs

using UnityEngine;

public class GameManager : MonoBehaviour {

    public static GameManager Instance;   // シングルトンインスタンス
    public UIPresenter uiPresenter;        // UI全般
    public GameInfoModel gameInfoModel;    // ゲーム情報
    public GameObject coinParent;      // コインオブジェクト親

    float TIME_LIMIT = 15.0f;         // 制限時間
    
    void Awake () {
        // シングルトン
        if (Instance == null) {
            Instance = this;
            DontDestroyOnLoad (gameObject);
        } else {
            Destroy (gameObject);
        }

        // ゲーム情報クラス
        this.gameInfoModel = new GameInfoModel(TIME_LIMIT);
    }

    // Use this for initialization
    void Start () {
        // イベントを設定
        this.uiPresenter.Init();

        // 初期値をセット、表示も更新
        this.gameInfoModel.SetCoinNum(coinParent.transform.childCount);
    }

    // コイン取得イベント
    public void OnGetCoin() {
        this.gameInfoModel.SubCoinNum();
    }
    
    // ゲームステートを取得
    public GameInfoModel.GameState GetGameState() {
        return this.gameInfoModel.gameState.Value;
    }
    
    // ゲームステートをセット
    public void SetGameState(GameInfoModel.GameState state) {
        this.gameInfoModel.SetGameState(state);
    }   
}

GameManagerが基本的にゲームの中核を担っています。

UIをコントロールする public UIPresenter uiPresenter;

ゲーム内の情報をModelとして保存しておくのが public GameInfoModel gameInfoModel;


PlayerController.cs

using UnityEngine;
 
// プレイヤー制御用スクリプト
public class PlayerController : MonoBehaviour {

    [SerializeField]
    float MOVE_VELOCITY = 3.0f;   // 移動速度m/s
    [SerializeField]
    float JUMP_FORCE = 8.0f;  // ジャンプ力

    Rigidbody rigidbody;

    [SerializeField]
    LayerMask groundLayer;      // 接地レイヤー  

    [SerializeField]
    bool isGround = false;        // 接地判定

    // Use this for initialization
    void Start() {
        // RigidBody取得
        this.rigidbody = GetComponent<Rigidbody>();
    }

    void Update() {
        // 接地判定
        this.isGround = Physics.Linecast(transform.position, transform.position - transform.up * 2.0f, groundLayer);

        // ジャンプ処理
        if (this.isGround && Input.GetKeyDown(KeyCode.W)) {
            // 上方向に力を加える
            this.rigidbody.AddForce(Vector3.up * JUMP_FORCE, ForceMode.Impulse);
        }
    }

    void FixedUpdate () {
        // 横移動処理
        float xInput = Input.GetAxisRaw("Horizontal");
        if (xInput != 0.0f) {
            this.rigidbody.velocity = new Vector3(xInput * MOVE_VELOCITY, rigidbody.velocity.y, 0);
        } else {
            this.rigidbody.velocity = new Vector3(0, rigidbody.velocity.y, 0);
        }
    }

    private void OnTriggerEnter(Collider col) {
        // コイン判定
        if (col.gameObject.CompareTag("Coin")) {
            col.gameObject.GetComponent<CoinController>().OnGetCoin();
        }
    }
}

主にプレイヤーを移動させるスクリプトです。
コインとの当たり判定など
プレイヤーオブジェクトにアタッチ


GameInfoModel.cs

using System;
using UnityEngine;
using UniRx;

public class GameInfoModel {
    // ゲームステート
    public enum GameState {
        GAME_PLAY,
        GAME_CLEAR,
        GAME_OVER
    }
    public ReactiveProperty<GameState> gameState { get; private set; }
    public IObservable<GameState> gameStateObservableHot { get; private set; }  // 未使用
    // コイン数
    public ReactiveProperty<int> coinNum { get; private set; }
    public IObservable<int> coinObservableHot { get; private set; }
    // 制限時間
    public ReactiveProperty<float> timeLimit { get; private set; }
    public IObservable<float> timerObservableHot { get; private set; }
    
    // コンストラクタ
    public GameInfoModel(float timeLimit) {
        // ステートのリアクティブプロパティ
        this.gameState = new ReactiveProperty<GameState>();
        this.gameState.Value = GameState.GAME_PLAY;
        
        // コイン
        this.coinNum = new ReactiveProperty<int>();
        coinNum.Value = 0;
        
        // 時間制限
        this.timeLimit = new ReactiveProperty<float>();
        this.timeLimit.Value = timeLimit;
        
        // 制限時間のストリーム作成(HOT)
        this.timerObservableHot = CreateTimerObservable(timeLimit);
        // コイン(HOT)
        this.coinObservableHot = this.coinNum.Publish().RefCount();
    }
    
    // 時間をセット
    public void SetTimeLimit(float time) {
        this.timeLimit.Value = time;
        Debug.Log(this.timeLimit.Value);
    }
    
    // 時間を減算
    public void SubTime(float subTime) {
        this.timeLimit.Value -= subTime;
    }
    
    // タイマーストリーム作成
    public IObservable<float> CreateTimerObservable(float timeSecond) {
        return Observable
            .Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1))  // 0秒後に1秒間隔で実行する
            .TakeWhile(x => timeSecond - x >= 0)                     // 0以下になったらOnComplete
            .Select(x => timeSecond - x)                             // 残り時間に変換
            .Publish()                                                  // ストリームをHot変換
            .RefCount();                                                // Observerが追加された時に自動でConnectし、いなくなったらDispose
    }
    
    // コイン数をセット
    public void SetCoinNum(int coinNum) {
        this.coinNum.Value = coinNum;
    }
    
    // コイン数をデクリメント
    public void SubCoinNum() {
        this.coinNum.Value--;
    }
    
    // ゲームステートをセット
    public void SetGameState(GameState state) {
        this.gameState.Value = state;
    }
}

public IObservable CreateTimerObservable(float timeSecond) {
.
.
.
ここででHot変換をしています。 おさらいですが、Hot変換は複数のSubscribeをするために必要な処理でした。


UIPresenter.cs

using UnityEngine;
using UniRx;

public class UIPresenter : MonoBehaviour {

    [SerializeField]
    private UIView uiView;

    public void Init () {
        // タイマーストリーム(Hot)
        var timerObserbavleHot = GameManager.Instance.gameInfoModel.timerObservableHot;
        // 表示変更
        timerObserbavleHot
            .Subscribe(setTime => this.uiView.OnDisplaySetTime(setTime));
        // 一秒ごとにイベントが発行されるので、残り時間をセット
        timerObserbavleHot
            .DistinctUntilChanged() // 重複した値は通さない
            .Subscribe(x => GameManager.Instance.gameInfoModel.SetTimeLimit(x));
        /* 
           タイマーイベントが1秒ごとに発火し、そのタイミングでテキスト更新と残り時間をModelに格納している。
           同じtimerObserbavleから通知を受け取るので、Hot変換されているものを使う必要がある。
           ちなみに.DistinctUntilChanged()を入れなければ、
                   timerObserbavleHot
           .Subscribe(setTime => {
               this.uiView.OnDisplaySetTime(setTime);
               GameManager.Instance.gameInfoModel.SetTimeLimit(x);
               
           });
           のようにまとめて、ColdなObservableでも大丈夫です。
       */
    
        // 時間が0になったらゲームオーバー表示イベント登録(Cold)
        var coinNum = GameManager.Instance.gameInfoModel.coinNum;
        var timeLimit = GameManager.Instance.gameInfoModel.timeLimit;
        timeLimit
            .Where(x => x <= 0 && coinNum.Value > 0)
            .Subscribe(_ => {
                    GameManager.Instance.SetGameState(GameInfoModel.GameState.GAME_OVER);
                    this.uiView.SetGameStateText("Game Over");
                }
            );
            
            /*
           timerObserbavleHotでGameManager.Instance.gameInfoModel.timeLimitの値が変更

           検知したtimeLimit(Reactive Propaty)に、Subscribeしたゲームオーバー処理を実行するという流れ
           */

        // コインストリーム(Hot)
        var coinObservable = GameManager.Instance.gameInfoModel.coinObservableHot;
        // 表示変更
        coinObservable
            .Subscribe(setCoinNum => this.uiView.OnDisplayAddCoin(setCoinNum));
        // ゲームオーバー中ではない かつ コインを全て取得したらゲームクリア表示
        coinObservable
            .Where(x => x == 0 && GameManager.Instance.GetGameState() != GameInfoModel.GameState.GAME_OVER)
            .Subscribe(setCoinNum => {
                        GameManager.Instance.SetGameState(GameInfoModel.GameState.GAME_CLEAR);
                        this.uiView.SetGameStateText("Game Clear");
                    }
                );
    }
}

UIのViewとGameInfoModelを繋げる役目のPresenterスクリプトです。
ここで、諸々Observableに登録をしています。


UIView.cs

using UnityEngine;
using UnityEngine.UI;

public class UIView : MonoBehaviour {
    [SerializeField]
    private Text timeCountText;    // 残り時間
    
    [SerializeField]
    private Text coinNumText;  // 取得コイン数
    
    [SerializeField]
    private Text gameStateText;    // ゲームステート
    
    // コインを取得した時に呼ばれる
    public void OnDisplayAddCoin(int coinNum) {
        this.coinNumText.text = "残りコイン数 : " + coinNum.ToString();
    }

    // 時間をセットする
    public void OnDisplaySetTime(float timeLimit) {
        // テキストを更新
        this.timeCountText.text = "Time : " + timeLimit.ToString();
    }

    // ゲームステート表示用テキスト
    public void SetGameStateText(string text) {
        this.gameStateText.text = text;
    }
}

MVCのViewの部分


CoinController.cs

using UnityEngine;

public class CoinController : MonoBehaviour {
    // コイン取得時のイベント
    public void OnGetCoin() {
        GameManager.Instance.OnGetCoin();
        Destroy(this.gameObject);
    }
}

コインオブジェクトにアタッチ

あとがき

Model(値)を変更すると自動でViewが更新される。Model側に通知先を保持しない。 UniRxが活きるような設計にうまくできていないような気がしますが、こんな感じになりました。
特にObservableに処理をsubscribeするのをどこでどうやって管理していけば良いのか、考える余地がありますね・・・。