何か色々徒然と
状態遷移って何が必要?
(2010. 5. 24)
状態遷移…。ゲームはありとあらゆる状態遷移の塊です。ゲームをUpdateとDrawで分けるなら、Updateの殆どは状態遷移の管理です。この状態遷移、ど〜も私は苦手意識があるんですよね
ぇ…。作り方を知らないというのがどうも…。そこで状態遷移についてあれこれ調べて、そもそもにしてゲームの状態遷移には一体何が必要なのだろうか徒然なるままに考えてみることにしました。本当に徒然で適当に思考を巡らせていますので、変なところはご愛嬌です。
@ 元祖状態遷移switch〜case…ん〜…
C言語で状態遷移と言えばswitch〜case。誰でも最初に学びます。状態遷移って「もし〜になったら〜な状態になってね」という物で、switch〜caseは「もし〜になったら」そのものです。状態遷移にswitch〜caseを使って駄目な事はありません。小さくて固定的な部分なら逆にこの方が見通しが良くなる事もあります。ただ、巨大な場合分けに無理やり使うのはダメです。バグって死にます:
switch〜caseのマズイ例 switch(state) {
case Initialize:
state = initialize();
break;
case Title:
state = title();
break;
case Game:
switch(gameState) {
case Start:
gameState = gameRun();
break;
case Pause:
gameState = gameStop();
break;
case End:
gameState = gameEnd();
state = Title;
break;
}
case EndGame:
state = endGame();
break;
default:
break;
}
上はゲームの初期化からタイトル、ゲーム開始からゲーム中の振る舞いまでを無理やりswitch〜caseで書いたものです。まぁ動くには動きますが、何とも見づらい…。上の「Game」の場合分けの最後にbreakが抜けているのに気がつきましたでしょうか?そういうミスもしやすくなります。工夫が欲しいところです。
A 関数ポインタ
@例で、判別している列挙型の名前と対応するメソッド名が殆ど同じなのに注目です。分かりやすい2重化ですね。せめてこんな2重化は避けたい。そこで考えられるのが「関数ポインタ」です。C言語では関数も変数の一種のようなもの(型として識別されるもの)です。変数ならば値を代入出来ます。関数ポインタに代入出来るのは「関数」そのものです。簡単な例を挙げます:
関数ポインタ void (*func)(void); // 関数ポインタ変数
// 歩く
void Walk() {
// 方向キーが入力されていなかったら止まる
if (!input.someDirect())
func = &Idle;
}
void Idle() {
// 立ち止まり中
}
int main() {
func = &Walk;
while(1) {
(*func)(); // 何か動く
};
}
1行目にあるのが関数ポインタを代入出来る変数です。見慣れないと「!!??」となる表記です。変数名は「func」でその前に「*」が付いているのでこれはポインタであることがわかります。変数の型は「void (*)(void)」となります。これは「引数が無くて戻り値も無い関数」です。ちょっと別の表記で練習すると:
関数ポインタ bool (*func2)(const char* str, int size); // 関数ポインタ変数
// この関数はfunc2に代入出来ます
bool TestFunc(const char* str, int size) {
return true;
}
これは引数がconst char*型とint型の2つで戻り値がboolである関数を代入出来る変数です。このように、関数ポインタにはその型と厳密に同じ引数と戻り値を持つ関数を代入出来ます。代入するとどいう恩恵があるのか?それは変数を通して代入した関数を呼べてしまう点にあります。上の(*func)()に注目です。これも見た目謎ですが、funcがポインタである事を思い出すとちょっと見えてきます。ポインタ変数の前に「*」を付けるとその実体になります。今実体は関数本体そのもので、それには引数が必要なので、後ろに関数呼び出しと同様に引数を渡します。
関数を取っ換え引っ換えできると言うことは、上のように同じ呼び出し方法でも別の関数にぴょんぴょんジャンプ出来る事になります。実際上のWalk関数の中ではキー入力が無ければfuncをIdle関数に変更しています。自分で次の状態を指定したわけです。これがswitch〜caseから大きく進化した部分です。つまり、遷移をするために条件判定はしているけれどもその判定部分を個々の関数内部に局所化しています。これ、何となく下のような図式を実現していますよね:
これをつなげて行けば、あぁ、なんだか状態遷移がどんどんできていきそうな…そんな淡い感覚がここにはあります。
関数ポインタの欠点を考えてみます。例えば、歩く人が複数いたらどうでしょう?Walk1、Walk2と関数を作るのは馬鹿げています。できれば「個々の歩く人の中で個別に状態遷移をして欲しい」わけです。また、条件判定をするには判定元の値との比較が必要ですが、グローバルな領域に歩く人1、歩く人2…の値を保持するのも駄目ですよね。つまり、グローバルな関数ポインタには「拡張性と値の隠蔽性」がまるでないんです。
ではどうするか?
個別とか値の隠蔽と言うともう「クラス」しかありません。C言語の世界を卒業して、C++の世界に飛び込んで状態遷移をさらに発展させるのです。
B クラス内遷移 〜関数ポインタ発展版〜
関数ポインタは何もグローバルな領域にある関数しか代入出来ないという訳ではありません。クラスのメソッドも当たり前ですが関数です。ですから、これも立派に「型」になっていて、それを代入する「変数(クラスメソッドポインタ)」を定義できます:
クラスメソッドポインタ class Human {
protected:
void (Human::*func)(void); // クラスメソッドポインタ
void Walk() {
if (!input.someDirect())
func = &Human::Idle;
}
public:
Human() : func(&Human::Walk) {}
void update() {
(this->*func)(); // 何か動く
};
}
先程の例をクラス化したものです。太文字に注目。クラス内メソッドを指定するにはクラス名スコープを付けます。また関数呼び出しはかならずthisを通します(こうしないとコンパイルエラーになる)。後は先ほどと全く同じですよね。でも、これで、
・ この関数はHumanクラスしか知らない(隠蔽性)
・ Humanインスタンス(オブジェクト)を作ると、個々が独立して遷移してくれる
・ 条件分岐に必要な変数などが外部に漏れない(隠蔽性)
という数々の恩恵に授かれています。Humanを使う人は、な〜んにも考えずにHuman::updateメソッドを呼びつづけるだけ。それで内部では勝手に状態がゴロゴロと変わってくれます。ここまでで、次のような状態になったわけです:
状態遷移がクラスの中にすっぽりと収まった。これは大きな進歩です。後はクラスを作りこんで行けば局所的な状態遷移がいくらでも書けるのですから。しかもその状態遷移を持ったクラスの複製(オブジェクト)はいくらでも作れます。
では、ちょっと気弱な人(WeakHuman)がいて、止まる時になぜかびっくりしてしまうとしましょう。Humanから派生したくなりますね:
クラス内遷移の変更をしたいのだけど… class WeakHuman {
protected:
// 驚く!
void Surprise() {
// 驚くアクションをして止まる
surpriseAction();
func = &Human::Idle;
}
virtual void Walk() {
if (!input.someDirect())
func = &WeakHuman::Surprise; // ←遷移先を変更
}
public:
WeakHuman() {}
}
Walkメソッド内で入力が無かったら「WeakHuman::Surpriseメソッド」に遷移する。一見すると良さそうですが、実はこれ、コンパイラに怒られます:
型の違うメソッドは代入出来ない! error C2440: '=' : 'void (__thiscall WeakHuman::* )(void)' から 'void (__thiscall Human::* )(void)' に変換できません。
funcの型は「void (Human::*)(void)」、それに対して「void (WeakHuman::*)(void)」であるSurpriseメソッドは型が違うので代入できないんです。引数と戻り値だけでなく、クラス内メソッドの場合はその所属クラスも厳密な型として認識されてしまいます。これは痛く不便です。
ある型に別の型を代入する。それが出来る(出来るように見える)物の一つは例えばint型にfloat型の値を代入するなどです。これはコンパイラが暗黙的に型を変換してくれています。もう一つ、ある型に別の型を入れる仕組みがC++にはあります。それは「親クラスのポインタに小クラスのポインタを代入する」です。いわゆる「多態性(ポリモーフィズム)」です:
ポリモーフィズム Human *human = new WeakHuman;
この代入はもちろん合法。ならば、
ポリモーフィズム func = new WeakHuman::Surprise();
とメソッドをnewして代入するという暴挙は…もちろん出来ません(^-^;。でもですよ、この形、な〜んか惜しい感じがしませんか?もしSurpriseがメソッドではなくてクラスだったら…?この代入の形は一応合法です。
C 状態をクラス化すると派生クラスで変更ができる…のか?
Humanクラスにある各メソッド。これがメソッドだから派生クラスでの遷移の変更ができない。では、そのメソッドをクラスに昇格してあげるとどうでしょう:
状態のクラス化 class Human {
protected:
// 状態基底
class StateBase {
public:
virtual void update(Human* parent) = 0; // 状態更新
};
// 歩く状態
class Walk : public StateBase {
virtual void update(Human* parent) {
if (!input.someDirect())
parent->state = new Human::Idle;
}
};
// 待機状態
class Idle : public StateBase {
public:
virtual void update(Human* parent) {}
};
StateBase *state; // 状態オブジェクト
public:
Human() : state(new Walk) {}
void update() {
state->update(this); // 何か動く
}
};
先程までHumanクラスにあったWalkとIdleメソッドはそれぞれクラス(インナークラス:Inner
Class)に格上げされました。共にStateBaseという共通の小さなクラスを継承しています。StateBaseクラスには更新用のupdateメソッドが定義されています。Human親クラスが遷移を取りまとめるのでStateBaseのポインタであるstate変数を持っています。インナークラスは親のstateポインタを直接書き換える事で次の遷移へ移行しています。
Humanクラスは実際これで遷移します。ならば!弱い人(WeakHuman)クラスでもStateBaseクラスを継承したSurpriseクラスを作れば!:
同じクラスを親に持っているので代入可能!…とは… class WeakHuman : public Human {
protected:
class Surprise : public Human::StateBase {
public:
virtual void update(Human* parent) {
parent->state = new Human::Idle;
}
};
};
これはいいでしょう〜!っと思ったのですが、何とこれコンパイルエラーになります:
アクセス権が無い… error C2248: 'Human::state' : protected メンバ (クラス 'Human' で宣言されている) にアクセスできません。
んだとこらー!っと怒りたくなります(^-^;。実は、派生クラスのインナークラスを親のインナークラスから派生すると、そのクラスはもう親クラスへのアクセス権を失う「別人」になってしまうんです。そのため、publicで公開されている変数を除いてはアクセスできません。詳しくはC++踏み込み編その13「内部クラスは外側クラスのメンバにアクセスし放題!」を御覧下さい。上のHuman::stateをpublicにすると解消はできます。でも、それはやっちゃだめです。実に惜しい…
ちょっと整理しましょう。
状態をインナークラスにすると、一つのクラスの中で好きなように状態遷移を実現出来そうです。しかし、親の派生クラスでインナークラスを継承すると親クラスの非公開メソッドやメンバにアクセス出来なくなります。ん〜・・・・・・。問題は「子(インナークラス)が親にアクセスしようとする」から起こっています。であれば、「親が子から状態遷移オブジェクトをもらう」というのはどうでしょうか?どういう事かというと、
状態遷移オブジェクトを返すようにしても遷移は起こります class Human {
protected:
// 状態基底
class StateBase {
public:
virtual StateBase* update() = 0; // 状態更新
};
// 歩く状態
class Walk : public StateBase {
virtual StateBase* update() {
if (!input.someDirect())
return new Human::Idle;
return new Walk(*this);
}
};
// 待機状態
class Idle : public StateBase {
public:
virtual StateBase* update() {
return new Idle(*this);
}
};
StateBase *state; // 状態オブジェクト
public:
Human() : state(new Walk) {}
void update() {
state = state->update(); // 何か動く
}
};
上ソースの太文字のように、インナークラスが次の状態遷移オブジェクトを返すようにすると、StateBaseを継承するクラスであれば確実に大丈夫です。親は戻ってくる新しいオブジェクトを受ければ次の状態になれます。
今度は大丈夫かなぁと恐る恐るWeakHumanを下記のように書き換えてみました:
WeakHumanもインナークラスが遷移オブジェクトを返す class WeakHuman : public Human {
protected:
// 歩く状態
class WalkEx : public Walk {
virtual StateBase* update() {
if (!input.someDirect())
return new Surprise;
return new WalkEx(*this);
}
};
class Surprise : public Human::StateBase {
public:
virtual StateBase* update() {
return new Human::Idle;
}
};
public:
WeakHuman() {
state = new WalkEx;
}
};
今度は…大丈夫でした!コンパイルエラーにはなりません。実際上のWeakHumanクラスはWalkとIdleの間にSurprise遷移が挟まります。
これで派生クラスで親クラス内遷移の一部分を別ルートにするなどの処理が出来そうな感じがしてきました。しかし、しかしです、壁はまだ続くのです。
D 遷移挿入の伝播が命取り
Cでは実はハマリがあります。それを図で説明します。まず、下記のような遷移があったとします:
遷移に少し変更が欲しくなったとしましょう。具体的にはCからDに行く遷移の間にclass Pを挟みこみたいと思います。Cにならい、派生クラスでインナークラスPを作成したとします。このクラスは仕様通りにDオブジェクトを返します:
PからDへは問題ありません。問題は、CからPです。今CはDオブジェクトを返していますので、これをPに変更する必要があります。変更…じゃぁclass CExでも作りますかと:
これでC→P→DはOK。でも今度はclass Bの作成するCオブジェクトをCExオブジェクトにする必要が出てきました。あれあれ?
そうなんです。各状態遷移オブジェクトが内部で作成するオブジェクトを固定してしまっているため、一部の挿入に対してそこから上の全クラスの変更を強いられてしまうんです。これは、根本的にダメダメなんです!これは挿入だけじゃなくてどのインナークラスの部分変更でもアウトです…。ん〜〜〜〜〜〜〜
E 状態遷移ファクトリ
失敗の原因はインナークラス内で他のオブジェクトを直接newしたためでした。上の例で見てみると、class
Cが次の遷移オブジェクトであるDを「作っちゃった」のが敗因です。であればです、例えばclass
Cが作る次のオブジェクトをを外部で指定したらどうでしょう。…それは、ん〜駄目ですね。親が遷移を支配しないようにしている方針に逆行します。次の遷移オブジェクトを作るのはやっぱり内部。となると、「次のオブジェクトを作っているつもり」にするしか無さそうです。つまり、class
CはDを作っているつもりなのに、実はPを作らされていた!っとするわけです。そういう芸当をするのは「ファクトリクラス」です。
ファクトリクラスというのは、オブジェクトを生成する専門家です。作り方は色々とありますが、引数にオブジェクトを表すIDや名前を指定すると、それに対応したオブジェクトを返してくれます。
上の例では本当はclass Cが作るDをPに変更するのが理想なのですが、これは気を付けないといけない事があります。他のクラスがDを作ろうとすると、それが軒並みPになってしまいます。それはきっとまずいです。よって、class
Cの派生クラスCExを作り、Cの代わりにCExを生成するようファクトリクラスを変更します。CExはもちろん遷移先としてPを指定します。生成部分をファクトリクラスに置き換えた場合の遷移図はこんな感じになります:
各状態遷移クラスの中では次の遷移オブジェクトを名前で指定します(これはIDでも構いません)。ファクトリクラスではその名前から生成するクラスを判別してオブジェクトを返します。上の図の青いのがファクトリクラスが返すオブジェクトです。class
Bが「Cに遷移しますよ」と宣言したのですが、ファクトリクラスはCExを返します。CExは自由に書き換えて良く、次にPへ遷移します。これでPを迂回してDに遷移する遷移の変更ができました。
ところで、このファクトリクラス自体はどこにあるのでしょうか?これは…ん〜新たな問題です。親クラスに非公開で持たせると、C辺りでハマったように、その派生クラスでアクセスできなくなります。かと言って、インナークラス内に定義するわけにも行きません。これは親クラスにファクトリオブジェクトを投げてもらうしか無いかなぁと思います:
親クラスにファクトリメソッドを追加 class Human {
protected:
// 状態基底
class Factory; // 前方宣言が必要です
class StateBase {
public:
virtual StateBase* update(Factory* factory) = 0; // 状態更新
};
// 歩く状態
class Walk : public StateBase {
virtual StateBase* update(Factory* factory) {
if (!input.someDirect())
return factory->create("Idle");
return new Walk(*this); // 自分自身はまぁいいでしょう
}
};
// 待機状態
class Idle : public StateBase {
public:
virtual StateBase* update(Factory* factory) {
return new Idle(*this);
}
};
// ファクトリクラス
class Factory {
public:
virtual StateBase* create(std::string str) {
if (str == "Idle") return new Idle;
if (str == "Walk") return new Walk;
return 0;
}
};
StateBase *state; // 状態オブジェクト
Factory *factory; // 状態ファクトリオブジェクト
public:
Human() : factory(new Factory) {
state = factory->create("Walk"); // コンストラクタ生成になってしまいます
}
void update() {
state = state->update(factory); // 何か動く
}
};
まぁ、これでクラス内で好きなように遷移ができますし、派生クラスでちょっと違う遷移にしたい時もファクトリクラスを派生することで実現できます。それにしても…クラスがやけにボリュームが出てきました。別の遷移があった時に、毎回これを書くのはしんどいのではないでしょうか?
F Stateクラス
StateBaseクラスとFactoryクラスは、他のクラスでも使えそうな汎用性を持っています。これがHumanクラスの中にあるのはもったいない。そこでStateクラスを新設して、その中に次のように収めてしまいます:
Stateクラス class State {
protected:
class Factory;
// 状態基底
class StateBase {
public:
virtual StateBase* update(Factory* factory) = 0; // 状態更新
};
// ファクトリクラス
class Factory {
public:
virtual StateBase* create(std::string str) = 0; // 状態遷移オブジェクト生成
};
StateBase *state; // 状態オブジェクト
Factory *factory; // 状態ファクトリオブジェクト
public:
State() : state(), factory() {}
virtual ~State() {}
// 状態更新
void update() {
if (state && factory)
state = state->update(factory);
}
};
状態遷移があるクラスはこのStateクラスを継承し、インナークラスで具体的なStateBaseとFactoryクラスの派生クラスを作成します。updateメソッドはこれでほぼ固定でしょう。変なことはしないのが吉なのでvirtualにはしていません。
Stateクラスを継承したバージョンのHumanクラスはこんな感じになります:
Stateクラスを派生したHumanクラス class Human : public State {
protected:
// 歩く状態
class Walk : public StateBase {
virtual StateBase* update(Factory* factory) {
if (!input.someDirect())
return factory->create("Idle");
return new Walk(*this);
}
};
// 待機状態
class Idle : public StateBase {
public:
virtual StateBase* update(Factory* factory) {
return new Idle(*this);
}
};
// ファクトリクラス
class HumanFactory : public Factory {
public:
virtual StateBase* create(std::string str) {
if (str == "Idle") return new Idle;
if (str == "Walk") return new Walk;
return 0;
}
};
public:
Human() {
factory = new HumanFactory;
state = factory->create("Walk");
}
};
大分に形になってきました。次に考えるべきは何でしょう…。Humanクラスのupdateを呼ぶと遷移が繰り返されます。このupdateは永遠には続かないかもしれません。いつかは終りを迎えますが、おお、その終りの合図がありません(^-^;。State::updateの戻り値はvoidで、結果を返さないのはマズイですね。取り敢えずboolを返しておきましょうか:
Stateクラス class State {
....
// 状態更新
bool update() {
if (!state || !factory)
return false;
state = state->update(factory);
return true;
}
};
ん〜〜何かが違うなぁ…。Stateクラスの内部にあるStateBaseクラスもupdateメソッドを持っていて、その戻り値があれば遷移を続けるし、戻り値が無ければ…それはもう終わりです。両方のクラスの挙動は極めて良く似ています。であれば、いっそのことStateBaseを無くしてしまうのはどうでしょう:
StateクラスからStateBaseクラスを無くしてみる class State {
protected:
// ファクトリクラス
class Factory {
public:
virtual State* create(std::string str) = 0; // 状態遷移オブジェクト生成
};
State *state; // 内部の状態オブジェクト
Factory *factory; // 内部への状態ファクトリオブジェクト
public:
State() : state(), factory() {}
virtual ~State() {}
// 状態更新
State* update(Factory* outerFactory) {
// 内部遷移
if (state && factory)
state = state->update(factory); // このクラスが管理する内部遷移の結果
return myUpdate(outerFactory);
}
// 自分自身の具体的な振る舞い
virtual State* myUpdate(Factory *outerFactory) {return 0;}
};
んー、見た目カオスってきました。updateメソッドは最終的には自分自身の振る舞いを更新しますが、先に自分の内部遷移であるstateオブジェクトを評価します。myUpdateメソッドが自分の具体的な振る舞いを記述する部分でvirtualで宣言されています。外から来るouterFactoryからは自分の遷移先をもらいます。一方で内部に持っているfactoryは内部遷移用に作ります。図に書くとこういう感じです:
外側のStateは自分のupdateの中で内部に持っている遷移を管理し更新します(updateメソッド)。その後にmyUpdateメソッドの中で自分の具体的な振る舞いを行い、その結果を見て次に移る遷移先を指定します。次も自分自身を呼んで欲しい場合は自分を返します。次の人にバトンタッチする場合はファクトリに頼んで次の人を作ってもらいます。
G 内部遷移の更新が先か自分が先か?
上の実装では内部遷移の更新を行ってから自分の更新を行っています。しかし、これはいつもそうあるべきでしょうか?例えば、自分がユーザからの入力を管理しているとしましょう。そして、内部遷移ではその入力結果を使うとします。この時、自分の更新が後になると、内部遷移は「1フレーム前の入力結果を利用」する事になってしまいます。これは明らかにまずいです。一方、内部遷移が終わった事を受けて自分の振る舞いも変える事もあります。この場合内部遷移が先に終わらないといけません。あらら、つまり「Stateクラスの更新で内部遷移と自分との順序付けをしてはいけない」んです。
ではどうするか?自分の更新を内部遷移の上下2箇所に設けます:
Stateクラス自身の更新は2箇所で class State {
public:
// 状態更新
State* update(Factory* outerFactory) {
// 先行更新
State *outerState = preUpdate(outerFactory);
if (outerState == 0 || outerState != this)
return state; // 先行更新で外部遷移へ
// 内部遷移
if (state && factory)
state = state->update(factory); // このクラスが管理する内部遷移の結果
return postUpdate(outerFactory);
}
// 自分自身の具体的な振る舞い
virtual State* preUpdate(Factory *outerFactory) {return this;}
virtual State* postUpdate(Factory *outerFactory) {return 0;}
};
これで仕様によってpreUpdateメソッドとpostUpdateメソッドを使い分けられるようになりました。
H 内部遷移の変数渡しの問題が出てきた
例えば音楽プレイヤーの遷移を上のStateクラスで作ったとしましょう。「音楽選択遷移」で選択した曲を「再生遷移」で再生するとします。この時、再生する曲を再生遷移に教えないといけません。今、各遷移はStateクラスの派生クラスになっていて、音楽選択遷移の内部で再生遷移オブジェクトが作られます。選択した曲はその時に渡す事になるはずなのですが、これは叶いません。なぜなら、オブジェクトはFactoryオブジェクトを通して生成されるからで、Factoryクラスは「setPlayName」のような具体的なインターフェイスを持っていないからです。
それどころか、現在の実装だと各Stateクラスの派生クラスが完全に独立していて、遷移を抜けると何も残せない事になります。つまり「内部遷移間の連携」がまるではかれないわけです。これはまずい…。
内部遷移間で値を共有するには、2つのアプローチがあるかなと思います。1つは自分の中で具体的な遷移オブジェクトを作り、公開されているコンストラクタやインターフェイスを通して値を渡す方法。もう一つはFactoryと同様にupdateメソッドの引数で値を代入するオブジェクトを渡し、それを次の状態遷移にも引渡す方法。前者の方法は今の所難しいと言うことで、後者の方法を考えてみます。
updateに物を引き渡すのですから、こういう感じでしょうか:
共有オブジェクトを渡したいんですが… class State {
public:
// 状態更新
State* update(Factory* outerFactory, Value& outerVal) {
// 先行更新
State *outerState = preUpdate(outerFactory, Value& outerVal);
if (outerState == 0 || outerState != this)
return state;
// 内部遷移
if (state && factory)
state = state->update(factory, Value& innerVal);
return postUpdate(outerFactory, Value& outerVal);
}
};
Valueというクラスを作って、それを引き渡す形に変更です。で、でもですよ、このValueをどれだけ多機能にしても、今後作られるであろうすべての変数やオブジェクトに対応できるとはとても思いません。つまり、updateの引数に固定的な共有オブジェクトを渡すのは無理があるんです。
ただです、引数の型がわからないのであれば、それをテンプレート引数にしてしまう道があります:
共有オブジェクトをテンプレート引数に! template< class VAL >
class State {
State<VAL> *state; // 内部遷移
public:
// 状態更新
State<VAL>* update(Factory* outerFactory, VAL& outerVal) {
// 先行更新
State<VAL>* outerState = preUpdate(outerFactory, VAL& outerVal);
if (outerState == 0 || outerState != this)
return state;
// 内部遷移
if (state && factory)
state = state->update(factory, VAL& innerVal);
return postUpdate(outerFactory, VAL& outerVal);
}
};
これならば、自分の親遷移が適切な引数を与えてくれれば良さそうに思えます。しかし、またもや問題が。内部遷移にも同じ型を渡すべきでしょうか?これだと先ほどと同様にVAL型はすべての遷移に対応する必要があり、実質何も変わっていません。内部遷移に渡すべきものは別の引数でしょう。しかもその引数は内部でしか使用しないでしょうから、外部の人があまり気にしてはいけません。うむ・・・・。
これはもはや親であるStataクラスで吸収できそうにありません。
I そもそも親が内部遷移をサポートしなければ!
変数の共有化を考えた時にStateクラスに無理が出だしました。内部遷移へ渡す型を指定出来ないのが理由です。ここで考えを再度改めます。そもそも、この内部遷移は自分の内部での状態遷移を表すために設けたものでした。でも、State派生クラス自体が末端状態で、内部遷移を持たない事はもちろんあります。その人にとってみると、内部遷移の機構は無用の押し付けです。つまり、内部遷移はすべての状態遷移の必須項目ではないと言うことです。
であるならば、内部遷移の機構をごっそりと削ってしまえば良いのではないでしょうか?
内部遷移を無くしてみる template< class VAL >
class State {
public:
// 状態更新
virtual State<VAL>* update(Factory* outerFactory, VAL& outerVal) {
return 0;
}
};
え〜と、こうなりました(笑)。Stateクラスはupdate内で何かして、次の遷移オブジェクトを返すだけの非常に単純なクラスになってしまいました。さらにです、今やVALはどんな型にでもなれるので、Factoryクラスをその中に吸収することもできます。よって、上の実装はさらに簡潔になります:
恐ろしく簡単に… template< class VAL >
class State {
public:
// 状態更新
virtual State<VAL>* update(VAL& outerVal) {
return 0;
}
};
ここまで簡単になると爽快です。
さて、こうなると内部遷移は自前で実装する事になります。例えば次のような感じです:
内部遷移がある人 class SelectMusic : public State<HumanManager> {
class InnerVal {
int HP; // 体力
};
State<InnerVal>* innerState;
InnerVal innerVal;
public:
// 状態更新
virtual State<HumanManager>* update(HumanManager& outerVal) {
if (innerState)
innerState = innerState->update(innerVal);
return innerState ? new SelectMusic(*this) : new PlayMusic(innerVal.getSelectMusic());
}
};
まずSelectMusicクラスの中に内部変数を管理するInnerValクラスを作ります。そして、その引数を共有するState<InnnerVal>内部遷移オブジェクト(innerState)を作ります。自分自身のupdateの中では内部遷移に対してInnerValオブジェクトを渡します。これで「値の共有化」が実現出来ています。内部遷移の結果遷移が終わっていなかったら、自分自身のコピーを返します。一方内部遷移が終わっていたら(NULLが返ってきていたら)、曲を再生するPlayMusicに再生する曲を渡します。
これならば、派生クラスの中で次の遷移や内部遷移などを自由に制御できます。図に書くと次のパーツ範囲を管理できるわけです:
左側はStateクラス本体、右側は内部遷移をInnerStateとして定義した場合です。点線部分は他の人の管理範囲です。
J FSM(有限状態機械)
さて、この辺りでFSMについてちょっと考えてみることにします。FSM(有限状態機械 : finit state machine)は「状態」と「動作」からなる「ふるまいモデル」です。普段は状態でぐるぐるしていますが、何らかのきっかけで「遷移」を起こし次の状態になります。
FSMには主に4つの動作が定義されています:
開始動作(Entry Atcion) その状態に初めてなる時に行う動作 入力動作(Input Action) 状態内で外部入力があった時に行う動作 終了動作(Exit Action) 状態が終了する時に行う動作 遷移動作(Transition Action) 次の状態に遷移する動作
これらを上の図に当てはめるとこうなります:
新しい状態になった時に開始動作が一度だけ実行されます。いわゆる初期化作業です。そのまま入力動作を繰り返し、何らかのきっかけで遷移する時には終了動作を行います。後片付けをするわけです。遷移動作というは次の状態に移るまでの動作の事です。上の図ですと矢印の棒に当たる部分かなと思うのですが、次のフレームで次の遷移に移る場合はこの動作はきっとありません。
ウィキペディアにもありますが、状態遷移には「ムーアマシン(Moore Machine)」と「メリーマシン(Mealy
Machine)」の2タイプがあります。ざっくり言うと、ムーアマシンは次の状態に移る動作も遷移の一つとして考えます。例えばモデルのアニメーションで「歩く」と「走る」があった時、「歩きから走り初める」や「走りをやめて行歩きに戻る」という遷移をつなぐ部分も記述するのがムーアマシンです。一方でメリーマシンは「歩く」と「走る」の状態だけ記述します。途中動作は各状態遷移の中で記述されます。より詳細に記述するならムーアマシン、遷移の骨子で考えるならムーアマシンと言ったところでしょうか。
さて、このFSMとStateクラスとを見比べると、Entry Actionはコンストラクタ、Input
Actionはupdateメソッド、Exit Actionはデストラクタに相当するかなと思います。ただ、コンストラクタで初期化作業をするためには、引数に共有変数を渡す必要があります。一長一短ありますが、私はコンストラクタで大きな初期化をするのがあまり好きではないので、updateメソッド内で初期化を行うentryActionを呼ぶようにします:
FSMに見合うように template< class VAL >
class State {
private:
bool bFirstUpdate;
protected:
virtual void entryAction(VAL& outerVal) {}
virtual State<VAL>* inputAction(VAL& outerVal) {}
virtual void exitAction(VAL& outerVal) {}
public:
State() : bFirstUpdate(true) {}
// 状態更新
State<VAL>* update(VAL& outerVal) {
if (bFirstUpdate) {
entryAction(outerVal);
bFirstUpdate = false;
}
State<VAL>* outerState = inputAction(outerVal);
if (状態遷移が起こったら)
exitAction(outerVal);
return outerState;
}
};
オブジェクトが作られて最初にupdateが呼ばれた時にはentryActionメソッドが呼ばれ、そのままinputActionに入ります。このメソッドが「自分とは別の」新しい状態オブジェクトを返した時には遷移が移る時なので、exitActionメソッドを呼んで後処理します。これで、FSMになりますね。updateメソッドが仮想関数では無くなった事に注目です。いわゆる「テンプレートメソッドパターン」の典型です。
さて、これみよがしに赤い文字で書いた「状態遷移が起こったら」ですが、これ、割と難しい問題です。inputActionが返すのは状態オブジェクトへのポインタですが、内部ではnewが入るはずなので、例え自分のコピーだとしても別のポインタです。ですから、状態オブジェクトが自分の分身なのか別人なのかを区別できないんです。
これを解決するには、例えば状態オブジェクトに固有IDを振るというのもあるのですが、inputActionの戻り値を状態オブジェクトではなくてフラグにするのが楽で良いかもしれません。すなわち、次も自分であればfalse、次は新しい人(もしくはNULL)ならばtrueを返すように約束するわけです。ただ、どっちがtrueだっけ?と絶対になりますので、列挙型で明確化しておきます:
状態遷移が起きたか否かを区別 class StateBase {
protected:
enum Trans {
Trans_Me_Again, // 再び自分
Trans_Occured, // 状態遷移が起きた
};
public:
StateBase() {}
};
template< class VAL >
class State : public StateBase {
private:
bool bFirstUpdate;
protected:
virtual void entryAction(VAL& outerVal) {}
virtual Trans inputAction(VAL& outerVal, State<VAL> **ppOutNextState) {return Trans_Me_Again;}
virtual void exitAction(VAL& outerVal) {}
public:
State() : bFirstUpdate(true) {}
// 状態更新
State<VAL>* update(VAL& outerVal) {
if (bFirstUpdate) {
entryAction(outerVal);
bFirstUpdate = false;
}
State<VAL>* outerState = 0;
Trans trans = inputAction(outerVal, &outerState);
if (trans == Trans_Occurd)
exitAction(outerVal);
return outerState;
}
};
inputActionメソッドが変わりました。引数は共有変数オブジェクトと次の遷移オブジェクトへのポインタへのポインタです。ここに次の遷移オブジェクトが返されます。戻り値はTrans列挙型となりました。「StateBaseって何?Stateクラスの中に列挙型を入れちゃいかんの?」と思われたかもしれません。これは列挙型自体はどのステートでも共通です。Stateクラスの中に書くと、これがテンプレート引数によって別々のものと扱われてしまいます。つまり、もしStateクラスに列挙型を定義すると、State<Value>::Trans_Occurdとテンプレート引数付きで値を返すことになっちゃうわけです。これは面倒くさいんですよ。なのでStateBaseというテンプレート引数の無い親を用意して、そこに列挙型を設ければ、Stateクラスを継承していれば上のように列挙名だけでアクセスできます。
これでめでたく状態遷移が一応形になりました。
K 生ポインタをそろそろやめます
ここまで、新しい遷移オブジェクトを作る時に平気でnewしてきましたが、もちろんこのままだととてつもない数のリークになります。newは必要ですが、それをすぐにスマートポインタに包んでしまいましょう:
生ポインタからスマートポインタへ class StateBase {
protected:
enum Trans {
Trans_Me_Again, // 再び自分
Trans_Occured, // 状態遷移が起きた
};
public:
StateBase() {}
};
template< class VAL >
class State : public StateBase {
private:
bool bFirstUpdate;
protected:
virtual void entryAction(VAL& outerVal) {}
virtual Trans inputAction(VAL& outerVal, sp<State<VAL> > &outNextState) {return Trans_Me_Again;}
virtual void exitAction(VAL& outerVal) {}
public:
State() : bFirstUpdate(true) {}
// 状態更新
sp<State<VAL> > update(VAL& outerVal) {
if (bFirstUpdate) {
entryAction(outerVal);
bFirstUpdate = false;
}
sp<State<VAL> > outerState;
Trans trans = inputAction(outerVal, outerState);
if (trans == Trans_Occured)
exitAction(outerVal);
return outerState;
}
};
これでリークの問題は解決します。後はnewをする事自体の問題について考えてみます。
L newする問題
状態を遷移させる時に新しいオブジェクトをnewします。それはまぁ良いとして、遷移させない時にもnewが発生します。それは次のような返し方をするためです:
毎フレームnew sp<State<VAL> > PlayMusic::inputAction(Value &outerVal) {
return !bEndMusic ? new PlayMusic(*this) : new Idle;
}
曲が鳴っている間はずっとPlayMusicオブジェクトが新規に作成されコピーされ続けられます。あらゆる所で遷移が動いているとき、フレーム内はnewの嵐になってしまうわけです。
小さなオブジェクトをnewする時の問題はメモリ断片化です。これがあちこちにばらまかれると、大きなメモリを取りたくても取れなくなってしまいます。また、確保可能なメモリ領域を探す手間が増えるので、パフォーマンスが落ちていきます。ただ、Windowsの場合、OSが非常に賢いため、なるべく断片化が起こらないように調節してくれるそうです。つまり、WindowsOSで動かす分には、多量のnewによるメモリ効率についてはそれほど大きな問題にはなりません。
newする事のよりきついペナルティは「オブジェクトの生成コスト」です。クラスのオブジェクトを作ると、内部に持っているオブジェクトの生成も同時に行われます。さらに上のようにコピーコンストラクタによってコピーを行うと、代入演算子によるコピーが発生します。と言う事は、例えば上のPlayMusicクラスの内部に沢山のメンバ変数があったとすると、毎フレームそのディープコピーが行われてしまうことになります。これは、数がかさばると非現実的になってきます。
これを解決するには「内部スマートポインタ」を使います。こういう名前かは知りません(^-^;:
内部スマートポインタ class PlayMusic : public State<MusicPlayer> {
private:
struct Inner {
std::string musicName;
Music music;
int curTimePos;
int endTime;
};
sp<Inner> inner;
public:
PlayMusic() : inner(new Inner) {}
};
クラスの中にインナークラス(構造体)を一つ作ります。その中にメンバ変数を全部入れてしまいます。PlayMusicクラス自体はその構造体をコンストラクタで1つ作り、それをスマートポインタに包んでしまいます。こうすると、PlayMusic自体はスマートポインタを1つだけ持つ大変軽量なクラスに変わります。コピーコンストラクタや代入演算子でディープコピーが発生した時も、起こるのはスマートポインタ間のコピーです。これは取るに足らないものです。
逆に言えば、このような状態遷移クラスを使う場合「必ず内部スマートポインタを使わないと死ぬ!」という事です。Inner構造体は必須です!
M テストプログラムを作ってみる
ちょっと息切れしてきましたので、この辺でテストプログラムを作って終わることにします。
簡単すぎてもつまらないけど難しすぎても混乱します。ん〜、ちょっと地味ですが、ファイルを読み込んでいる間に「NowLoading」を出すプログラムを作ってみましょうか。アプリが始まると指定のテキストファイルを読み込みます。この時わざと時間をかけるために少しずつゆっくり読み込む事にします。読み込んでいる最中は「Now
Loading」を出し続けます。全部読み込み終わったらフェードイン。フェードが終わったらそのテキスト内容をちょっとずつ描画します。全部描画し終わったらフェードアウトさせます。こういうのってゲームで大変に良く出てきます。
これを遷移図で書くとこんな感じになりそうです:
この枠一つ一つが状態クラスです。これらはアプリケーションクラスの中で展開する事にしましょう。尚、以下のサンプルの完全なソースはこちらに置いておきます。適当にダウンロードして動かしてみて下さい。Stateクラスもありますよ(^-^)。
○ ファイル読み込み中遷移
ファイル読み込み中クラスとしてApplication::Loadingクラスを作ります。インナークラスですよ。ファイル読み込み中には大抵「Now Loading」が画面に出ています。これはその名もずばり「NowLoading」クラスを作り管理してもらいましょう。ファイルが読み込まれている間はこのクラスの描画メソッドが呼ばれるようにします。よってNowLoadingオブジェクトをLoadingクラスのメンバとします。Now Loadingを描画している間は裏でテキストファイルを読み込みます。今回は遷移を感じるために、わざと時間をかけて読み込むようにします。読み込みが終わったら次のApplication::FadeInクラスに遷移します。ひとまずザクザクっと作った実装を御覧下さい:
////////////////////////////
// ファイルロード
class LoadingFile : public State<Application> {
std::string filePath;
int readSize, fileSize;
FILE *fp;
public:
LoadingFile(const std::string &filePath) : filePath(filePath), readSize(), fileSize() {}
virtual ~LoadingFile() {}
protected:
virtual void entryAction(Application& outerVal) {
// フェーダを使ってホワイトアウト状態にしておく
outerVal.fadeIn(0xffffffff, 0);
// ファイルオープンしてちょこっとだけ読みます
fp = fopen(filePath.c_str(), "rb");
if (fp) {
// ファイルサイズ格納
fseek(fp, 0, SEEK_END);
fileSize = ftell(fp);
}
}
virtual Trans inputAction(Application& outerVal, Dix::sp<State<Application> > &outNextState) {
if (!fp) {
outNextState = 0;
return StateBase::Trans_Occured;
}
// 現在読めている部分までジャンプして1バイトだけ読む
char c[8];
fseek(fp, readSize, 0);
if (!fread(c, 1, 1, fp)) {
// 読みきった!
outNextState.SetPtr(new FadeIn); // フェードインへ
return StateBase::Trans_Occured;
}
// 読んだ分だけをメモリに追記
outerVal.textBlock.push(c, 1);
readSize++;
// 読み込み中なのでNowLoading描画を指示
char nlStr[128];
sprintf(nlStr, "Now Loading(%d)", 100 * readSize / fileSize);
outerVal.requestNowLoading(true, nlStr);
outNextState.SetPtr(new LoadingFile(*this));
return StateBase::Trans_Me_Again;
}
// 終了処理はちゃんとね
virtual void exitAction(Application& outerVal) {
// ファールポインタクローズ
if(fp) fclose(fp);
// 終端文字を入れておきます
outerVal.textBlock.push("\0", 1);
// NowLoading描画終了指示
outerVal.requestNowLoading(false, "");
}
};
最初に呼ばれるのがentryActionメソッドです。初期化メソッドですね。ここでは画面をホワイトアウトしておくためにフェーダーに白色を設定し、読み込むべきファイルをオープンしています。その後呼ばれるinputActionメソッド内ではオープンしたファイルを1フレーム1バイトずつ読んでいます。もちろんわざと時間を掛けるためですが、沢山のファイルを分割して読むときも読み込み負荷を低減するために似たような事をします。
読んでいる最中はアプリケーションに足してNow Loadingを描画するよう指示を出しています。上の場合はこちらから描画して欲しい文字列を指定しています。ファイル読み込みが終了したら(追加読み込みサイズが0)、フェードインする遷移へ移行します。終了処理exitActionではファイルポインタをクローズして、Now
Loading描画をやめるよう指示をしています。
○ フェードイン遷移
ロードが完了したらフェードインしてきます。これはフェーダーに対して「少しずつ透明になれ」と依頼するだけです:
class FadeIn : public State<Application> {
int counter;
static const int fadeTime = 60 * 2; // 2秒で
public:
FadeIn() : counter() {}
virtual ~FadeIn() {}
protected:
virtual void entryAction(Application& outerVal) {
// フェーダにフェードイン指示
outerVal.fadeIn(0x00000000, fadeTime); // 透明になれ
}
virtual Trans inputAction(Application& outerVal, Dix::sp<State<Application> > &outNextState) {
// 2秒経ったら文字送り開始
if (++counter >= fadeTime) {
outNextState = Dix::sp<TextDraw>(new TextDraw);
return StateBase::Trans_Occured;
}
outNextState.SetPtr(new FadeIn(*this));
return StateBase::Trans_Me_Again;
}
};
inputActionがぐるぐるまわるので、counterをインクリメントさせていき2秒(120フレーム)経ったら次の文字送り遷移に移行します。とても簡単です。
○ 文字送り遷移
画面が展開した後に文字送りを行います。文字送りの方法はあまたあるかと思いますが、ここでは付け焼刃的な方法です:
class TextDraw : public State<Application> {
std::vector<std::string> curStr;
unsigned pos;
unsigned line;
int wait;
public:
TextDraw() : pos(), line(), wait() {}
virtual ~TextDraw() {}
protected:
virtual void entryAction(Application& outerVal) {
curStr.push_back(std::string());
}
virtual Trans inputAction(Application& outerVal, Dix::sp<State<Application> > &outNextState) {
if (--wait <= 0) {
if (pos < outerVal.textBlock.size()) {
// 現在の文字位置の文字をチェック
char* textPtr = outerVal.textBlock.getPtr();
BYTE tmp[3] = {};
tmp[0] = textPtr[pos++];
if (IsDBCSLeadByte(tmp[0])) {
// 日本語なのでもう1バイト
tmp[1] = textPtr[pos++];
}
curStr[curStr.size() - 1] += (const char*)tmp;
// ライン数を更新
if (line != pos / 60) {
curStr.push_back(std::string());
line++;
}
// ウェイトを再度設定
wait = 3;
} else {
outNextState = Dix::sp<FadeOut>(new FadeOut); // フェードアウトへ
return StateBase::Trans_Occured;
}
}
// 文字列を描画してもらう
for (unsigned i = 0; i < curStr.size(); i++)
outerVal.drawText(curStr[i], i);
outNextState.SetPtr(new TextDraw(*this));
return StateBase::Trans_Me_Again;
}
};
描画すべきテキストは共有変数(outerVal)にあります。そこから1文字取得するのですが、2バイト文字の場合はさらにもう1バイト取得しています(IsDBCSLeadByte関数)。取得した文字をstd::stringに追加すれば1文字送った事になります。現在までの取得バイト数であるposが60バイトを超えたら次の行を追加しています。文字の描画自体は外の人に頼んでいます。すべての文字を描画し終わったらフェードアウト遷移へ移ります。
○ フェードアウト遷移
フェードアウトはフェードインと殆ど同じ実装になります:
class FadeOut : public State<Application> {
int counter;
static const int fadeTime = 2 * 60; // 2秒で
public:
FadeOut() : counter() {}
virtual ~FadeOut() {}
protected:
virtual void entryAction(Application& outerVal) {
// フェーダにフェードイン指示
outerVal.fadeIn(0xffffffff, fadeTime);
}
virtual Trans inputAction(Application& outerVal, Dix::sp<State<Application> > &outNextState) {
if (++counter >= fadeTime) {
outNextState = 0;
return StateBase::Trans_Occured;
}
outNextState.SetPtr(new FadeOut(*this));
return StateBase::Trans_Me_Again;
}
};
フェーダーに2秒でホワイトアウトするように指示し、あとは時間が来るまでinputActionメソッド内で待機です。
N 遷移の扱いの注意点
先のサンプルプログラムを作って感じたことは「遷移とタスクの使い分け」です。サンプルは上から下に向かって線形に進んでいます。プログラムもその通りに進むのですが、一般にゲームの画面は色々なものがいっぺんに動いています。これを遷移で行うのはちょっと苦しいかなと思いました。実際先のプログラムは文字送りが終わった後、文字が消えてフェードアウトします。改良する事はもちろんできるのですが、線形で単独にイベントが連なる遷移の性質を良く表しています。並列的な処理には遷移よりもタスクが向いています。ただ、タスクはタスクで良し悪しがあります。場面を作り込む時に遷移にすべきかタスクにすべきかは慎重に選択する必要があります。
遷移を使うべきかどうかは「状態がどれだけ排他的であるか」が重要なのかなと思いました。例えばゲームではある場面が終わった後に次の場面を裏読みしておく時間が必要になると気があります。この場合一時的に2つの場面が同時に動く状況が生じます。これを排他性の高い遷移で対応させようと思うとなかなかに大変だったりします。
もう一つ、上のサンプルで「おぉどうするべぇ」と思ったのは、更新と描画の使い分けでした。例えば、ゲームのタイトルには「背景」「メニュー文字」「カーソル」「3Dモデル」など一つの遷移の中で色々なものを描画する必要があります。この時、通常のゲームプログラムだと次のようなプロセスを経ます:
更新してからその状態を元に描画を行う。分かりやすい構図です。ところが、状態遷移を適当に作るとこういう事が起こってしまいます:
親の更新中に子の描画処理(実際に描画されてしまう処理)が起き、その後に親の描画処理が起こってしまいます。これはちょっと問題なわけです。ではどうするか?「状態遷移クラスの中に更新と描画を分けて『更新』が終わったら『描画』を呼ぶのはどうだろう」と思ったのですが:
これは実はまずいです。なぜなら、更新処理の結果「状態が移ってしまっている」事があるためです。更新処理が終わり描画処理でもう一度その状態オブジェクトにアクセスしたいと考えても、すでにそのオブジェクト自体が失われている事があるわけです。遷移クラスに更新と描画メソッドを設けて2度呼ぶというのはどうもうまくありません。
であれば、状態の更新時に描画すべきオブジェクトをPushしていくのはどうでしょう?つまりこういう状況を作るわけです:
更新処理で子や孫の遷移を更新する際に描画すべきオブジェクトをアプリケーションに登録(Push)してしまいます。赤い丸がその描画オブジェクトだと思って下さい。そして全更新が終わった後にアプリケーションの描画処理部分でPushされた描画オブジェクトを一気に描画してしまいます。こうすると、更新順番での描画が保証されます。それだけでなく、描画を登録制にする事でアプリケーションが意図的に描画の順番を変更する事も可能です。これは例えば半透明オブジェクトのZソートや同じテクスチャを用いたメッシュ描画の「属性ソート」などができるわけです。こういう事をできるようにするには、描画オブジェクトをしっかりと分離する必要があり、また描画オブジェクトが参照する数値やメッシュなどが更新オブジェクトが無くなっても存在することを保証するひつようがあります。参照先の保証というと「スマートポインタ」です。スマートポインタの威力が最強に発揮されるのはこういう場面かなと思います。
いやぁ、実に徒然に遷移について書き殴ってきましたが、ようやく自分の中でも遷移の方法や扱い方について腑に落ちた感があります。別の考えやもっと良い方法はあるかもしれませんが、一つ自分の中で方法論を確立しているというのは安心ですね。よかったよかった(^-^)