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

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

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

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

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

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

https://github.com/TakashiHamada/advent_calendar_2021

# 作成した状態遷移のサンプル
![画面収録 2021-12-01 14.52.54_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95460/1eef0ff0-f38c-fc1b-701e-d7bf68de1d03.gif)

## 仕様

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

![スクリーンショット 2021-12-01 14.28.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95460/e45a814f-14ef-4f2f-cfda-4657e723bfd0.png)

# 実装の特徴

## 状態とUIをセットにする

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

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

レイアウトの位置が分かりやすいように、少しだけ背景に色をつけています。
（これも実行時に透明になります）
![スクリーンショット 2021-12-01 15.45.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95460/acc0fd41-0010-f6b4-5f78-6b0764b3f13e.png)

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

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

また、その状態で必要なオブジェクトは、各状態の子要素として生成します。
![スクリーンショット 2021-12-01 14.30.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95460/04daf753-7fb0-0b18-0d45-a92d7baa4201.png)

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

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

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

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

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

```c#
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()` で購読が開始され、このスクリプトが**破棄されたとき購読が停止**されます。

```c#
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95460/1f790b07-d370-a977-f9bd-81b55f607061.png)

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

```c#
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.timeScale`を`0`にすることです。

```c#
Time.timeScale = 0f;
```

こうすることで、`Time.delataTime`を使ったアニメーション処理を止められます。
サンプルではスペースキーを押すとポーズの開始と解除ができます。
![画面収録 2021-12-01 18.52.22_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95460/be66dea2-75cb-c7a7-e13d-97c5003509e0.gif)

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

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

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

```c#
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を無視するスレッドを指定**します。

```c#
Observable.EveryUpdate()
    .Where(_ => Input.GetMouseButtonDown(0))
    .BatchFrame(0, FrameCountType.FixedUpdate) // InputはUpdateでないと正しく動かない
    .Subscribe(_ => Debug.Log(" Click! <= " + this.gameObject.name))
    .AddTo(this.disposables);
```
# 最後に…
以上が、現段階の状態遷移の設計です。
分かりづらい部分や、もっと良くするアイディアがありましたら、ご指摘頂けますと幸いです。