LoginSignup
3
6

More than 1 year has passed since last update.

同じシーンの中で状態遷移を実装するベストプラクティス

Posted at

はじめに

メインゲームなど中心的なシーンでは、開始演出、ゲーム本編、結果発表、などの複数の状態を同じシーンの中で切り替えて実装する場合があります。

最初はキレイにかけていたとしても、作り込むにつれて複雑で見通しが悪いコードになってしまうこともしばしばありました。この記事では、先日リリースできたゲームの状態管理の仕組みをベースに、開発中に課題に思った点を整理して、自分なりのベストプラクティスな設計を考えてみます。

この記事に含まれている要素は、主に以下の通りです。

  • 状態遷移の管理方法の一例
  • UniRxの購読を止める方法
  • ポーズの実装方法の一例

この記事では説明用に一部のソースコードのみ抜粋・編集しています。
動作する全コードを知りたい場合は、githubのサンプルを御覧ください。

作成した状態遷移のサンプル

画面収録 2021-12-01 14.52.54_1.gif

仕様

  • 同じシーンの中にSTATE_A(黄)STATE_B(青)STATE_C(紫)の3つの状態がある。
  • それぞれの状態では、0.1秒ごとにオブジェクトを生成し、20個になると次の状態に進む。
  • スペースキーで、ポーズの開始と解除が切り替わる。
  • 画面をクリックすると、どの状態が有効なのかをログに表示する。

スクリーンショット 2021-12-01 14.28.34.png

実装の特徴

状態とUIをセットにする

状態とUI画面は、ほとんどの場合、セットになるのではないでしょうか。
この設計では、Canvas以下のゲームオブジェクトに状態名をつけ、併せてUIのレイアウトを行いました。

また、Sceneウィンドウで、全UIを見渡しやすくするために、並べています。
(全UIは、実行時に中央に集まるようにしています)

レイアウトの位置が分かりやすいように、少しだけ背景に色をつけています。
(これも実行時に透明になります)
スクリーンショット 2021-12-01 15.45.13.png

オブジェクトのアクティブ・非アクティブで、状態の有効・無効を表現する

ゲーム実行中、アクティブな状態はヒエラルキーで、白文字で表示され、無効な状態は、グレーアウトします。
ヒエラルキーを見るだけで、どの状態が有効かが、すぐに分かります。

また、その状態で必要なオブジェクトは、各状態の子要素として生成します。
スクリーンショット 2021-12-01 14.30.48.png

オブジェクトをアクティブ、非アクティブにしたときをトリガーにして呼ばれる関数があります。
OnEnable()OnDisable() です。

ここでは、この仕組みを利用して各状態の初期化、終了処理を記述しています。

状態の開始時と終了時にUniRxの購読を開始、停止している

UniRxの購読は、気が付かないところで購読され続け、不具合の原因になることがあります。
例えば、適当なスクリプトのUpdate関数に、何か処理を書いたとしましょう。シーン遷移した場合、オブジェクトは破棄され、そのUpdate関数の中に書かれた処理が実行されることはありません。
しかし、UniRxの Observable.EveryUpdate() を使い、購読した場合、停止処理を書かないとシーンを遷移しても実行され続けます。

ここでの設計では、前述のシーン遷移時に呼ばれる、OnEnable()OnDisable() を利用して、購読の開始と停止を記述します。

public class EachScene : MonoBehaviour
{
    // 購読しているリスト
    private List<IDisposable> disposables = new List<IDisposable>();

    // 状態オブジェクトがアクティブになったときに呼ばれる
    void OnEnable()
    {
        Observable.Interval(TimeSpan.FromSeconds(0.1f)) // 0.1秒ごとに生成
            .TakeWhile(_ => this.objectsRoot.childCount < 20) // 20個に達するまで
            .Subscribe(_ =>
            {
                // 生成部分
            }, () => // 20個に達したら呼ばれる
            {
                // 自らを非表示にすると、OnDisableが呼ばれる
                this.gameObject.SetActive(false);
            })
            .AddTo(this.disposables); // 購読しているリストに追加
    }

    // 自分自身が破壊されたり、非アクティブになったときに呼ばれる
    void OnDisable()
    {
        // 後述するが、子要素を破壊する
        foreach (Transform child in this.transform)
            Destroy(child.gameObject);
        // 購読停止処理
        foreach (IDisposable disposable in this.disposables)
            disposable.Dispose();
    }
}

サンプルでは、立方体などのオブジェクトが次々に生成され、回転します。
この回転にもUniRxを使用しています。
ここでは Start() で購読が開始され、このスクリプトが破棄されたとき購読が停止されます。

public class SimpleRotation : MonoBehaviour
{
    void Start()
    {
        Observable.EveryFixedUpdate()
            .Subscribe(_ => this.transform
            .Rotate(Vector3.right * Time.deltaTime * 100f))
            .AddTo(this); // このスクリプトが破棄されたときに、購読も停止されるように指定
    }
}

状態遷移時に、その状態の子要素を一括して破壊することで、まとめて購読を停止される設計です。

非アクティブになるときを状態遷移のトリガーにしている

SceneManager.cs が、意図した順番に、状態のオブジェクトをアクティブにします。
それぞれの状態のスクリプト(ここではEachState.cs)が、それぞれの条件を満たしたときに、自らを非アクティブにします。
SceneManager.csは、状態のオブジェクトの非アクティブを監視しており、その状態が終了したことを知ります。

スクリーンショット 2021-12-01 16.47.44.png

非アクティブになったオブジェクト名(状態名)から、次の状態のオブジェクトをアクティブにします。

public class SceneManager : MonoBehaviour
{
    private GameObject targetState; // 監視する状態のオブジェクト

    void Start()
    {
        // 最初の状態を指定
        ActivateState("STATE_A");

        // 各状態が終わったときの遷移先を指定
        Observable.EveryFixedUpdate()
            .Select(_ => this.targetState.activeInHierarchy)
            .DistinctUntilChanged()
            .Where(active => !active)
            .Select(_ => this.targetState.name)
            .Subscribe(stateName =>
            {
                switch (stateName)
                {
                    // ここにそれぞれの状態が終了したときに、次に何をすべきかを指定する
                    case "STATE_A" :
                        ActivateState("STATE_B");
                        break;
                    case "STATE_B" :
                        ActivateState("STATE_C");
                        break;
                    case "STATE_C" :
                        ActivateState("STATE_A");
                        break;
                    default:
                        break;
                }
            })
            .AddTo(this);
    }

    private void ActivateState(string stateName)
    {
        var state = this.transform.Find(stateName).gameObject;
        state.SetActive(true);
        // 監視対象に
        this.targetState = state;
    }
}

TimeScaleでポーズ機能を実装している

ポーズ機能を実装するにあたり、お手軽な方法はTime.timeScale0にすることです。

Time.timeScale = 0f;

こうすることで、Time.delataTimeを使ったアニメーション処理を止められます。
サンプルではスペースキーを押すとポーズの開始と解除ができます。
画面収録 2021-12-01 18.52.22_1.gif

一見簡単なのですが、気をつけなくてはならないことがいくつかあります。

Update()timeScaleの影響を受けないので、この関数の中に書かれた処理は動き続けます。
一方で、FixedUpdate()timeScale=0のときは止まるので、ポーズで止めたい処理はこちらを利用します。

ただしInputクラスはFixedUpdate()の中に書くと、挙動が変になるので、Update()関数内で書かなくてはなりません。
ここではUniRxのBatchFrameオペレーターを使用して、止められるようにしています。

Observable.EveryUpdate()
     .Where(_ => Input.GetMouseButtonDown(0))
     .BatchFrame(0, FrameCountType.FixedUpdate) // InputはUpdateでないと正しく動かない
     .Subscribe(_ => Debug.Log(" Click! <= " + this.gameObject.name))
     .AddTo(this.disposables);

ポーズ画面で動かしたい処理は、Update() で書けば動きます。
ただしアニメーションさせたいときはdeltaTimeを使ってアニメーションの見え方を一定にしたいところ。
無理やりですが、UniRxのスケジューラー機能を使って、timeScaleを無視するスレッドを指定します。

Observable.EveryUpdate()
    .Where(_ => Input.GetMouseButtonDown(0))
    .BatchFrame(0, FrameCountType.FixedUpdate) // InputはUpdateでないと正しく動かない
    .Subscribe(_ => Debug.Log(" Click! <= " + this.gameObject.name))
    .AddTo(this.disposables);

最後に…

以上が、現段階の状態遷移の設計です。
分かりづらい部分や、もっと良くするアイディアがありましたら、ご指摘頂けますと幸いです。

3
6
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
3
6