読者です 読者をやめる 読者になる 読者になる

チリペヂィア

リンクフリー。サンプルコードなどは関連記事内でライセンスについて明示されない限り商用利用なども自由に行って構いませんが、自己責任でお願いします。またこれら日記内容の著作権自体は放棄していません。引用部分については引用元の権利に従ってください。

Unityでシーンをまたぐ状態の保存とか

前回のネタと代わって、ちょっと備忘録。Unityには原則的にグローバル変数相当のものがないです。基本的にシーン切替えのたびにGameObjectを全部リリースしてしまう方針になっています。ある程度まとめてヒープをごっそり片付けてしまうルールです。断片化やリーク対策には適切。

なるほど確かに精神衛生的…C#などがいくら「ガベージコレクションされるインスタンス型の経由を前提にしてるから、そこは…」と言っても、リアルタイムゲームにとってヒープ領域の断片化は常に恐ろしいものです。もしいつだって高速にアクセスできる本当に無限のレジスタがあれば、何も考えずにマシンガンの弾全てを一つ一つ無圧縮サウンド付きのオブジェクトに出来て、コーディングも今よりずっと気楽なものになると思うのですが、多分、永遠に記憶領域は足らないものなのでしょう。

さてUnityでゲーム用のグローバル変数的な何かを作るとしたら、それはきっとこんな機能を持っています。

  • 勝手にロードされて
  • 簡単にセーブできて
  • 一つしか存在せず
  • シーンをまたいでも消えない

作戦1 PlayerPrefs
Unityでの基本的な低レベル入出力っぽいものを利用して、不揮発記憶に逃がしてしまう作戦。あえて言うならWinAPIのiniファイルの操作に類似するものの、Win環境ではレジストリに記録される。

作戦2 Object.DontDestroyOnLoad()
これを使うと、オブジェクトがシーンをまたいでも破棄されなくなる。

今回は作戦1を採用します。多少は速度パフォーマンスで劣るでしょうが、長期記憶を利用するのでシャットダウン耐性がつきます(レジストリの件は気にしない方向で…「無条件に別環境に移植しやすいので」という言い訳も含めて)。

それにDontDestroyOnLoad()はゲームで何度も利用される汎用オブジェクトのプリロード&キープというニュアンスを感じます。作戦2が有効なシーンも多々あるとは思うので、ケースバイでハイブリッドにしてしまうのが良いと思います。

PlayerPrefsはもう一つ、intとfloatとstringしか記録できない、という弱点がありますが、これは.NETのSerialize機能で解決できるはず…と思ったら、やっぱりForumに既出でしたね。バイナリを文字列エンコードするライブラリがあるのでそれを利用しています。

public class Serialize {
	// <!!> T is any struct or class marked with [Serializable]
	public static void Save<T> (string prefKey, T serializableObject) {
		MemoryStream memoryStream = new MemoryStream ();
		BinaryFormatter bf = new BinaryFormatter ();
		bf.Serialize (memoryStream, serializableObject);
		string tmp = System.Convert.ToBase64String (memoryStream.ToArray ());
		PlayerPrefs.SetString ( prefKey, tmp );
	}

	public static T Load<T> (string prefKey) {
		if (!PlayerPrefs.HasKey(prefKey)) return default(T);
		BinaryFormatter bf = new BinaryFormatter();
		string serializedData = PlayerPrefs.GetString(prefKey);
		MemoryStream dataStream = new MemoryStream(System.Convert.FromBase64String(serializedData));
		T deserializedObject = (T)bf.Deserialize(dataStream);
		return deserializedObject;
	}
}

LoadでロードしてSaveでセーブします。Serializeという名前は重複しやすそうなので、適当なネームスペースやクラスの配下に置いてあげて下さいな(なおこのページでは以後、Logic.Serializeに置いたとして扱います)。Serializable属性に関しては、以下も参考にしてみてください。
DOBON.NET オブジェクトの内容をバイナリファイルに保存、復元する: .NET Tips: C#, VB.NET

これで、prefKeyで任意のキー名を指定してクラスまたは構造体を丸ごと自由に保存できるようになりました。Serializable属性ならなんでもOKです。ただ(やらないとは思いますが)TあたりのサイズがM単位とか巨大なデータは保存するのに向かないと思います。ググってみたらレジストリはREG_SZ一個あたり1M程度までらしいです。Base64変換は元バイナリの4/3倍に増えるので、元データ(Tが要するバイナリサイズ)は512Kbあたりまでが無難です。これもWin環境での話なので、また別環境に移植するときはチェックしないといけません。とは言えゲームのセーブデータがバイナリで512kbって結構なビッグタイトルっぷりですけれども…。

これだけでは要件に足りないので、シーンあたり一個だけデザイナーから設定するMonoBehaviorクラスを作ります。このクラスを継承して、タイトルシーンやバトルステージといったMonoBehaviourに派生させて、どちらからも共通のセーブ領域を扱えるようにします。また、AwakeやOnDestroyといったイベントで、セーブデータを自動でロード/セーブします。セーブデータの寿命は正確に言うとシーンをまたがず、コンポーネントが設定されているシーンで勝手に再ロードされるようになっています。クラスはジェネリックに実装しておき、任意のセーブ情報クラスを後付けできるようにしておきます。

public class BasicSceneScript<T> : MonoBehaviour where T : class, new() {

	public T autoSaveField {
		get {
			if ( m_autoSaveField == null ) {
				m_autoSaveField = Logic.Serialize.Load<T>(typeof(T).Name);
				if ( m_autoSaveField == null ) m_autoSaveField = new T();
			}
			return m_autoSaveField;
		}
	}
	
	public void LoadField() {
		m_autoSaveField = Logic.Serialize.Load<T>(typeof(T).Name);
		if ( m_autoSaveField == null ) m_autoSaveField = new T();
	}
	
	public void SaveField( bool isFlushPrefs ) {
		SaveField(isFlushPrefs, false);
	}
	
	public void SaveField( bool isFlushPrefs, bool isReleaseSaveField ) {
		if ( m_autoSaveField != null ) Logic.Serialize.Save(typeof(T).Name, m_autoSaveField);
		if ( isReleaseSaveField ) m_autoSaveField = null;
		if ( isFlushPrefs ) PlayerPrefs.Save();
	}
	
	virtual protected void Awake() {
		LoadField();
	}
	
	virtual protected void OnDestroy() {
		SaveField(true, true);
	}
	
	private static T m_autoSaveField = null;
}

この実装の弱点は、保存するTを一つに限定している事です。例えば戦場の数千体のキャラクターデータを詳細に保存したくて、セーブデータが512kbを超えたりするなら、このクラスは書き直さなければなりません。

T
セーブデータクラス型。あとから指定します。シングルトンっぽいことをやっている都合、Tにはジェネリックの型制約として「newをpublicに公開しているクラス」という制約を設けました。もちろん[Serializable()]属性も必要です。

autoSaveField
Tのインスタンスを取得。Tは内部的にはstaticに存在していて、どのBasicSceneScript派生クラスから呼び出しても同じインスタンスを返します。autoSaveFieldがnullを返す事はありません。もし内部的にnullだった時は勝手にロードをトライして、過去のセーブが見つからない時はデフォルト値としてTをnewしてから返します。

LoadField
明示的にロードを行います。その時に保存されているセーブデータで上書きされるので、一時的な変更を帳消しにしたい時などに使います。

SaveField
明示的にセーブを行います。isFlushPrefsをtrueに設定すると、内部で同時にPlayerPrefs.Save()*1が呼び出されます。isReleaseSaveFieldをtrueに設定すると、内部のstaticなセーブデータのインスタンスが解放されますが、通常この処理を必要とする事はありません。

Awake/OnDestroy
それぞれの契機で、ロード/セーブ+解放を自動で行います。

PlayerPrefsのキー名をTの型名によって一意に固定し、セーブ/ロード用のフィールドをstaticに保有するので、間違って複数のBasicSceneScriptをデザイナーから設定しちゃっても動くには動くはずですが、リソースを共有するだけの無駄な処理にはなってしまいます。

以下使用例。

using UnityEngine;

[System.Serializable()]
public class AutoSaveField {
	
	[System.Serializable()]
	public struct RequiredScene {
		public enum Mode {
			Title,
			Result,
			Battle
		}
		public Mode mode;
	}
	
	public RequiredScene requiredScene;
	public int test;
	
	public AutoSaveField() {
		requiredScene.mode = RequiredScene.Mode.Title;
		test = 0;
	}
}

public class BattleStageScript : Logic.BasicSceneScript<AutoSaveField>
 {
	// Use this for initialization
	void Start () {
		autoSaveField.test = 512;
		autoSaveField.requiredScene.mode = AutoSaveField.RequiredScene.Mode.Battle;
	}
	// その他処理をうんちゃらかんちゃら
}

AutoSaveField
試しに用意したセーブデータクラス。このクラスの情報が自動でセーブ/ロードされます。[Serializable()]属性をつけるのをお忘れなく。

BattleStageScript
試しに継承してみたステージ管理クラス。

このBattleStageScriptクラスを、シーン中のゲームオブジェクトに一つだけコンポーネントとして登録しておきます。上の例ではとくに処理らしい処理はしてませんが、命名の文脈からすると、RequiredSceneをもちっと充実させて、Start()処理あたりでrequiredSceneのデータを元に「何番のステージを元に昼モードから夕方モードに設定変更して〜」といった初期化や、記憶しておいたキャラクターの配置なんかを行ったりする感じになるのでしょうか。

ちなみにyield処理からアクセスされたりすると分からないのでスレッドセーフタイプを書いておきます。lock処理がそれぞれの環境でどの程度有効かは正直言ってわかりませんのですが、一応…。

public class BasicSceneScript<T> : MonoBehaviour where T : class, new() {

	public T autoSaveField {
		get {
			lock (m_lock) {
				if ( m_autoSaveField == null ) {
					m_autoSaveField = Logic.Serialize.Load<T>(typeof(T).Name);
					if ( m_autoSaveField == null ) m_autoSaveField = new T();
				}
				return m_autoSaveField;
			}
		}
	}
	
	public void LoadField() {
		lock (m_lock) {
			m_autoSaveField = Logic.Serialize.Load<T>(typeof(T).Name);
			if ( m_autoSaveField == null ) m_autoSaveField = new T();
		}
	}
	
	public void SaveField( bool isFlushPrefs ) {
		SaveField(isFlushPrefs, false);
	}
	
	public void SaveField( bool isFlushPrefs, bool isReleaseSaveField ) {
		lock ( m_lock ) {
			if ( m_autoSaveField != null ) Logic.Serialize.Save(typeof(T).Name, m_autoSaveField);
			if ( isReleaseSaveField ) m_autoSaveField = null;
			if ( isFlushPrefs ) PlayerPrefs.Save();
		}
	}
	
	virtual protected void Awake() {
		LoadField();
	}
	
	virtual protected void OnDestroy() {
		SaveField(true, true);
	}
	
	private static System.Object m_lock = new System.Object();
	private static T m_autoSaveField = null;
}

まぁ、まだ実用はしてないので何があるか分からないサンプルなんですけどね!その結果は追々(多分)。

*1:良く分かりませんが、多分IOストリームで言うflush処理みたいなもので、メモリ上の処理を実際に書き込む処理をやってるんじゃないかな?ブレーク貼ってステップ実行してみましたが、この処理の前後でレジストリに反映されていたのでおそらくそうかと。