ホームへ戻る

4章. シーン管理の方法を学ぶ

 ゲームプログラムで一番大きな設計単位は「シーン」です。
ゲームは起動するとロゴが出て、タイトル画面となり、難易度選択画面が出て、ゲーム画面となります。



この一つ一つの画面のことを「シーン(Scene)」と呼びます。
このシーンはうまく設計しないと管理が複雑になってしまいます。
うまく設計しないと大変そうだなという例を紹介します。

例えばまずタイトル画面が出ます。



「ゲームスタート」を選択します。



章の選択画面でデフォルトから一つ下に変更して「第六章」を選択します。



難易度選択画面で、「あ、やっぱりさっきの画面に戻ろう」とユーザーが思って戻ると・・。



何が表示されるべきでしょうか?
もちろんさっきの画面がさっきの状態で表示されてほしいです。
しかし「画面」ごとにnewしてdeleteするような設計では「さっきの状態に戻す」ということが出来ません。
それなら全部の画面のインスタンスを全保持するように設計する様にすればいいのかと言えばそうではなく、
ユーザーが完全にゲーム画面をスタートすると、もう難易度選択画面のインスタンスは要らない訳です。

「じゃゲームが始まったらメニューのインスタンスを破棄するようにすればいいのか」と言われたらそうでもありません。

上の画面で難易度が選択されたらもうその画面に戻ることはできないので破棄してもいいのですが、
スペルプラクティスのような画面を実装しようとしたらどうでしょう。
四聖龍神録2には章ごとに練習できるモードや弾幕ごとに練習できるモードがあります。



タイトル画面で「弾幕」を選択します。



遊びたい弾幕を選択します。



ゲームが始まりました。この時戻ると・・?
先ほどの弾幕選択画面の、先ほど選択し終わった状態で復活して欲しいです。

このようにシーンのインスタンスを破棄する破棄しない、更には「一つ前に戻る」ということを容易に管理できるようにしないといけません。
ここでうまく設計するポイントとして「シーンをスタックで管理する」という考え方を取り入れます。スタックについて知らない人はこちらをどうぞ

一つ一つのシーンをスタックに詰めて行くのです。
一番上にあるシーンのみ更新・描画処理をし、下にあるシーンはただインスタンスを保持しているだけで何も処理はさせません。
以下のようにシーンのスタックをするスタッカーを用意します。



まずはタイトル画面をスタックに積みます。



タイトル画面で「ゲームスタート」が選択されると「章選択画面」を生成してスタックに積みます。
スタックの最上位シーンのみ更新・描画しますから、灰色になったタイトル画面は処理しません。インスタンスを保持しているだけです。



ここで章が選択されたら、「難易度選択画面」を生成してスタックに積みます。
今度は章選択画面も処理せず新しく上に積んだ難易度選択画面のみを更新・描画します。



ここでユーザーが「前の画面に戻る」処理をした時は、スタックからポップするだけでよいのです。
すると章選択画面はインスタンスをそのまま保持しているため、先ほどと全く同じ状態で復帰出来ます。
この積んでいるシーンは一気に空っぽにしたいこともあります。
タイトル画面→章選択画面→難易度選択画面 で決定するとゲームが始まります。
ゲーム画面からはもう難易度選択画面に戻る必要はないので、スタックを空にした上でゲーム画面シーンを積みます。



このように空にしたいタイミングやシーンを生成・破棄するタイミングはスタックを保持しているクラスには分からず、
各シーンクラスの中で決定され、シーンを保持している親に伝達する必要があります。

このようにインスタンスを保持している側から親にコールバックする仕組みも必要です。
言語によってコールバックとかリスナーとかと呼びます。
これも含めて設計してみましょう。


eScene.h


#pragma once

enum eScene {
    Title,  //タイトル画面
    Game,   //ゲーム画面
};

シーンの種類をenumで定義しています。
非常にたくさんのシーンを作ることになりますが、まずはタイトル画面とゲーム画面しかないことにします。


IOnChangedListener.h


#pragma once

#include "eScene.h"
#include "AbstractScene.h"
#include "Parameter.h"

class IOnSceneChangedListener
{
public:
    IOnSceneChangedListener() = default;
    virtual ~IOnSceneChangedListener() = default;
    virtual void onSceneChanged(const eScene scene, const Parameter& parameter, const bool stackClear) = 0;
};

次にコールバックするためのリスナーインターフェイスを定義します。
C++にはご存知の通り、インターフェイスという概念が言語として存在しない、まさに言語の欠陥があります。
インターフェイスのことを知らないひとはググってください。
そこで純粋仮装関数だけのクラスを定義してインターフェイスクラスとして定義します。
必要なのはIOnSceneChangedListenerを実装したクラスは「onSceneChanged」を実装しなければならないということです。
引数は、
第一引数scene … 次にどのシーンにするのかということを示すシーン種別のenum
第二引数parameter … 次のシーンに渡すパラメータ※
第三引数stackClear … スタックをクリアするか否かの指定
を指定します。
※パラメータというのは、例えば難易度シーンで「Normal」が選択されてゲーム画面に遷移する時「前の画面でNormalが選択された」という情報を伝達する必要があります。
 このような次のシーンに渡す必要のあるパラメータを示します。

ここで注目なのが、このようなパラメータをグローバル変数やシングルトンを通じてやり取りしてはいけないという点です。
C++をかじったことがある人なら「Singletonパターン」というデザインパターンを知っているかと思います。
Singletonパターンは要するにC言語の悪しき文化であるグローバル変数と同じです。
Singletonパターンは初心者が使うとほとんどの場合破綻した設計になります。
「どこからでもアクセスできる便利なクラス」は設計する時非常に便利に感じますが、ものすごく多くのデメリットがあります。
Singletonパターンを使ってはいけない理由はどこか別の章で紹介しますが、
前のシーンでSingletonクラスorグローバル変数に設定して、次のシーンでその値を見るような設計にはしてはいけません。
各インスタンスで閉じるようなパラメータの渡し方をしてください。
そのためにも、第二引数のparameterは中級者から上に成長するために必要な考え方です。
重要なのでparameterについては次の章で詳細に説明します。この章では一旦読み飛ばしてください。


AbstractScene.h


#pragma once

#include "IOnSceneChangedListener.h"
#include "Parameter.h"

class IOnSceneChangedListener;

class AbstractScene
{
protected:
    IOnSceneChangedListener* _implSceneChanged;
public:
    AbstractScene(IOnSceneChangedListener* impl, const Parameter& parameter);
    virtual ~AbstractScene() = default;
    virtual void update() = 0;
    virtual void draw()   = 0;
};

AbstractScene.cpp


#include "AbstractScene.h"

/*!
@brief コンストラクタ
@param impl シーン変更のリスナー
@param parameter 前のシーンから渡されたパラメータ
*/
AbstractScene::AbstractScene(IOnSceneChangedListener* impl, const Parameter& parameter) : 
    _implSceneChanged(impl)
{
}

次にAbstractSceneの定義です。
シーンクラスはそれぞれのシーンごとにありますが、管理部は現在何のシーンになっているかなんて知りたくありません。
そこでストラテージパターンというデザインパターンを使います。
例えば今回Looperクラスがシーンスタックを保持する役をするわけですが、
Looperが今何のシーンになっていて、次に何をしないといけないかをすべて知らないといけないような設計にはしたくありません。
そうでないとシーンの数が多くなってきたとき、Looperの仕事が膨大になってしまいます。
Looperさんはインスタンスを生成する時にだけ、指示されたインスタンスを生成し、
後は保持する、更新指示をする、描画指示をするだけで、その時にはどの画面でも共通のI/Fで処理できるようにしたいと思います。

例えば以下のような例をみてください。今画像のデコーダがあるとします。
JpgのデコーダとPngのデコーダがありますが、decodeというI/Fは同じです。
ですので、その抽象クラスとしてImageDecoderを定義します。


これをソースコードで表現してみましょう。

class ImageDecoder {
public:
    virtual Bitmap decode() = 0;
};

class JpgDecoder : public ImageDecoder {
public:
    Bitmap decode() override;
};

class PngDecoder : public ImageDecoder {
public:
    Bitmap decode() override;
};

int main(){
    ImageDecoder* decoder = new JpgDecoder();
    decoder->decode();
}


main部はJpgDecoderのインスタンスを生成していますが、保持の仕方としては、抽象クラスのポインタで保持しています。
こうすることで、PNGのデコーダーに切り替えたい時は
= new JpgDecoder();

= new PngDecoder();
にするだけで本体の部分は全く変更する必要がありません。
こうするとインスタンスの保持クラスの仕事が減り楽になります。
シーンのインスタンスを保持するLooperクラスはnewするときだけ条件分岐するものの、後は全く条件分岐なく、
どのインスタンスでも共通の処理をします。このような設計方法をストラテージパターンと呼びます。

ではAbstractSceneを継承したTitleSceneを見てみましょう。


TitleScene.h


#pragma once

#include "AbstractScene.h"

class TitleScene : public AbstractScene
{
public:
    TitleScene(IOnSceneChangedListener *impl, const Parameter& parameter);
    virtual ~TitleScene() = default;
    void update() override;
    void draw() override;
};

TitleScene.cpp


#include "TitleScene.h"
#include <DxLib.h>

TitleScene::TitleScene(IOnSceneChangedListener* impl, const Parameter& parameter) : AbstractScene(impl, parameter)
{
}

void TitleScene::update()
{
}

void TitleScene::draw()
{
    DrawString(100, 100, "タイトル画面", GetColor(255,255,255));
}

特になんてことはない、親クラスを継承して定義しただけです。
コンストラクタの引数もただ親に渡しているだけなので、自分は何もしていません。
唯一やっているのは「タイトル画面」と表示しているだけです。
TitleSceneはタイトル画面のシーンです。タイトル画面でやりたいことはここに実装していきます。

では次にこれを保持しているLooperクラスをみてみます。


Looper.h


#pragma once

#include <stack>
#include <memory>
#include "AbstractScene.h"
#include "IOnSceneChangedListener.h"

class Looper final : public IOnSceneChangedListener
{
public:
    Looper();
    ~Looper() = default;
    bool loop() const;
    void onSceneChanged(const eScene scene, const Parameter& parameter, const bool stackClear) override;

private:
    std::stack<std::shared_ptr<AbstractScene>> _sceneStack; //シーンのスタック
};

Looper.cpp


#include "Looper.h"
#include "TitleScene.h"
#include "Error.h"

using namespace std;

Looper::Looper()
{
    Parameter parameter;
    _sceneStack.push(make_shared<TitleScene>(this, parameter)); //タイトル画面シーンを作ってpush
}
/*!
@brief スタックのトップのシーンの処理をする
*/
bool Looper::loop() const
{
    _sceneStack.top()->update();    //スタックのトップのシーンを更新
    _sceneStack.top()->draw();      //スタックのトップのシーンを描画
    return true;
}

/*!
@brief シーン変更(各シーンからコールバックされる)
@param scene 変更するシーンのenum
@param parameter 前のシーンから引き継ぐパラメータ
@param stackClear 現在のシーンのスタックをクリアするか
*/
void Looper::onSceneChanged(const eScene scene, const Parameter& parameter, const bool stackClear)
{
}

Looperはシーンのスタックを持つのでprivate変数が一つ追加されています。
コンストラクタでは最初の画面をnewして格納しています。
parameterは前の画面からの引き継ぎが無いので無いのと同じです。

Looper.loop()ではスタックのトップにあるシーンだけを更新・描画します。
今は一つしかはいっていないので、TitleSceneがupdate、drawされることになります。
シーンの変更通知の中身はまだ実装していません。
次の章で説明します。

実行結果


→分からないことがあれば掲示板で質問して下さい


HPトップへ 質問掲示板へ

- Remical Soft -