Help us understand the problem. What is going on with this article?

switch文を使わないゲーム状態遷移inC++

More than 1 year has passed since last update.

はじめに

今回Qiitaに書き込みするのは初めてです。C++でゲームプログラミングやってるので、自分のやり方を書き残していこうと思います。
独学プログラマ なので、間違っていたり、少しばかり極端なことも書くことになるかと思いますので、C++やゲームプログラミングをやっている諸兄を憤慨させることになるかもしれませんが、その場合はできる限り冷静にご指摘いただけると助かります。
なお、検証はVisualStudio2017のC++コンパイラ及びWindows10の実行環境です。

ここでいう状態遷移とは

ここでいう状態遷移とは、ゲームの各画面(シーン)状態(タイトル画面→キャラセレなど→ゲーム本編→ゲームオーバーなど)の流れをコントロールするものを表しています。
statemachine.PNG
そのほかにもキャラクターのニュートラル→ジャンプ→着地→しゃがみ→攻撃→ダメージなどの状態遷移も扱います。

switch文を使わないってどういう事?

おそらくプログラミングを習い始めの時は、上記に書いたような状態遷移を扱う場合は状態を表すenum変数などを用意して、何かイベントが起きるたびに変数を変更し、switch分岐で処理を切り替えていたと思います。

switch( screenstate ){
case title_scene:
  //タイトル用の処理
  break;
case characterselect_scene:
  //キャラセレ用の処理
  break;
    :
    :
  (以下略)
}

全然それで間違っていないのですが、どうしてもこのswitch文を置いている関数ブロックが長くなってしまうのがあまり好きじゃないです。これが置かれるであろう関数はUpdate関数とかになると思いますが…
凝ったことをやろうとすると、どうしてもこの部分が複雑になっていってよくないです。というわけで僕のプログラムでは状態遷移にswitch文を使用しません。

じゃあなにを使用するのか?

じゃあどうやって状態遷移をするのかというと3つほど考えます

  • Stateパターンによる切り替え
  • メンバ関数ポインタによる切り替え
  • ラムダ式による切り替え(たまにしか使いませんが…)

もしほかに良い、カッコいい状態遷移をお知りの諸兄はコメント欄等で教えていただけると幸いです。

Stateパターンで状態遷移

ひとまずは、シーンの遷移をStateパターンで作ってみることを考えます。基底クラスをSceneとします。

遷移基底クラス

class Input;
class SceneController;
///シーン管理のための基底クラス
///(純粋仮想クラス)
class Scene
{
protected:
  SceneController& _controller;
public:
  Scene(SceneController& controller);
  virtual ~Scene();

  ///シーンの更新を行う
  virtual void Update(const Input& input) = 0;
  ///シーンの描画を行う
  virtual void Draw()= 0;
};

ちなみにInputは入力情報が入ったオブジェクトクラスです。SceneControllerは遷移を制御するクラスです。こいつを基底クラスに持たせることによって、各シーンは次のシーンへ飛ぶことができるようにしています。 ついでだからSceneControllerも書きますが

遷移制御クラス

class Scene;
class Input;
///シーン管理クラス
class SceneController
{
private:
  shared_ptr<Scene> _scene;
public:
  SceneController();
  ~SceneController();

  //各シーンのUpdateを呼び出す
  void SceneUpdate(const Input& input);

  //シーンを変更する
  void ChangeScene(Scene*);

};

ChangeSceneでシーンの変更を行うようにしています。さて、こうするとクラス・ファイルの数は増えますが、それぞれの状態を独立して管理でき、さらには状態遷移について管理するのは実は状態遷移前のクラスが状態遷移後のクラスを知ってればいいという事になります。

あとはかんたん

さて、あとはご想像の通り、Sceneを継承したクラスを作っていけば良いです。
classdiagram.PNG
ここまでができていれば後は、切り替え部分だけですが簡単です。SceneControllerのメンバ関数にChangeSceneがあったと思いますが、アレの中身は

void
SceneController::ChangeScene(Scene* scene) {
  _scene.reset(scene);
}

このようになっているとします。あとは呼び出し側がChangeSceneにnewして突っ込めば終わりですよね。初期シーンがタイトルシーンだとすると

SceneController::SceneController(){
  _scene.reset(new TitleScene(*this));
}

これでデフォルトがタイトルシーンとなります。thisを渡しているのはタイトルシーンじしんが次のシーンへの切り替えをできるようにするためです。

という事でタイトルシーンが切り替えする部分は

void 
TitleScene::FadeoutUpdate(const Input& input) {
  if (--_wait == 0) {
    _controller.ChangeScene(new CharacterSelectScene(_controller));
  }
  else {
    //フェードアウト処理コード(略)
  }
}

のようにします。タイトルがフェードした後に次の処理に来るように書いています。ちなみにしれっとスマートポインタを使用しているので元のシーンは自動的に消えます。

ちょっと改良

階層型メニューとか、ポーズ画面とかのように、遷移後に「前の画面に戻りたい」という事があるかと思います。結構やってる人も多いみたいなんで書くのは憚られるんですが、敢えて書きます。
状態遷移オブジェクトをスタック型で管理するという事です。

状態遷移をスタックで管理!?

そもそもスタックを知らない人もいるかと思いますが、FILO(First In Last Out)の構造です。どうするかをポーズ画面を例にとって説明します。

まず、ここまでの話をそのまま実装しているなら
1.PNG
こういう遷移になっているでしょうが、ポーズはポーズしたところでゲームオブジェクトを消してもまずいし、終わったら速やかにポーズ状態を解除して、ゲーム中に戻ってきてほしい…なのでスタックを使って
pausein.PNG
こうして
pauseout.PNG
こうしたい。

実装

さて、どう実装しようか?という話ですが、素直にstd::stackを使います。普通のStateパターンが実装できてるなら簡単ですよ~。
まず、SceneControllerをこう書き換えます。

#include<stack>
class Scene;
class Input;
///シーン管理クラス
class SceneController
{
private:
  std::stack<std::shared_ptr<Scene>> _scene;

public:
  SceneController();
  ~SceneController();

  void SceneUpdate(const Input& input);
  void ChangeScene(Scene*);
  void PushScene(Scene*);
  void PopScene();
};

さっきまでただのshared_ptr<Scene>だったのが、stack<shared_ptr<Scene>>になってるのが…わかるだろう?
まずはChangeSceneの挙動は「変えずに」スタックに対応させていきたい。となるとChangeScene

void
SceneController::ChangeScene(Scene* scene) {
  _scene.pop();
  _scene.emplace(scene);
}

こうなる。ちなみにemplaceというのは、新しい記法で、↑のは内容的には
_scene.push(shared_ptr<Scene>(scene));
と同じ結果になる。もともとあったシーンオブジェクトはpopしてしまって、新しいのをpushしてるんで、Changeと同じ意味になります。
さて、これで元のChangeSceneはできたわけだ。ちがうのはここからだ・・・です。PushScenePopSceneですが、こいつらも簡単です。

void
SceneController::PushScene(Scene* scene) {
  _scene.emplace(scene);
}

void
SceneController::PopScene() {
  _scene.pop();
  assert(!_scene.empty());
}

・・・工事完了です。ね?簡単でしょ?
あとは、ゲームプレイ中にポーズボタン押されたタイミングで、ポーズをPushSceneすればOK

void
GamePlayingScene::Update(const Input& input) {
  if (input.IsTriggered(0, "pause")) {
    _controller.PushScene(new PauseScene(_controller));
  }
  (以下略)
}

あ、PushとPauseがスペル似てるから、混乱しそうだけど間違えないでくださいね。Popはただpopするだけなので説明はしませんが、Pauseシーンが終わる際にpop命令出せばまた元のプレイシーンに戻るわけです。
はい、Stateパターンの状態遷移に関してはこのくらいです。

メンバ関数ポインタによる状態遷移

さて、Stateパターンくらいは知ってるよって人も多いんじゃないかなと思います。が、ここで考えるかもしれません。
「でもさぁ、シーンならともかくキャラクタの状態遷移にStateパターン使うのは大げさすぎ!やっぱりパパッとswitch文書いて…終わりでいいんじゃない?」
まぁ待て待て、まだ慌てるような時間じゃない

メンバ関数ポインタとは・・・?

そこで活用してみたいのはメンバ関数ポインタです。
メンバ関数ポインタじたい基本なはずですが、なんかマイナーなのか知らない人が多いみたいです。
メンバ関数ポインタとはその名の通りクラスのメンバ関数を指し示すポインタの事です。
通常の関数ポインタとちょっと違うので、注意してください。

基本構文

メンバ関数ポインタ宣言

戻り値 (クラス名::*変数名)(パラメータ);
ちょーっとややこしいですが、けったいな宣言ですね。メンバ指定子にアスタリスクがつくというなんだこれって感じの宣言になります。
実際のコードだとこんな感じですね
void (Player::*_updater)(const Input&);
これで宣言はOKなので、あとはメンバ関数の代入と実行だけです。自クラス(この例だとPlayerクラス)内で宣言する場合でも、所有クラス名::を忘れいないようにしなければならないので、注意してください。

メンバ関数ポインタにメンバ関数を代入

ポインタ変数=&クラス::メンバ関数名;
注意点は、&が付くところと所有クラスの名前::がやっぱり必要なところです。&を忘れがちなので注意しましょう。

メンバ関数ポインタで関数を実行

(オブジェクト名->*)(パラメータ);
文法はこうです。これはオブジェクトがポインタだった場合なので、オブジェクトが非ポインタなら
(オブジェクト名.*)(パラメータ);
になります。アスタリスクが変なつき方をしているので、注意しましょう。初めての人がみたら何やってんの?って感じです。実際のコードは

void 
PauseScene::Update(const Input& input) {
  (this->*_updater)(input);
}

こんな感じですね。Update関数はとにかく_updaterを実行して、状態によって_updaterの中身が切り替わっていくというイメージですね。

ラムダ式を使って状態遷移

正直これはおまけみたいなもんです。
前回のメンバ関数ポインタの話をラムダ式にしただけですね。
ラムダ式自体はご存知だと思います。
ただしメンバ変数に利用するにはもうちょっと知識が必要です。それはラムダ式の「型」がなんなのかという知識です。

いつもだったら、ラムダ式を代入する先の変数はauto指定をしていると思うんですが、これって型はどうなってるんでしょうか?

ラムダ式は関数オブジェクト(ファンクタ)に代入されます。さて、ファンクタの代入先の型はどう作ったらいいんでしょうか?

それはfunction型を使います。まず#include<functional>します。そのうえで

std::function< 戻り値(パラメータ) > ラムダ変数;

という感じで宣言します。このラムダ変数に対して、ラムダ式を代入することができますので、内容の切り替えが可能!!というわけです。

ラムダ変数 = [ バインド ]( パラメータ ){ 処理 };

で代入できます。で、今回みたいに状態遷移に利用したい場合には予めメンバのconstラムダ変数に初期化子で代入する事になります。例えば

const std::function<void(const Input& input)> lambda_fadein;
const std::function<void(const Input& input)> lambda_fadeout;

のように宣言しておいて、コンストラクタ初期化子(初期化リスト)で

PauseScene::PauseScene(SceneController& controller):Scene(controller),
lambda_fadein ([this](const Input& input) {
  ()
  --_frame;
}),
lambda_fadeout([this](const Input& input) {
  ()
  ++_frame;
}){
  ()
}

のように、初期化リストでlambda_fadeinやlamda_fadeoutなどの値を入れてしまいます。constがついてるから、ここでやらなければ怒られるんです。
それができたら、

std::function<void(const Input& input)> _lambda;

で宣言して、

_lambda=lambda_fadein;

などやって切り替えます。
ちなみに実行は、当然のように

_lambda(input);

です。簡単ですね。

それでは、switch文を使わない状態遷移に関しては以上です。読んでいただきありがとうございました。

注意点(およびご意見)

さっそくプログラマ諸兄からご指摘がありましたので記載しておきます。

  • 「メンバ関数ポインタは空関数があると、最適化の際に違う場所を指し示すことがあるよ」 とのこと

さて、空関数は当然のように意味がないため最適化された時に実体が省略されてるっぽい?飛び先としてのシンボルは存在するものの空関数を指し示しているはずの場所は次の関数となっており、予想外の挙動を示したとのこと。

一応、VisualStudio2017のReleaseモードでいくつかのパターンを試してみましたが、今のところ確認できていない。ご指摘いただいた兄貴曰く「コンパイラによるのかもしれない」とのこと、予想外の挙動をされても怖いので安全策としてvoid空関数ではなくboolかintあたりにして0かfalseを返すようにするか、volatile指定子をつけるかという対処になるかな。最適化についての挙動を確認できていないため、どこまで用心深くやるかは環境次第かなと。

  • メンバ関数ポインタだとデバッグ時に状態を確認できないため、enumでやったほうが分かりやすいのではないか?

とのこと。一応、VisualStudio2017の場合ならば現在の状態のメンバ関数はデバッガで確認できます。
しかしながら、これも環境によってはそんな親切な機能がない場合もありますので、開発環境次第という事ですね。
VisualStudioが神ツールであることがわかり、ありがたいご意見でした。世の中VisualStudioで開発できる環境ばかりでもないので、注意しましょう。

tsuchinokoman
元2Ⅾゲームプログラマー兼アマチュアボクサー。現在はどこぞでDirectX12とかC++プログラミングとか、数学とか教えています。 https://twitter.com/CTsuchinoko
http://pg-boxer.cocolog-nifty.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした