【Unity(C#)】UniRxを使ってスキップ機能付きのテキストを1文字ずつ表示させる実装を試したけど微妙だった


UniRx

Unityで利用できるReactiveExtensionらしいです。
この説明だと私自身も意味がわからないので、参考リンク1のお言葉を頂戴して説明します。

直訳すると「反応性拡張」で、イベントに対する反応を拡張するためのライブラリ

何かしらのイベント(ボタンを押した、プレーヤーが移動したなど何でも)に対する反応を
簡単に書くことができるっぽいです。

私自身、ソースコードを見に行って、
"はいはいなるほどね"と言えるレベルまで理解できていません。

Rxに採用されているデザインパターンで、Observerパターンというのがあるのですが、
それに関しても人に説明できるところまで理解でき次第、まとめようと思っています。

今回は、使いながら覚えていきましょう 
という意図で調べながら作ったらなかなか残念な仕上がりだったのでそれをメモに残します。

テキストを1文字ずつ表示

GIFで見たまんまの意味です。

既に何年も前に実装されている先駆者様がいらっしゃいました。
【参考リンク】: UniRXでuGUIのテキストをアニメーションさせる

文字を1文字ずつ表示する機能自体は参考リンクで完結しているので、
今回はスキップ機能(文字をいっきに最後まで表示させる機能)をUniRxで実装してみます。

コード

テキストコンポーネントを持つオブジェクトにアタッチ
using UniRx;
using System;
using UnityEngine;
using UniRx.Triggers;
using UnityEngine.UI;

public class TextPerOneWrite : MonoBehaviour
{
    [SerializeField]
    float m_textInterval = 0.2f;

    [SerializeField]
    KeyCode m_keyCode = KeyCode.Space;

    Text m_windowText;

    IDisposable m_textDispose;
    IDisposable m_updateDispose;

    void Start()
    {
        m_windowText = this.gameObject.GetComponent<Text>();
        m_windowText.text = "";

        //実行サンプル 文字を1文字ずつ出す
        ShowPerOne("ウホウホバナナヨコセ");
        ShowPerOne("ウホウホバナナヨコセ(早口)", 0.1f);

    }

    void ShowPerOne(string commentText)
    {
        m_windowText.text = "";

        if (m_textDispose != null)
        {
            m_textDispose.Dispose();
            m_updateDispose.Dispose();
        }

        m_textDispose = Observable.Interval(TimeSpan.FromSeconds(m_textInterval))
            .Take(commentText.Length)
            .Select(_ => 1)
            .Scan((accumulation, newValue) => accumulation + newValue)
            .DoOnCompleted(() => m_updateDispose.Dispose())
            .SubscribeToText(m_windowText, length => commentText.Substring(0, length))
            .AddTo(this);

        //特定のキー入力で文字を1文字ずつ出す機能を止める
        m_updateDispose =  this.UpdateAsObservable()
            .FirstOrDefault(_ => Input.GetKeyDown(m_keyCode))
            .Subscribe(_ =>
            {
                m_textDispose.Dispose();
                m_windowText.text = commentText;
            });
    }

    void ShowPerOne(string commentText, double textInterval)
    {
        m_windowText.text = "";

        if (m_textDispose != null)
        {
            m_textDispose.Dispose();
            m_updateDispose.Dispose();
        }

        m_textDispose = Observable.Interval(TimeSpan.FromSeconds(textInterval))
            .Take(commentText.Length)
            .Select(_ => 1)
            .Scan((accumulation, newValue) => accumulation + newValue)
            .DoOnCompleted(()=>m_updateDispose.Dispose())
            .SubscribeToText(m_windowText, length => commentText.Substring(0, length))
            .AddTo(this);

        //特定のキー入力で文字を1文字ずつ出す機能を止める
        m_updateDispose =  this.UpdateAsObservable()
           .FirstOrDefault(_ => Input.GetKeyDown(m_keyCode))
           .Subscribe(_ =>
           {
               m_textDispose.Dispose();
               m_windowText.text = commentText;
           });
    }
}

特定のキーを押すと1文字ずつ流れる文字を一気にスキップして表示することができます。

Observable.Interval

引数に時間を指定してあげるとその指定した間隔で値を流す(処理を実行)ことができます。

ちなみにTimeSpan.FromSecondsというのはどうやらC#の機能のようで
時間を指定するときによく使うみたいです。

【参考リンク】:TimeSpan.FromSeconds(Double) メソッド

Scan

Scanというオペレーターは前回発行された値と今発行された値の畳み込みを行うことができます。
平たく言うと重ねて足し合わせるイメージです。

.Scan((accumulation, newValue) => accumulation + newValue)

accumulation蓄積、累算などの意味を持つので、言葉の意味で覚えるとわかり易いです。
累算した値に、受け取った値(newValue)を足しているというわけですね。

【参考リンク】:UniRxを使ってみる

ストリームの寿命管理

uniRxは非常に便利ですが、
使う上で気を付けることの1つにストリームの寿命管理があります。

ストリームというのは

メッセージが伝達される経路、仕組み、機構のこと

らしいです。(またの名をObservableというらしい)

【引用元】:ObserverパターンからはじめるUniRx

このストリームというのが役目を終了した(もう必要でなくなった)段階で
購読を終了してあげる必要があります。

そうしてあげないと、パフォーマンスが低下したり、
もう存在していないGameObjectを参照してエラーが起きたりします。

【参考リンク】:UniRx入門 その2 - メッセージの種類/ストリームの寿命


Dispose

ストリームの購読終了を任意のタイミングで行うことが可能です。
一回変数に入れて、好きなタイミングで呼び出したらいいんじゃないでしょうか。


AddTo

AddToというメソッドを利用して、
先述したもう存在していないGameObjectの参照を未然に防ぐことができます。

引数に与えたGameObjectが削除された際に、自動的にDisposeを呼び出してくれます。


OnCompleted

このメッセージが発行されSubscribeまで到達すると購読が終了するらしいです。

今回どこにもOnCompletedを書いてませんが、
どうやらTakeによって指定回数分メッセージが通った際に発行され、
最後のSubscribeに到達しているようです。
(違うかもしれないんで、使ってておかしいと思ったらまた書き直します)

ストリームいっぱいできちゃう問題

前回のストリームが実行中であっても次に作成したストリームを同じ変数に突っ込めば
前回のストリームを止めた上で次のストリームを実行できそう!

ダメでした。なので、変数に既に何かしらが格納されているかチェックして
もし入っていたらストリームを止めてます。

    if (m_textDispose != null)
    {
        m_textDispose.Dispose();
        m_updateDispose.Dispose();
    }

m_textDispose = Observable.Intervalのように1つの変数に格納したからといって、
ストリームが1つしか作成されるわけではない、前回のストリームは止まらない...というのがわかりました。

ストリームがストリームを監視するのはあまりよくない

強い人に見て頂いた際にご指摘頂きましたが、けっこうごちゃごちゃしてしまっています。

自分で作っていても感じたことなのですが、

・ストリームの処理を条件分岐したい
・ストリームの挙動を途中で動的に変えたい

というような要望をUniRx使用時に盛り込むと、
”ストリームB”で”ストリームA”を監視するような状態になるので
条件分岐や変えたい挙動の数だけストリームが増えてしまいます。

なので、今回のように
Observable.IntervalUpdateAsObservableから任意のタイミングで止めるというのは
本来便利なRxを使っているにもかかわらずややこしくなってしまっています。

初心者あるあるらしいので、次回からはその辺りも意識してみようと思います。

参考リンク

UniRx入門シリーズ 目次
UniRx オペレータ逆引き
UniRxを紐解く「Take(1)とFirst()の違い」
【Unity】【UniRx】Observable.DoXxx()系のメソッドの挙動まとめ
【Unity】UniRx入門リンク集