その7 ゲーム遷移の実装をあれこれ考えてみる
ゲーム製作には大きく2つの面があるように思います。1つは画面に絵を効果的に出すための「画像」を取り扱う面。テクスチャを描画したりシェーダを書くのはこちらです。そしてもう1つはゲームの進行や場面変化などゲームそのものの面白さや動きを演出する「遷移」の面。両方とも欠かす事ができません。
この章では遷移の基本であるswitch〜caseをベースにして遷移の実装方法をあれこれ発展させてみたいと思います。試行錯誤は面白いですね。
@ たたき台switch〜caseによる遷移
ゲームの大外の流れを考えます。デモが始まって、タイトルになって、プレイヤーがstartを選択したらゲームが始まる。ゲームが終わったらタイトルに戻ってきて、何もしないとデモに遷移。タイトルでexitを選択したらゲーム終了。典型的な遷移の一つです。これを、まずはC言語ベースのswitch〜case文で書いてみます:
switch〜case構文によるタイトル画面の遷移 // ステート変数
enum State {
State_Demo,
State_Title,
State_Game
};
State state_ = State_Demo; // ステート
int demo();
int title();
int game();
int main() {
bool isEnd = false;
do {
switch( state_ ) {
case State_Demo:
if ( demo() == 0 )
state_ = State_Title; // タイトルへ
break;
case State_Title:
{
int ret = title();
if ( ret == 0 )
isEnd = true; // 終了
if ( ret == 1 )
state_ = State_Demo; // デモへ
else if ( ret == 2 )
state_ = State_Game; // ゲームへ
break;
}
case State_Game:
if ( game() == 0 )
state_ = State_Title; // タイトルへ
break;
}
} while( !isEnd );
}
こんな感じでしょうか。ざっと動作を見てみます。メインに入るとisEndという終了フラグを初期化します。すぐにループに入ってstate_を判定します。state_は最初State_Demoに初期化されているので、demo関数が実行されます。この関数の中身でごにょごにょと何かすると終了を表す「0」がそのうち戻ってきます。デモの後にはstate_はState_Titleになってタイトルが実行されます。タイトルでは3つの戻りの状態があります。「0」でゲームを終了します。「1」はデモへ移行します。そして「2」はゲームを開始します。State_Gameではgame関数が実行されてゲームが再生されます。ゲームが終了すると「0」が戻ってくるので、タイトルに状態を戻します。
このようにswitch〜caseを使うと状態遷移を記述する事ができます。ただ…あえてまずい感じに書いたのですが、突っ込みどころが色々とありますよね。それを改良していこうというのがこの章の目的です。
A ゲームシーン内で遷移させよう
まず最初の改良は、C言語をC++に移行することです。もちろんクラスを使います。ここではGameSceneクラスに先のプログラムをごっそりと移行します。どうしてそうするのか?ここが大切ですね。簡単に言えば枠組みが出来てわかりやすくなるからです。クラスは変数やメソッドを囲います。それにより、「このクラスはゲームの大外の遷移を専門に管理する事」ができます。逆に言えば、それ以外の事はしません。
main関数の中での条件分岐はすべてupdateメソッドの中に記述する事にします。GameSceneオブジェクトを使う人が責任を持ってこのメソッドを毎フレーム呼び出します。とりあえず移植するとこんな感じです:
updateメソッド // ステート変数
enum State {
State_Demo,
State_Title,
State_Game
};
State state_ = State_Demo; // ステート
// ステートメソッド
int GameScene::demo();
int GameScene::title();
int GameScene::game();
//! 更新
bool GameScene::update() {
bool isEnd = false;
switch( state_ ) {
case State_Demo:
if ( demo() == 0 )
state_ = State_Title; // タイトルへ
break;
case State_Title:
... 後は同じ〜
}
return isEnd;
}
updateメソッドが呼ばれる度にisEndが初期化されます。state_の判断は先と同じですが、実行するメソッドがクラス内部に隠蔽されます。これがくくる感覚です。そして更新の結果を毎フレームisEndで返します。
少しパッケージ化された感じがしますが、ステートが外部にあるのがいただけません。こうするとステートのスコープがグローバル領域になってしまいます。state_変数もこのクラス内でしか使わないのですから、当然メンバとしてくくるべきです。それを踏まえたヘッダーファイルはこうなります:
GameScene.h class GameScene {
public:
enum State {
State_Demo,
State_Title,
State_Game
};
public:
GameScene() : state_( State_Demo ) { }
// ステートメソッド
int GameScene::demo();
int GameScene::title();
int GameScene::game();
//! 更新
bool GameScene::update() {
private:
State state_; // ステート
}
まずState列挙型をクラスの内部で宣言します。こうすることでこの列挙型はGameScene::State列挙型となります。スコープ解決演算子によって名前衝突が避けられます。コンストラクタでステートが初期化されます。これが重要です。C言語の時にグローバルにあった変数は、すべてクラスの中だけで存在するものになりました。
B updateメソッドを整理
updateメソッドの中では状態の遷移を管理します。State_Titleの部分だけを取り出してみます:
updateメソッドのState_Title部分 bool GameScene::update() {
bool isEnd = false;
switch( state_ ) {
case State_Title:
{
int ret = title();
if ( ret == 0 )
isEnd = true; // 終了
if ( ret == 1 )
state_ = State_Demo; // デモへ
else if ( ret == 2 )
state_ = State_Game; // ゲームへ
break;
}
... 以下続く
}
}
この部分に嫌なマジックナンバーがありますね。0が終了って、コメントが無いと全くわかりません。しかも、3ヶ月経つと書いた本人でさえすっかり忘れるコードになっています。titleメソッドがどう振舞ってどう次の遷移に繋がるかは、きっとtitleメソッドが一番良く知っているはずです。ですから、上のステートの変更はtitleメソッドの中でおこなってしまいましょう:
titleメソッド void GameScene::title() {
int ret = title();
if ( ret == 0 )
isEnd_ = true; // 終了
if ( ret == 1 )
state_ = State_Demo; // デモへ
else if ( ret == 2 )
state_ = State_Game; // ゲームへ
}
変更が2つあります。1つはtitleメソッドの戻り値。内部でステートを変更するので戻す必要が無くなりましたのでvoidにします。もう1つは終了フラグです。終了を告げるためのフラグをここで設定するので、クラスのメンバ変数(isEnd_)に格上げします。
他のステートメソッドの中身も同様にすると、updateメソッドの中身は結構すっきりします:
状態を外に出したupdateメソッド bool GameScene::update() {
switch( state_ ) {
case State_Title: title(); break;
case State_Demo: demo(); break;
case State_Game: game(); brreak;
}
return isEnd_;
}
大分良くなってきました。一先ず節を閉めます。
C ステートとステートメソッドが同じになるのが嫌だなぁ〜
ここまでのクラス化で結構見通しが良くなりました。ただ、一つ微妙に面倒な部分があります。クラスのヘッダー部でState列挙型を定義しました。そして、それと同じ名前を持つメソッドを定義しました。これ、何だか2重定義のような感じがします。
例えばtitleメソッドの内部でゲームを始めたいならば、State_Gameを判定するのではなくて、updateメソッドに直接gameメソッドを呼んでもらえればかなり良い感じです。これを実現するには「関数ポインタ」を使います。クラスの場合はメソッドポインタとでも言うかもしれません。
まず、メンバ変数にステートメソッドのポインタを格納する変数を追加します:
メソッドポインタを持つGameSceneクラス class GameScene {
private:
void ( GameScene::*func_)(); // メソッドポインタ
};
この変数の初期化はコンストラクタで行います:
コンストラクタでメソッドポインタを初期化 GameScene::GameScene() :
isEnd_( false ),
func_( &GameScene::demo )
{
}
メソッドポインタの代入は上のように&演算子を付けるのがポイントです。これでfuncはdemoメソッドに化けました。updateメソッドでは毎回このポインタの先にあるメソッドを実行してもらうようにします:
メソッドポインタを実行するupdateメソッド bool GameScene::update() {
( this->*func_ )(); // メソッドポインタを実行
return isEnd_;
この呼び出し方は特殊な感じがしますが、これがメソッドポインタを正しく呼ぶ唯一の方法です。先ほどtitleメソッド内ではstate_にステート変数を入れましたが、今はもうstate_変数はありません。もちろんFuncにメソッドポインタを入れても良いのですが、多少わかりやすくするためにsetStateメソッドを追加しておきましょう:
setStateメソッド void GameScene::setState( void ( GameScene::*func )() ) {
func_ = func;
}
このメソッドを通せば、例えばdemoメソッド内で次のようにステートを変更できます:
demoメソッド内でステート変更 void GameScene::demo() {
setState( &GameScene::title );
}
こうする事で一々State列挙型を定義しなくともステートを変更できるようになりました。updateメソッドは大変にシンプルになってしまいました。最初のswitch〜caseは見る影もありません。
これでupdateメソッドのリファクタリングはだいたいOKです。後他にできそうな事を探してみましょう。
D デモシーンの分離
GameSceneクラスがupdateメソッド内で最初に実行するdemoメソッド。このメソッドは名前の通りデモを再生します。簡単に再生と言いますが、ファイルの読み込みや描画などかなり大変な処理を内部では行うはずです。それをdemoメソッド内で全部書くわけには…当然いかないわけです。
demoの再生にも「初期化」「再生」「終了処理」という一連の遷移があります。GameSceneクラスが内部で遷移を実現できたように、デモもクラス化すれば状態遷移ができます。そこでデモを再生するDemoSceneクラスを作ります。
デモシーンクラスの内部には、主に上の3つの状態があるとします。GameSceneクラスと全く同じように実装すると、ヘッダーは次のようになりそうです:
DemoScene.h class DemoScene {
public:
DemoScene() : func_( &DemoScene::init ) {}
~DemoScene();
private:
void init(); // 初期化
void play(); // デモを再生
void terminate(); // 終了処理
void setState( void ( DemoScene::*func )() ); // ステート設定
public:
bool update(); // 更新
private:
void ( DemoScene::*func_ )(); // メソッドポインタ
};
DemeSceneクラスを作ったので、これをGameSceneクラスに持たせる事にします:
GameScene.hにデモクラスをメンバとして追加 class GameScene {
private:
DemoScene demoScene_; // デモ
};
続いてGameScene::demoメソッドの内部の処理をこのクラスに委譲します:
GameScene::demoメソッドを変更 void GameScene::demo() {
if ( demoScene.update() )
setState( &GameScene::title );
}
updateメソッドが終了を告げたら、タイトルに戻る遷移をしています。
タイトルも割とやる事が色々ありますので、クラス化して動作を分離してしまいます。結局GameSceneクラスには各遷移に対応するシーンクラスが幾つか追加される事になります。今一度GameSceneクラスのヘッダーを確認しておきましょう:
GameScene.h class GameScene {
public:
GameScene();
~GameScene();
private:
void demo(); // デモ
void title(); // タイトル
void game(); // ゲーム
public:
bool update(); // 更新
private:
bool isEnd_; // 終了フラグ
DemoScene demoScene_; // デモ
TitleScene titleScene_; // タイトル
MyGameScene mygameScene_; // ゲーム
void( GameScene::*func_ )(); // メソッドポインタ
};
かなりすっきりです。
他に出来る事は無いでしょうか?じ〜っと考えてみます。
E updateの戻り値を列挙型に
GameSceneクラスのupdateメソッドの戻り値はbool型になっています。しかし、「false」が返ってきた時、これが何を表すのか良く分かりません。クラスのルールとして「終了」とすれば良いのですが、しばらく見ないと確実に忘れます。
そこで、updateメソッドの戻り値を列挙型にして文字として見られるようにします:
State列挙型 class GameScene {
public:
enum State {
State_Finish, // 遷移終了
State_Continue // 遷移中
};
pubic:
State update(); // 更新
};
こうすれば、updateメソッドの戻り値が何を表しているかわかります。
しかし、他の遷移クラスのupdateメソッドの戻り値も同じくしたいのですが、このままだと面倒な事になります。上のState列挙型は実際はGameScene::State列挙型です。DemoSceneクラスの内部にも同じようなState列挙型を定義すると、それはDemoScene::State列挙型になります。これれは似て非なる型です。できれば同じ型にしたいなぁと思うわけです。
一番簡単なのは列挙型をグローバルな領域で定義する事ですが、それはやっぱりちょっと嫌です。State列挙型なんてどこかで誰かが使っていそうですから。そこで、シーンクラスの親クラスを作ります。そして、その親クラスの中にStateResult列挙型を作る事にします(名前変えました)。遷移クラスはこの親クラスから派生する事にします:
SceneBaseクラス class SceneBase {
public:
enum StateResult {
StateResult_Finish, // 遷移終了
StateResult_Continue // 遷移中
};
virtual StateResult update() = 0; // 更新
};
class GameScene : public SceneBase {
public:
virtual StateResult update();
};
class DemoScene : public SceneBase {
public:
virtual StateResult update();
};
結構工夫できる部分はあるもんです。最初のC言語によるswitch〜case文と相当に変わってしまいました。そして、1つの状態を1つのメソッドで表現できているので遷移管理は随分としやすくなります。
F ところで描画はどうした
さて、ここまでの遷移クラスはupdateメソッドを公開しておりました。でも、ゲームには描画もあります。描画はupdateメソッドとは別のルートで一度に描画した方が良いと思います。描画メソッドを呼ぶのは大外の大外。たぶんアプリケーションクラスレベルです。
updateメソッドの中で子状態のupdateメソッドを呼んだように、描画メソッド(renderメソッド)の中でも子状態のrenderメソッドを呼ぶのがルールです。これを踏襲する事で、階層状の描画が実現できます。
これ以上の状態遷移を作るには、その仕様を完全に定義して幾つかのクラスを組み合わせる必要が出てきます。出来ない事はないのですが、懲りすぎるとはまります。状態遷移で大切なのは分かりやすさかなと思います。適度なバランスで実装したいもんです。
この章の遷移を参考にしたサンプルプログラムを作りました。簡単にするためコンソールアプリケーションにしましたが、しっかり状態遷移しておりますのでご確認下さい。