【Unity】UniRxでカウントダウンタイマーを作る

  • 30
    Like
  • 0
    Comment
More than 1 year has passed since last update.

UniRxについての記事のまとめはこちら


Unity開発において、カウントダウンタイマー(もしくはカウントアップタイマー)を実装する機会は多いと思います。
今回はそのカウントダウンタイマーをUniRxを使って作ってみたいと思います。

Rxで作ったカウントダウンタイマー

指定秒数カウントダウンするストリーム
/// <summary>
/// CountTimeだけカウントダウンするストリーム
/// </summary>
/// <param name="CountTime"></param>
/// <returns></returns>
private IObservable<int> CreateCountDownObservable(int CountTime)
{
    return Observable
        .Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1)) //0秒後から1秒間隔で実行
        .Select(x => (int)(CountTime - x))                       //xは起動してからの秒数
        .TakeWhile(x => x > 0);                                 //0秒超過の間はOnNext、0になったらOnComplete
}

Rxで秒数をカウントダウンする場合は上記のような書き方で実装することができます。
(GenerateやPlan/Patternを使っても良いが、UniRxにはこれら機能がまだ実装されていない。)

ただし、このCreateCountDownObservableで作ったストリームはColdであるという点に注意する必要があります。
(Subscribeしたタイミングで始めてカウントダウンが開始される点、1つのタイマストリームを複数SubscribeするにはHot変換が必要である点)

Hot/Coldについてはこちらを参考にしてください。

Rxでタイマを作るメリット/デメリット

Rxでタイマを作ることと、Unity標準のコルーチンやInvokeRepeateでタイマを作ることを比較すると、Rxで作る場合は以下の様なメリット・デメリットがあります。

メリット

  • ストリームそのものが時間を管理してくれる
  • タイマを監視する側の処理が書きやすい
  • タイマを元に別の処理をスタートさせることが簡単にできる

デメリット

  • ある瞬間におけるタイマの時刻を取得することができない(一時変数に格納する必要あり)
  • タイマを途中で止めたり、残り時間を書き換えたりがやりにくい(できなくはない)
  • Rxそのものが難しくてわかりにくい(Hot/Coldがわかってないと罠を踏む)

タイマをRxで作る最大のメリットは、「タイマを監視する側の処理が書きやすい」と「タイマを元に別の処理をスタートさせることが簡単にできる」です。

例)Rxのタイマの数値を使って処理をする

では例として、先ほどのCountDownTimerを用いて以下の機能を実装してみます。

  • Start()のタイミングから60秒をカウントする
  • タイマの数字をUnityEngine.UI.Textに描画する
  • カウントが10秒以下になったら上記のUnity.GUI.Textの文字色を赤くする
  • カウントが10秒以下になったらカウントの度にSEを鳴らす
  • カウントが終了したらUnity.GUI.Textの文字を消す
  • カウントが終了したらになったらSEを鳴らす
RxCountDownTimer.cs

/// <summary>
/// カウントダウンするコンポーネント
/// </summary>
public class RxCountDownTimer : MonoBehaviour
{
    /// <summary>
    /// カウントダウンストリーム
    /// このObservableを各クラスがSubscribeする
    /// </summary>
    public IObservable<int> CountDownObservable
    {
        get
        {
            return _countDownObservable.AsObservable();
        }
    }

    private IConnectableObservable<int> _countDownObservable;

    void Awake()
    {
        //60秒カウントのストリームを作成
        //PublishでHot変換
        _countDownObservable = CreateCountDownObservable(60).Publish();
    }

    void Start()
    {
        //Start時にカウント開始
        _countDownObservable.Connect();
    }

    /// <summary>
    /// CountTimeだけカウントダウンするストリーム
    /// </summary>
    /// <param name="CountTime"></param>
    /// <returns></returns>
    private IObservable<int> CreateCountDownObservable(int CountTime)
    {
        return Observable
            .Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1)) 
            .Select(x => (int)(CountTime - x))                      
            .TakeWhile(x => x > 0);                                
    }
}
CountDownTextComponent.cs

/// <summary>
/// タイマの時刻をもとにTextを更新するコンポーネント
/// </summary>
public class CountDownTextComponent : MonoBehaviour
{

    /// <summary>
    /// UnityEditor上で渡しておく
    /// </summary>
    [SerializeField]
    private RxCountDownTimer rxCountDownTimer;

    /// <summary>
    /// uGUIのText
    /// </summary>
    private Text text;

    void Start()
    {
        text = GetComponent<Text>();

        //タイマの残り時間を描画する
        rxCountDownTimer
            .CountDownObservable
            .Subscribe(time =>
            {
                //OnNextで時刻の描画
                text.text = string.Format("残り時間:{0}", time);
            }, () =>
            {
                //OnCompleteで文字を消す
                text.text = string.Empty;
            });

        //タイマが10秒以下になったタイミングで色を赤くする
        rxCountDownTimer
            .CountDownObservable
            .First(timer => timer <= 10)
            .Subscribe(_ => text.color = Color.red);
    }
}
CountDownSoundComponent.cs
[RequireComponent(typeof(AudioSource))]
public class CountDownSoundComponent : MonoBehaviour {

    //効果音
    [SerializeField]
    private AudioClip SE_CountDownTick;
    [SerializeField]
    private AudioClip SE_CountDownEnd;
    private AudioSource audioSource;

    /// <summary>
    /// UnityEditor上で渡しておく
    /// </summary>
    [SerializeField]
    private RxCountDownTimer rxCountDownTimer;

    void Start()
    {
        this.audioSource = GetComponent<AudioSource>();

        //カウントが10秒以下になったらSEを1秒毎に鳴らす
        rxCountDownTimer
            .CountDownObservable
            .Where(time => time <= 10)
            .Subscribe(_ => audioSource.PlayOneShot(SE_CountDownTick));

        //カウントが完了したタイミングでSEを鳴らす
        rxCountDownTimer
            .CountDownObservable
            .Subscribe(_ => { ;}, () => audioSource.PlayOneShot(SE_CountDownEnd));
    }
}

RxCountDownTimerでカウントダウンストリームを作り、それをCountDownTextComponentとCountDownSoundComponentが監視して値の変化と同時に処理を行うという実装になっています。

Rxを使うと「タイマの現在時刻が向こうから流れてくる」というイベントと同じこと(というよりそれ以上のこと)が少ないコード量で記述することができてしまいます。
値の変化を監視して処理を行う」「変動する値が(複雑な)条件を満たした時に処理を行う」といった場面ではRxは強力に働きます。

例)タイマをもとに複雑な処理をする

先ほどの60秒タイマの挙動を変えてみましょう。

  • Start()時に3秒間のカウントダウンをする
  • 3秒のカウントダウンが終わってから60秒カウントダウンをする
  • 60秒のカウントが終わったあと、5秒待ってから別シーンに遷移する

「ゲームの試合開始前の3秒カウントダウン」→「試合時間60秒のカウントダウン」→「5秒のリザルト表示後にゲーム終了」をイメージして下さい。

これをRxで書くと以下のようになります。

GameTimerManager.cs
public class GameTimerManager : MonoBehaviour {

    /// <summary>
    /// 試合開始前のカウントダウン
    /// </summary>
    public IObservable<int> GameStartCountDownObservable{get;private set;}

    /// <summary>
    /// 試合中のカウントダウン
    /// </summary>
    public IObservable<int> BattleCountDownObservable { get; private set; }

    void Start()
    {
        //試合前の3秒タイマ
        //3秒タイマのストリームをPublishでHot変換(まだConnectはしない)
        var startConnectableObservable = CreateCountDownObservable(3).Publish();
        //外に公開するためのObservableとして保存
        GameStartCountDownObservable = startConnectableObservable;

        //試合中の60秒タイマ
        //60秒タイマのストリームをPublishでHot変換(まだConnectはしない)
        var battleConnectableObservable = CreateCountDownObservable(60).Publish();
        //外に公開するためのObservableとして保存
        BattleCountDownObservable = battleConnectableObservable;

        //3秒タイマのOnCompleteで60秒タイマをConnectする(60秒タイマの起動)
        GameStartCountDownObservable.Subscribe(_ => { ;}, () => battleConnectableObservable.Connect());

        //60秒タイマの後ろにConcatで5秒タイマを連結し、そのOnCompleteでシーン遷移させる
        BattleCountDownObservable
            .Concat(CreateCountDownObservable(5))
            .Subscribe(_ => { ;}, () => { Application.LoadLevel("NextScene"); });

        //3秒タイマ起動
        startConnectableObservable.Connect();
    }

    /// <summary>
    /// CountTimeだけカウントダウンするストリーム
    /// </summary>
    /// <param name="CountTime"></param>
    /// <returns></returns>
    private IObservable<int> CreateCountDownObservable(int CountTime)
    {
        return Observable
            .Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1)) 
            .Select(x => (int)(CountTime - x))                       
            .TakeWhile(x => x > 0);                                 
    }
}

ちょっと複雑かもしれませんが、これだけで実装完了です。
状態を管理するフィールド変数は不要で、ストリームの合成のみで記述することができました。

まとめ

  • Rxを用いるとタイマーを少ないコード量で実装ができる
  • ストリーム自身が時刻を保持するため、一時保存用のフィールド変数が不要になる(ただし任意のフレームでの時刻取得はできなくなる)
  • タイマーの複雑な挙動はRxの合成メソッドで記述できてしまう
  • UIの書き換えなどにRxのストリームが便利に使える(ModelからViewへの通知)
  • RxのHot/Coldの挙動を把握してないと罠を踏みやすいポイントである
  • Rxそのものが難しい
  • UniRxもっと流行るべき