チャチャチャおもちゃの抹茶っちゃ

ゲームのこととかプログラミングのこととか。気が向いたら書く。ブログタイトルは友人が考えました。

【C++】ステートマシンをつくる【OpenSiv3D】

はじめに

この記事はCCS Advent Calendar 2019 12/09の記事です。

adventar.org

こまつなさん 2019年、本厄だったらしい[CCS Advent Calendar 2019 7日目] ←前 | 後→MeerIiokaさん

C++でステートマシンをつくる

プログラミング言語C++を用いて、ステートマシンと呼ばれる機構をつくります。

ステートマシンって何?

もともとはオートマトンとかの分野で使われている言葉で、入力と現在の状態によって次の状態が決まる論理回路のことをステートマシンと呼んだりします。状態遷移図なんかでモデル化でき、複数の状態をなんらかの起因で変化していくものに対して扱います。

f:id:mattyan1053:20191209185151p:plain
状態遷移図の例(状態数2)

本記事では、C++のクラスなどを用いて、複数の状態を遷移することで描画内容や更新内容を変化させることのできる機構をつくることにします。

やりたいこと

ステートマシンを用いてやりたいことは以下のような感じです。

  • 現在の状態に適したupdate()draw()関数を呼びたい
  • 状態変化のトリガーを外部から与えたい
  • 状態変化時に呼びたい関数など毎フレーム呼ばれない関数も定義したい

これらの要望に応える機構を目指します。

つくったもの

実際に作ったものをはります。

注意点

以下のコードはOpenSiv3Dを用いて使うことを想定したコードです。とはいえ、OpenSiv3Dの機能で用いているのはHashTableとログ出力のみなので、std::unordered_mapcerrなどで代用すれば問題なく他でも使えると思います。

あとそれなりに新しいC++じゃないと動かないともいます。explicitとかある・・・・・・。

コード

  • StateMachine.hpp
# pragma once
# include <Siv3D.hpp>

/// <summary>
/// 状態基底クラス
/// </summary>
template<typename T>
class State {
private:

    T m_id;

public:

    /// <summary>
    /// デフォルトコンストラクタ
    /// </summary>
    /// <remarks> 必ず状態IDをつけます。 </remarks>
    State() = delete;

    /// <summary>
    /// 新しい状態を作成します。
    /// </summary>
    /// <param name="id"> 状態ID </param>
    explicit State(T id) :m_id(id) {}

    /// <summary>
    /// デフォルトデストラクタ
    /// </summary>
    virtual ~State() = default;

    /// <summary>
    /// 状態IDを取得します。
    /// </summary>
    const T& Id() { return m_id; }

    /// <summary>
    /// 状態に入ったときに呼ばれます。
    /// </summary>
    virtual void setUp() {}

    /// <summary>
    /// 情報を更新します。
    /// </summary>
    /// <remarks> 毎フレーム呼ばれます。 </remarks>
    virtual void update() {}

    /// <summary>
    /// 描画を更新します。
    /// </sumamry>
    /// <remarks> 毎フレーム呼ばれます。 </remarks>
    virtual void draw() const {}

    /// <summary>
    /// 次の状態に移る前に呼ばれます。
    /// </summary>
    virtual void cleanUp() {}

};

/// <summary>
/// 状態管理クラス
/// </summary>
template<typename T>
class StateMachine {

private:

    /// <summary>
    /// 状態リスト
    /// </summary>
    HashTable<T, std::shared_ptr<State<T>>> m_stateList;

    /// <summary>
    /// 現在の状態
    /// </summary>
    std::shared_ptr<State<T>> m_state;

public:

    /// <summary>
    /// デフォルトコンストラクタ
    /// </summary>
    StateMachine() = default;

    /// <summary>
    /// デフォルトデストラクタ
    /// </summary>
    virtual ~StateMachine() = default;

    /// <summary>
    /// 現在の状態のIDを返します。
    /// </summary>
    /// <returns> 現在の状態のID </returns>
    Optional<const T&> getCurrentStateName() const {
        if (m_state == nullptr) {
            return none;
        }
        return m_state->Id();
    }

    /// <summary>
    /// 指定の状態へ移行します。
    /// </summary>
    /// <param name="nextStateId"> 移行先の状態ID </param>
    void goToState(T nextStateId) {

        if (!m_stateList.contains(nextStateId)) {
            Print << U"Error: Not exist state: " << nextStateId;
            return;
        }

        if (m_state != nullptr) {
            m_state->cleanUp();
        }

        m_state = m_stateList[nextStateId];
        m_state->setUp();

    }

    /// <summary>
    /// ステートマシンの初期化を行います。
    /// </summary>
    /// <remarks> 必ずオーバーライドします。 </remarks>
    virtual void initializeStateMachine() = 0;

    /// <summary>
    /// 状態を追加します。
    /// </summary>
    /// <param name="state"> 追加する状態のstd::make_shared</param>
    void addState(const std::shared_ptr<State<T>>& state) {
        if (state == nullptr) {
            Print << U"Error: This state is nullptr";
            return;
        }
        if (m_stateList.contains(state->Id())) {
            Print << U"Error: Already exist state: " << state->Id();
            return;
        }
        m_stateList[state->Id()] = state;
    }

    /// <summary>
    /// 毎フレーム呼ばれます。情報を更新します。
    /// </summary>
    void update() {
        if (m_state == nullptr) {
            return;
        }
        m_state->update();
    }

    /// <summary>
    /// 毎フレーム呼ばれます。描画を更新します。
    /// </summary>
    void draw() const {
        if (m_state == nullptr) {
            return;
        }
        m_state->draw();
    }

};

使い方

以下使い方です。

State クラス

状態に関するクラスです。インターフェースみたいなやつです。状態遷移図でいう丸のやつですね。

状態を表すクラスに継承します。テンプレート引数に状態名管理クラスをつけるのを忘れずに。

enum TestStates{
    Idle,
    Runnning
};
struct Idle : public State<TestStates> {
};

デフォルトコンストラクタは許可していません。必ず状態名を引数につけましょう。

struct Idle : public State<TestStates> {
    Idle() : State<TestStates>(TestStates::Idle) {}
};
  • Id()

状態名を取得します。

  • setUp()

この状態に入ったときだけ呼ばれます。繰り返しは呼ばれません。

  • update()

毎フレーム呼ばれる更新用関数です。

  • draw()

毎フレーム呼ばれる描画用関数です。

  • cleanUp()

次の状態に移る直前に呼ばれます。

setUp()update()draw()cleanUp()はオーバーライドしなくても動きます。つまり何もしないということもOKです。

StateMachine クラス

ステートマシンとして管理したいクラスに継承します。継承の際はテンプレート引数に状態名を管理するものを入れてあげましょう。

class Test : public StateMachine<String> {
};
enum TestStates {
    Idle,
    Running
};
class Test2 : public StateMachine<TestStates> {
};

特に指定はありません。インスタンスを作ればOKです。特に理由がないのであればここでinitializeStateMachine()を呼んであげるようにするのが丸いです。

  • initializeStateMachine()

純粋仮想関数になっていて、必ずオーバーライドする必要があります。あとは初期化したいときに呼びましょう。ここでは、作成したインスタンスに状態を追加していきます。状態の追加にはaddState()メソッドを使います。追加する際には、安全のためshared_ptrを使っているのでstd::make_sharedします。

addState(std::make_shared<Idle>());
addState(std::make_shared<Running>());
  • addState(const std::shared_ptr<State>& state)

状態を追加する関数です。最初に呼ばずともあとから呼ぶこともできます。Stateクラスを継承したオブジェクトをmake_sharedして渡してあげましょう。

  • update()

現在の状態のupdate()関数を呼びます。

  • draw()

現在の状態のdraw()関数を呼びます。

  • goToState(T nextStateId)

指定の状態へ移行します。引数には状態名をいれてあげましょう。

if (ball.x >= 500) main.goToState(TestStates::Idle);
  • getCurrentStateName() const

現在の状態名を取得できます。

使用例

以下使用例です。ステートマシンで管理するオブジェクト本体の数値を状態クラスからもいじりたいのでコンストラクタで自身の参照を渡しています。よしなにやりましょう。
ちなみにこのコードでは円の移動に座標計算を行っていますがこれは悪い例で移動速度がフレームレートに依存します。Scene::Delta()を使うとかしましょう。

  • Main.cpp
# include <Siv3D.hpp> // OpenSiv3D v0.4.0

# include "StateMachine.hpp"

// 状態
// 列挙体を使っているがStringなどでも良い
enum BallStates {
    Idle,
    Moving,
};

// 状態管理したいクラス
class MovingBall : public StateMachine<BallStates> {

    Circle m_ball;

public:

    MovingBall() {
        m_ball.r = 40;
        m_ball.setPos({ 100, 200 });
        initializeStateMachine();
        goToState(BallStates::Idle);
    }

    // ステートマシンを初期化する。
    void initializeStateMachine() override {

        // 状態を追加していく。
        addState(std::make_shared<Idle>(*this));
        addState(std::make_shared<Moving>(*this));

    }

    // 状態をインナークラスで定義する。
    struct Idle : public State<BallStates> {
        // 外側クラスを覚えておくとアクセスできる
        MovingBall& main;
        Idle(MovingBall &_main) :State<BallStates>(BallStates::Idle), main(_main) {}

        // 状態移行直後
        void setUp() override {
            main.m_ball.x = 100;
            Print << U"Start Idle";
        }
        // 状態の最中
        void update() override {
            if (MouseL.down()) {
                main.goToState(BallStates::Moving);
            }
        }
        void draw() const override {
            main.m_ball.draw(Palette::Blue);
        }
        // 状態終了時
        void cleanUp() override {
            Print << U"Finish Idle";
        }
    };

    struct Moving : public State<BallStates> {
        MovingBall& main;
        Moving(MovingBall& _main) :State<BallStates>(BallStates::Moving), main(_main) {}

        void setUp() override {
            Print << U"Start Moving";
        }
        void update() override {
            main.m_ball.x += 10;
            if (main.m_ball.x >= 500) {
                main.goToState(BallStates::Idle);
            }
        }
        void draw() const override {
            main.m_ball.draw(Palette::Red);
        }
        void cleanUp() override {
            Print << U"Finish Moving";
        }
    };

};

void Main()
{
    // 背景を水色にする
    Scene::SetBackground(ColorF(0.8, 0.9, 1.0));

    // 状態を持つオブジェクト
    MovingBall b;

    while (System::Update())
    {

        b.update();
        b.draw();

    }
}

f:id:mattyan1053:20191209193102p:plain

最後に

以上ステートマシンをC++で作ってみた話でした。意外とこういう便利機能作ってみるのも楽しいので皆さんやってみては?
アドベントカレンダー明日は守銭奴な話か電子書籍のススメが読めるらしいです。

謝辞

作ったコードはOpenSiv3D作者のReputelessさんにレビュー頂きました、ありがとうございました!