セーブ・ロードの自動化 その1(メンバ変数の保存)
ゲームを作成する上で、実は相当に難しいのがセーブ・ロードです。もちろん、それはゲームの規模にもよりますが、3Dオブジェクトを沢山使って世界を構築するゲームのセーブやロードは容易ではありません。もしこれが自動化できればすばらしいですよね。実は、「Game
Programing Gems 3」にそれに関する1つの解決策が示されています。ただ、ページの制約もあり、少しその実装がわかりにくくなっています。そこでこの節では、Game
Programing Gems 3に紹介されているセーブ・ロードの自動化の方法について詳しく掘り下げてみます。
(尚、著作の関係もありますので、原著は参考として相当に噛み砕いて説明し、出現するクラスも原著と別の表記にしていることをご了承下さい)
@ セーブ・ロードの概要
まず、セーブ・ロードをどう行うのか示します。
ある1つのクラスを設計図として作られたオブジェクトは、言うならばそこに含まれるメンバ変数によって形作られていると言って良いでしょう。ということは、オブジェクトを形成するメンバ変数を外に出して保存することがセーブであり、そのデータを空のオブジェクトに流し込めば、オブジェクトは復元され、それがロードとなります。下図はロードのイメージです。
ローカルな変数だけだと割と楽なのですが、オブジェクトのメンバ変数には他のオブジェクトへのポインタが含まれる事も良くあります。これは、少し方法を考えないといけません。
オブジェクトへのポインタ(アドレス)自体をセーブすることに意味はありません。次にロードしてもその番地にはオブジェクトが存在しえないからです。その代わり、保持しているオブジェクトのID番号のようなものをセーブしておけば、ロード時にそのIDを持つオブジェクトと再びつながる事により状態を元に戻す事ができます。
次のセーブの模式図をご覧下さい。
ローカル変数(B)と共に自分の指しているオブジェクトのクラスIDと個別IDをセーブします。
次はロード手順の模式図です。
まず、ID 1のクラスID(Class N)を元に空のオブジェクトを生成し、セーブデータを流し込みます。この段階ではまだローカル変数(B)と他のオブジェクトの個別IDのみがあるだけです。全てのオブジェクト(ID 2、ID 3に該当)を生成し終わった後に、対応する個別IDを持つオブジェクトを見つけ、ポインタでつなぎます。こうすると、セーブした時の状態をしっかりと復活できます。以上がセーブロードの大まかな仕組みです。
それではじっくり実装していくことにしましょう。
A セーブしたい変数の位置をオフセットで指定する方法
セーブする時には、クラスが宣言している変数から必要な変数のみを抜き出して保存します。しかしながら、例えばセーブしたいクラスについて、GetData1()、GetData2()と取得関数を実装するでしょうか?これでは保存する側は全てのクラスを知っていなければならず、自動化の意味がありません。セーブのポイントは「クラスで宣言される変数のアドレスは決まっている」点にあります。次のクラス宣言をご覧下さい。
class CPlayer
{
protected:
int m_iLevel;
char m_bFlag;
public:
CPlayer(){ m_iLevel = 1; m_bFlag = 16; }
};
このようなクラスのオブジェクトを生成すると、メンバ変数は次のようにメモリ上に置かれます。
アドレス(バイト単位) 変数 値 バイナリ 1000 m_iLevel 1 01 1001 00 1002 00 1003 00 1004 m_bFlag 16 10 1005 00 1006 00 1007 00
すなわち、宣言した変数の順番にメモリ上に格納されます。「あれ?m_bFlagはchar型なので1バイトじゃないの?」と思われるかもしれませんが、実際はアラインメントが入るので4バイト分メモリが占有されます(ただし実装系固有です)。
ここからが肝です。生成したオブジェクトのポインタは、上で言うと「1000番」と必ずオブジェクトの先頭アドレスを指します。ですから、例えば保存したいのがm_bFlagだとしたら、「オブジェクトの先頭アドレス
+ 4(バイト単位のオフセット値)」の位置にあるデータを4バイト分保存すれば良いのです。この「先頭アドレス+オフセット値から変数のサイズ分保存する」というルールは、全てのクラスに共通します。これより、あらゆるクラス内の保存したい任意の変数は、「オフセット値」「変数のサイズ」という簡単な構造体で指定することができてしまうのです!
クラス内の任意の変数を指す構造体をDATARECORD構造体として、次のように宣言しておきます。
struct DATARECORD
{
char type; // データタイプ
int offset; // オフセット値
int size; // サイズ
};
typeは保存するデータタイプを示します。通常のローカル変数ならばTYPE_LOCAL、ポインタならばTYPE_PTRなどとなります。これについては後ほど。
offsetは変数の位置を示す先頭アドレスからのオフセット値、
sizeは保存サイズです。
セーブしたい各クラスは、この構造体の配列でセーブデータを指定します。配列自体は固定的なものですから、static宣言で指定してしまいましょう。先ほどのCPlayerクラスで例を示します。
class CPlayer
{
protected:
int m_iLevel;
char m_bFlag;
static DATARECORD m_gDataRecord[ ]; // 保存するデータ配列
public:
virtual DATARECORD *GetDataRecord(); // 保存するデータ配列を取得
};
// 保存する変数の位置を指定
DATARECORD CPlayer::m_gDataRecord[] =
{
{TYPE_LOCAL, 0, 4} // m_iLevel
{TYPE_LOCAL, 4, 4} // m_bFlag
};
太文字で示した部分がセーブに関する追加箇所です。静的なDATARECORD配列であるm_gDataRecordを宣言し、外部でその中身を固定(コンパイル時決定)しています。ここでは両方の変数を保存していますね。
ところで、DATARECORDの指定ですが、変数が沢山並ぶとオフセット位置とサイズを計算するのはちょっとしんどいかもしれません。そこで次のようなマクロを宣言し、それを自動化してしまいます。
#define DATA_LOCAL( CLASSNAME, MEMBERNAME ) \
{\
TYPE_LOCAL, \
( (__int64)&((CLASSNAME*)0)->MEMBERNAME ), \
sizeof( ((CLASSNAME*)0)->MEMBERNAME ) \
}
#define DATA_END { TYPE_END, 0, 0}
これの詳しい意味合いはこちらをご覧下さい。__int64というのは32bitCPUおよび64bitCPUの両方に対応する整数型です。これはコンパイラが文句を言ってくるのでこうしています。このマクロにより配列は簡潔に指定できるようになります。
// 保存する変数の位置を指定
DATARECORD CPlayer::m_gDataRecord[] =
{
DATA_LOCAL( CPlayer, m_iLevel ),
DATA_LOCAL( CPlayer, m_bFlag ),
DATA_END
};
(このアイデアはGame Programing Gems 3に記載されています。すばらしいですよね(^-^))
DATA_ENDは文字通りデータの終わり(配列の終わり)を示すセパレータです。この静的な配列をクラスごとに丁寧に指定し、仮想関数GetDataRecord()でその配列を返すようにすれば、もうセーブはできたようなものです!
B CSaveObjBaseセーブ基底クラス
セーブしたい全てのクラスはCSaveObjBase基底クラスから派生するようにしましょう。CSaveObjBaseクラスは、今のところ次のような簡潔なものです。
SaveObjBase.h class CSaveObjBase
{
public:
virtual DATARECORD *GetDataRecord() = 0; // データ配列を取得
};
純粋仮想関数GetDataRecord()が含まれるので、派生クラスごとに中身を実装する必要があります。これは各クラスで定義するDATARECORD構造体の配列へのポインタを返すだけで、先ほどCPlaterクラスで例を示したとおりに実装すればOKです。
このクラスは後ほど少しずつ発展していきます。
C テストセーブしてみよう!(CSaveManagerクラス)
この段階で簡単なセーブがもうできます。その作業はセーブを管理するCSaveManagerクラスに一任します。
CSaveManagerクラスにとりあえずSave関数を定義しておきましょう。
SaveManager.h class CSaveManager
{
public:
int Save( DATARECORD *list, CSaveObjBase* pobj);
};
今はテストなので、引数にDATARECORD構造体配列へのポインタlistとオブジェクトの先頭ポインタpobjを渡すようにしましょう。これで変数を指定できるのはもう大丈夫ですよね。
Save関数の中身でセーブファイルを生成して、リストの情報からデータをバイナリで保存します。
SaveManager.cpp #include <fstream>
using namespace std;
int CSaveManager::Save( DATARECORD *list, CSaveObjBase* pobj)
{
// セーブファイルオープン
ofstream ofs;
ofs.open( "TestSaveData.dat", ios_base::out | ios_base::app | ios_base::binary );
if( !ofs.is_open() )
return 0;
// データの書き込み
while(list->type != TYPE_END)
{
// データの位置を算出
char *pos = (char*)pobj + list->offset;
// 書き込み
ofs.write((char*)pos, list->size);
// 次のリストへ
list++;
}
return 1;
}
ファイルオープン後、リストタイプをチェックします。list->offsetには保存したい変数のオフセット位置がすでに格納されているので、それを絶対位置posに変換します。後はその位置にあるデータをofstream::write関数で書き出すだけです。これをTYPE_ENDが来るまで繰り返すわけでうす。
では実際に使ってみましょう。
main.cpp #include "SaveManager.h"
#include "Player.h"
int main()
{
CSaveManager SvMgr;
CPlayer Player1, Player2;
Player2.m_iLevel = 6;
Player2.m_bFlag = 32;
// セーブ
SvMgr.Save( Player1.GetDataRecord(), &Player1 );
SvMgr.Save( Player2.GetDataRecord(), &Player2 );
}}
実行するとTestSaveData.datというバイナリファイルが生成されます。ファイルの中身はこうなりました。
バイト +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 16進数 01 00 00 00 10 06 00 00 00 20 10進数 1 16 6 32
コンストラクタでCPlayer::m_iLevelは1、m_bFlagは16と初期化されています。またPlayer2はそれぞれ6と32に変更しましたが、それらがその順番できっちり入っているのがわかりますね(リトルエンディアンですので注意)。
D セーブオブジェクトを登録制に
テストで作ってみたCのSave関数は幾つか弱点があります。まず、ファイル名を指定できません。これは、それ用の関数を設けても良いかもしれませんし、セーブ時に直接指定する手もあります。それよりも、もっと面倒な事があります。それはセーブファイルの上書きができないということです。上のプログラムではSave関数を2回呼んでいますが、1回目で生成されたファイルに「追記」しています(ios_base::appを指定しているので)。これは同じファイルがある場合に、そこに常に追記してしまう仕様になっています。「セーブの前にファイルを消したら?」というのもダメです。それだと、最後に呼ばれたSave関数の情報しか格納できません。
セーブは基本的に沢山のオブジェクトをまとめて保存するのですから、保存の前に保存すべきオブジェクト(へのポインタ)を全部かき集めておいてからセーブしてしまうべきでしょう。つまり、セーブオブジェクトを登録制にした方がすっきりするのです。この実装はえらく簡単で、CSaveManagerクラス内にCSaveObjBaseクラスのリストを保持して、どんどん追加していけばよいだけです。セーブすべきオブジェクトを全部登録し終わったら、改めてSave関数を一度だけ呼び、ファイルを上書き状態で1つオープンして、登録されたオブジェクトのデータを全部書き込んで閉じれば良いのです。
登録する関数をAddSaveObj関数とします。内容はまぁ良いですよね。Save関数の引数は、先ほどはオブジェクトを直接指定していましたが、改訂版ではセーブするファイル名を指定することにしましょう。実装はこうなります。
SaveManager.cpp #include <fstream>
#include <string>
using namespace std;
int CSaveManager::Save( string filepath )
{
// セーブファイルオープン
ofstream ofs;
ofs.open( filepath.c_str(), ios_base::out | ios_base::binary );
if( !ofs.is_open() )
return 0;
// データの書き込み
list< CSaveObjBase* >::iterator it;
for(it = m_ObjList.begin(); it!=m_ObjList.end(); it++)
{
// セーブテーブルを格納
DATARECORD *list = (*it)->GetDataRecord();
while(list->type != TYPE_END)
{
// データの位置を算出
char *pos = (char*)(*it) + list->offset;
// 書き込み
ofs.write((char*)pos, list->size);
// 次のリストへ
list++;
}
}
return SAVE_OK; // セーブ完了
}
これで、ローカル変数のセーブは完了です。ポインタの扱いについては後ほど詳しく行いますが、まずはロードを実装してしまいましょう。
E ロードの実装に伴うCSaveManager、CSaveObjBaseの変更
セーブは吐き出されたデータを保存するだけですが、ロードは保持しているデータからオブジェクトを復活させなければなりません。これは、セーブよりもかなり面倒なんです。ところで、先ほどセーブしたデータをもう一度ここで見てみます。
バイト +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 16進数 01 00 00 00 10 06 00 00 00 20 10進数 1 16 6 32
さて、このデータだけからオブジェクトを復活することはできません。というのは、このデータが何のオブジェクトのデータなのか判別する手段が無いからです。つまり、セーブをする時にクラスを判別するIDを記録する必要があります。また、よく見るとデータのタイプ、データのサイズも実は分からない状態です。さらに言えば、オブジェクトを分けるセパレータも設けたいものです。CSaveManager::Save関数にはまだ色々と追加が必要のようです。
とりあえず、CSaveObjBaseクラスにGetClassID関数を追加します。
SaveObjBase.h class CSaveObjBase
{
public:
virtual DATARECORD *GetDataRecord() = 0; // データ配列を取得
virtual int GetClassID() = 0; // クラスIDを取得
};
クラスのIDは完全に一意である必要があります。重複は許されません。これは、結構しんどい制約だったりします。相当に確実なのはクラスの名前をそのまま数字に変換して保持することですが、文字1文字に1バイトを要するので、セーブファイルがどでかくなります。それほど沢山のクラスを扱わないのであれば、素直に#defineかenumで列挙するのが良いのではないでしょうか。
クラスIDは、オブジェクトのデータを書き出す直前に書き込むことにしましょう。CSaveManager::Save関数内に追記します。
SaveManager.cpp int CSaveManager::Save( string filepath )
{
//// 中略 ////
// データの書き込み
list< CSaveObjBase* >::iterator it;
for(it = m_ObjList.begin(); it!=m_ObjList.end(); it++)
{
// セーブテーブルを格納
DATARECORD *list = (*it)->GetDataRecord();
// クラスIDの書き込み
int ID = (*it)->GetClassID();
ofs.wirte((char*)&ID, sizeof(int) );
while(list->type != TYPE_END)
{
// データの位置を算出
char *pos = (char*)(*it) + list->offset;
// 書き込み
ofs.write( (char*)&list->type, sizeof(char) ); // データタイプ
ofs.write( (char*)&list->size, sizeof(int) ); // データサイズ
ofs.write( (char*)pos, list->size ); // データ本体
// 次のリストへ
list++;
}
// セパレータを1つはさむ
char sep = TYPE_END;
ofs.write( (char*)&sep, sizeof(char) );
}
return SAVE_OK; // セーブ完了
}
F ロード機構の実装
ロードはセーブと逆の作業をしていきます。まずCSaveManagerクラスにLoad関数を設けます。Save関数同様にファイル名を指定します。
SaveManager.h class CSaveManager
{
protected:
list<CSaveObjBase*> m_ObjList;
public:
int Save( string filename );
void AddSaveData( CSaveObjBase *pobj );
int Load( string filename );
};
Load関数の中では指定のセーブファイルを開き、セーブデータを頭から読んでいきます。まずセーブファイルの最初に記録されているクラスIDを見て作成するクラスを決めます。という事は、オブジェクトをIDから生成する仕組みが必要になります。これは、いわゆる「ファクトリークラス」の出番です。と言うより、CSaveManager自体をファクトリクラスにしてしまった方が早いですね。
SaveManager.h class CSaveManager
{
protected:
list<CSaveObjBase*> m_ObjList;
public:
int Save( string );
void AddSaveData( CSaveObjBase *pobj );
int Load( string filename );
virtual CSaveObjBase *CreateObj( int classID ); // オブジェクト生成
};
CSaveManager::CreateObj関数の実装は、例えばこうなります。
SaveManager.cpp CSaveObjBase *CSaveManager::CreateObj( int classID )
{
switch( classID )
{
case CPLAYER_ID:
return new CPlayer();
}
return NULL; // 指定のクラスが無い
}
この関数が生成するオブジェクトは空っぽです。ここにデータを送り込むにはどうするか?CSaveObjBase::GetDataRecord関数がそのまま使えます。この関数が返すDATARECORD構造体の配列にはオブジェクトへのポインタ(オフセット値)が直に入っていますから、そのポインタの先にデータを流し込めば良いのです。Load関数は次のようになります。
SaveManager.cpp int CSaveManager::Load( stirng filepath )
{
// セーブファイルオープン
ifstream ifs;
ifs.open( filepath.c_str(), ios_base::in | ios_base::binary );
if( !ifs.is_open() )
return 0;
while( !ifs.eof())
{
// データの読み込み
// クラスIDの取得
int ClassID;
ifs.read( (char*)&ClassID, sizeof(int) );
// 読み込み時にエラーが起きた場合はロードを終了する
if(ifs.fail() != 0)
break;
// オブジェクトの生成
CSaveObjBase *pobj = CreateObj( ClassID );
AddSaveData( pobj ); // 生成したオブジェクトは保持する
//オブジェクトが無い場合はデータを飛ばす
if(!pobj)
{
// 指定のクラスが無かったので空回し
int type;
int size;
while(!ifs.eof())
{
ifs.read( (char*)&type, sizeof(char) ); // データタイプ
if(type == TYPE_END)
break;
ifs.read( (char*)&size, sizeof(int) ); // データサイズ
ifs.ignore(size); // サイズ分のデータを飛ばす
}
continue;
}
// オブジェクトに値を格納
DATARECORD *list = pobj->GetDataRecord();
while(list->type != TYPE_END)
{
// データの位置を算出
char *pos = (char*)pobj + list->offset;
ifs.ignore( sizeof(char) + sizeof(int) ); // タイプとサイズを飛ばす
ifs.read( (char*)pos, list->size );
// 次のリストへ
list++;
}
// セパレータを飛ばす
ifs.ignore( sizeof(char) );
}
return LOAD_OK; // ロード完了
}
エラー判定などが大分入っているので長くなってしまいました。核となる部分は太文字の箇所です。クラスIDをセーブデータから取得した後に、そのIDに対応するオブジェクトをCreateObj関数から取得します。次にそのオブジェクトからDATARECORD構造体の配列を取得します。この配列には変数のアドレスへのポインタまでのオフセット値がしっかりと格納されていますから、それを1つずつ取り出してアドレスの絶対値を計算しします(posがそれです)。次にセーブデータには「タイプ」と「変数のサイズ」が5バイト分並んでいるので、それを飛ばします。ifstream::ignore関数はバイト単位でビシッと飛ばしてくれるので便利です。飛んだ先には取り出したいデータがありますので、list->size分取り出し、オブジェクトの中に直接格納します。これを配列分繰り返します。データタイプがTYPE_ENDだったらそのオブジェクトに格納すべきデータはもうないので、1バイト分のセパレータを飛ばし、次のオブジェクトの格納に移ります。ああ、説明も長いです・・・(^-^;
これでローカルメンバ変数のみの単純なセーブとロードが実はもう出来てしまいました。この後にポインタの扱いに発展させていきたいのですが、その前にリファクタリングをした方が良さそうです。
G リファクタリング
ここまで作成したCSaveManagerクラスとCSaveObjBaseクラスについてリファクタリングをしておきましょう。気になる所がいくつかあります。ます、CSaveManager::Load関数でCSaveObjBaseクラスにデータを流し込んだ後の処理です。オブジェクトによってはデータを流し込んだだけではだめで、そのデータからオブジェクトの内部を作り直さなければならない物もあります。しかし、現段階ではそれを行う部分がありません。これはSaveする時も同様で、オブジェクトによっては特別な処理をしなければならないこともあります。
そこで、CSaveObjBaseクラスに改めてSave関数とLoad関数をつくり、CSaveManagerクラスは登録したオブジェクトからそれら関数を呼ぶ事にします。
SaveObjBase.h class CSaveObjBase
{
friend class CSaveManager;
public:
virtual DATARECORD *GetDataRecord() = 0; // データ配列を取得
virtual int GetClassID() = 0; // クラスIDを取得
protected:
virtual int Save( CSaveManager* mgr );
virtual int Load( CSaveManager* mgr );
};
// セーブ
int CSaveObjBase::Save( CSaveManager* mgr )
{
return mgr->Write( GetDataRecord(), this ); // 書き込み
}
// ロード
int CSaveObjBase::Load( CSaveManager* mgr )
{
return mgr->Read( GetDataRecord(), this ); // 読み込み
}
CSaveObjBase::Load関数とSave関数は、他のオブジェクトから呼ばれたくは無いのでprotected宣言します。しかしそうするとCSaveManagerクラスもこれら関数を呼べなくなってしまいます。そこで、CSaveObjBaseクラスとCSaveManagerクラスはフレンド関係になってもらいます。
Write関数とRead関数の中身は、CSaveManager::Save関数およびCSaveManager::Load関数内で1つのオブジェクトに対する読み書き部分を抜き出した形となります。こうすることにより、これら関数の仕事がはっきりとし、また関数内のプログラムの量も格段に短くなり読みやすくなります。「読みやすくすること」はリファクタリングの基本ですね。
ここまでの段階の全コードをこちらに公開します。
解凍すると4つのヘッダーファイルおよび4つの実装ファイルが作成されます。コンパイラはVC++.Net2005が最適ですが、STLのある他のコンパイラでも多分大丈夫です。ファイルを全部プロジェクトに参加させた後、例えば次のように使用します。
#include "stdafx.h"
#include "TestSaveManager.h"
#include "Player.h"
int _tmain(int argc, _TCHAR* argv[])
{
CPlayer P, P2;
P2.SetLevel( 100 );
CTestSaveManager SM;
SM.AddSaveData( &P );
SM.AddSaveData( &P2 );
SM.Save( "SaveFile1.dat" );
SM.Load( "SaveFile1.dat" );
return 0;
}
ちなみに、現段階でLoad時に生成したオブジェクトの開放は行っておりません。メモリリークが発生しますので、実用は避けてください(デストラクタで消せばよいだけなので、実装できる方はどうぞ)。