はじめに
メインゲームなど中心的なシーンでは、開始演出、ゲーム本編、結果発表、などの複数の状態を同じシーンの中で切り替えて実装する場合があります。
最初はキレイにかけていたとしても、作り込むにつれて複雑で見通しが悪いコードになってしまうこともしばしばありました。この記事では、先日リリースできたゲームの状態管理の仕組みをベースに、開発中に課題に思った点を整理して、自分なりのベストプラクティスな設計を考えてみます。
この記事に含まれている要素は、主に以下の通りです。
- 状態遷移の管理方法の一例
- UniRxの購読を止める方法
- ポーズの実装方法の一例
この記事では説明用に一部のソースコードのみ抜粋・編集しています。
動作する全コードを知りたい場合は、githubのサンプルを御覧ください。
作成した状態遷移のサンプル
仕様
- 同じシーンの中にSTATE_A(黄)STATE_B(青)STATE_C(紫)の3つの状態がある。
- それぞれの状態では、0.1秒ごとにオブジェクトを生成し、20個になると次の状態に進む。
- スペースキーで、ポーズの開始と解除が切り替わる。
- 画面をクリックすると、どの状態が有効なのかをログに表示する。
実装の特徴
状態とUIをセットにする
状態とUI画面は、ほとんどの場合、セットになるのではないでしょうか。
この設計では、Canvas以下のゲームオブジェクトに状態名をつけ、併せてUIのレイアウトを行いました。
また、Sceneウィンドウで、全UIを見渡しやすくするために、並べています。
(全UIは、実行時に中央に集まるようにしています)
レイアウトの位置が分かりやすいように、少しだけ背景に色をつけています。
(これも実行時に透明になります)
オブジェクトのアクティブ・非アクティブで、状態の有効・無効を表現する
ゲーム実行中、アクティブな状態はヒエラルキーで、白文字で表示され、無効な状態は、グレーアウトします。
ヒエラルキーを見るだけで、どの状態が有効かが、すぐに分かります。
また、その状態で必要なオブジェクトは、各状態の子要素として生成します。
オブジェクトをアクティブ、非アクティブにしたときをトリガーにして呼ばれる関数があります。
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
は、状態のオブジェクトの非アクティブを監視しており、その状態が終了したことを知ります。
非アクティブになったオブジェクト名(状態名)から、次の状態のオブジェクトをアクティブにします。
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
にすることです。
Time.timeScale = 0f;
こうすることで、Time.delataTime
を使ったアニメーション処理を止められます。
サンプルではスペースキーを押すとポーズの開始と解除ができます。
一見簡単なのですが、気をつけなくてはならないことがいくつかあります。
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);
最後に…
以上が、現段階の状態遷移の設計です。
分かりづらい部分や、もっと良くするアイディアがありましたら、ご指摘頂けますと幸いです。