🔥

C++で汎用的な状態管理クラスを実装する方法 後編

2024/12/15に公開

この記事は、神戸電子専門学校 ゲーム技研部 Advent Calendar 2024の15日目の記事です。
https://qiita.com/advent-calendar/2024/kdgamegiken

はじめに

この記事は後編です。
ステートパターンの基礎について詳しく知りたい方は、前編から読んでいただけるとありがたいです。

汎用的なステートマシン

前編で作成したシンプルなステートパターンのみを実装したステートマシンでは少し使い勝手が悪いため、後編からは汎用的に使えるステートマシンに改造していきます。

StateBase

まずは、全てのステートの基底となるクラスです。
変更点はコード内と照らし合わせてください。

テンプレートを使うことで、ステートの持ち主に簡単にアクセスできるようになります。[変更点(1),変更点(3)]

また、フレンドクラスを使いマシン専用の処理を用意する事で、安全に処理を呼んだり値をセットしたりすることもできます。[変更点(2),変更点(4),変更点(5)]

これで、使う側で特別な実装をしなくても、簡単かつ安全にステートマシンや持ち主にアクセス出来るようになりました。

template<typename OwnerType>
class StateMachine;

// 全ての状態の基底となるクラス
template<typename OwnerType> // 変更点(1) テンプレート引数で状態を管理する対象の型を指定
class StateBase
{
protected:
	friend class StateMachine<OwnerType>; // 変更点(2) ステートマシン以外から呼び出し関数などにアクセスできないように

	// 状態が開始したときに一度だけ呼ばれる処理
	virtual void OnStart(OwnerType* a_pOwner) {}// 変更点(3) ステートの持ち主を引数から取得できるように
	// 状態が更新するときに呼ばれる処理
	virtual void OnUpdate(OwnerType* a_pOwner) {}
	// 状態が終了したときに一度だけ呼ばれる処理
	virtual void OnExit(OwnerType* a_pOwner) {}

private:

	// この状態を管理しているステートマシーンをセット
	void SetMachine(StateMachine<OwnerType>* a_pMachine)
	{
		m_pMachine = a_pMachine;
	}

	// 開始関数をマシンから呼ぶための関数
	void CallStart(OwnerType* a_pOwner) // 変更点(4) 仮想関数をマシンが安全に呼ぶための関数を追加
	{
		if (m_pMachine == nullptr || a_pOwner == nullptr)
		{
			return;
		}
		OnStart(a_pOwner);
	}

	// 更新関数をマシンから呼ぶための関数
	void CallUpdate(OwnerType* a_pOwner)
	{
		if (m_pMachine == nullptr || a_pOwner == nullptr)
		{
			return;
		}
		OnUpdate(a_pOwner);
	}

	// 終了関数をマシンから呼ぶための関数
	void CallExit(OwnerType* a_pOwner)
	{
		if (m_pMachine == nullptr || a_pOwner == nullptr)
		{
			return;
		}
		OnExit(a_pOwner);
	}

protected:

	StateMachine<OwnerType>* m_pMachine = nullptr; // 変更点(5) このステートを管理しているステートマシンのポインタを保存

    // 
    OwnerType* m_pOwner = nullptr;


};

StateMachine

次に、ステートを管理するクラスです。

StateBaseにポインタを渡すために、テンプレートでステートの持ち主の型を指定し、初期化時にセットするようにします。[変更点(1),変更点(2)]

ステートの変更関数ではステートのインスタンスではなく型を渡し、可変長テンプレート引数でステートのコンストラクタに値をセットすることで、関数内でステートの作成、初期化、セットが完結します。[変更点(3)]

また、ステート変更命令は一度関数オブジェクトに格納して次の更新前に実行するため、ステートの更新関数中に変更命令を出しても処理中にメモリが解放されることもなくなりました。[変更点(4),変更点(5)]

// 状態を管理するクラス
// テンプレート引数は状態を管理する対象の型
template<typename OwnerType> // 変更点(1) テンプレート引数で状態を管理する対象の型を指定
class StateMachine
{
public:
	StateMachine()
		:m_fnChangeState([](){})
	{

	}

	// ステートマシンの開始関数
	void Start(OwnerType* a_pOwner) // 変更点(2) 初期化時にオーナーをセット
	{
		m_pOwner = a_pOwner;
		m_fnChangeState = [](){};
	}

	// 状態を変更する関数
	// 引き数はステートのコンストラクタに渡す値
	// ステートの変更処理を関数ポインタの中に閉じ込め更新が終わった後に呼ぶ
	template<typename StateType, typename...ArgType>// 変更点(3) ステートの型を指定したら関数内でインスタンスを作成し、コンストラクタに値を渡せるように変更
	void ChangeState(ArgType...a_args)
	{
		// ステートの変更命令を格納する
		m_fnChangeState = [&]() // 変更点(4) ステートの変更命令を関数ポインタに格納する
			{
				// オーナーがセットされてなければ何もしない
				if (m_pOwner == nullptr)
				{
					return;
				}

				// もしすでにステートがセットされてたら終了する
				if (m_spNowState != nullptr)
				{
					m_spNowState->CallExit(m_pOwner);
					m_spNowState = nullptr;
				}

				// 新しいステートを作成
				m_spNowState = std::make_shared<StateType>(a_args...);
				if (m_spNowState == nullptr)
				{
					return;
				}
				// 新しいステートにこのマシーンをセット
				m_spNowState->SetMachine(this);
				// ステートの開始
				m_spNowState->CallStart(m_pOwner);
			};
	}


	// 状態の更新
	void Update()
	{
		// ステートの変更命令があれば処理する
		m_fnChangeState(); // 変更点(5) ステートが更新する前にステートの変更命令があれば実行		
		m_fnChangeState = [](){};
		

		// ステートがセットされてたら更新する
		if (m_spNowState != nullptr)
		{
			m_spNowState->CallUpdate(m_pOwner);
		}

	}

private:

	// 状態の持ち主のポインタ
	OwnerType* m_pOwner = nullptr;

	// 今のステート
	std::shared_ptr<StateBase<OwnerType>> m_spNowState = nullptr;

	// ステートの変更命令を保存しておく関数オブジェクト
	std::function<void()> m_fnChangeState;

};

使い方

基本的な使い方は前編で作成したステートマシンと同じですが、いくつか変わった点があります。

まず、今回の実装からStateBaseに持ち主のポインタにアクセスする機能が追加されたため、PlayerStateBaseのような特別な基底クラスを実装する必要がなくなりました。
代わりに、ステートマシンの初期化時に持ち主のポインタを渡す必要があります。

また、ステートの変更命令も変更関数を呼ぶだけで済み、直後にreturnを呼ぶ必要もありません。

コードはイメージです

// サンプルプレイヤー
class Player :public GameObject
{
public:

	void Start()
	{
        // 初期化時にこのインスタンスのポインタを渡す
		m_stateMachine.Start(this);
        // 初期状態のステートをセット
        m_stateMachine.ChangeState<Player_StandState>();
	}

	void Update()
	{
		m_stateMachine.Update();
	}

private:

    // ステートを管理するクラスのインスタンス
	StateMachine<Player> m_stateMachine;

};

// プレイヤーのジャンプ状態
class Player_JumpState :public StateBase<Player>
{
public:

	Player_JumpState(Vector3& a_vec)
	{
		m_jumpForce = a_vec;
	}

	Player_JumpState(float a_x, float a_y, float a_z)
	{
		m_jumpForce.x = a_x;
		m_jumpForce.y = a_y;
		m_jumpForce.z = a_z;
	}

	void OnStart(Player* a_pPlayer)override
	{
		// アニメーションを変更
		m_pPlayer->ChangeAnimation("Jump");

		// 指定した向きに衝撃を加える
		m_pPlayer->AddImpact(m_jumpForce);
	}

	void OnUpdate(Player* a_pPlayer)override
	{
		// プレイヤーが地面と触れたらStandに遷移
		if (m_pPlayer->IsTouch("Ground"))
		{
			m_pMachine->ChangeState<Player_StandState>();
		}
	}

	void OnExit(Player* a_pPlayer)override
	{

	}

private:

	Vector3 m_jumpForce;

};

// プレイヤーのダッシュ状態
class Player_RunState :public StateBase<Player>
{
public:

	void OnStart(Player* a_pPlayer)override
	{
		a_pPlayer->SetAnimation("Run");
	}

	void OnUpdate(Player* a_pPlayer)override
	{
		// 前方向に力をセットし続ける
		m_pPlayer->SetForce(0, 0, 1);

		// Wキーが押されなくなったらStandに戻る
		if (!Input::IsHold("W"))
		{
			m_pMachine->ChangeState<Player_StandState>();
		}
		// Spaceキーが押されていたらジャンプする
		if (Input::IsHold("Space"))
		{
			m_pMachine->ChangeState<Player_JumpState>(0, 10, 3);
		}
	}

	void OnExit(Player* a_pPlayer)override
	{

	}

private:

};

// プレイヤーの待機状態
class Player_StandState :public StateBase<Player>
{
public:

	void OnStart(Player* a_pPlayer)override
	{
		a_pPlayer->SetAnimation("Stand");
	}

	void OnUpdate(Player* a_pPlayer)override
	{
		// Wキーが押されていたら走り出す
		if (Input::IsHold("W"))
		{
			m_pMachine->ChangeState<Player_RunState>();
		}

		// Spaceキーが押されていたらジャンプする
		if (Input::IsHold("Space"))
		{
			m_pMachine->ChangeState<Player_JumpState>();
		}
	}

	void OnExit(Player* a_pPlayer)override
	{

	}

private:

};

応用

前項でステートパターンを汎用的に扱うためのクラスができました。
ここからは、ステートパターンをさらに活かすためのアイデアを紹介します

ステートのリスト管理

RTSのNPCなどで複雑なステート管理が必要な場合は、ステートのリスト管理を実装するといいかもしれません。

NPCの狩りを実装する場合、索敵ステート→接近ステート→攻撃ステート→肉を拾うステート、といった流れで出来そうです。
ですが、狩りが終わった後に元居たステートに条件式で戻るのは大変です。

そこで、ステートをリストで管理することで状態遷移の履歴を残します。そうすることで、今いるステートをポップするだけで前のステートに戻れるようになります。
また、終了処理がステートのポップだけで済むため、「モノを拾ってインベントリに入れる」といったような汎用的なステートを使いまわせるというメリットもあります。

これで、比較的手軽に複雑な状態管理を実装することができるようになります。

↑の場合、攻撃ステート、拾うステート、食事ステートは一度実装すれば使いまわすことができます。

StateMachine

ステートをリストで管理するように変更します。[変更点(4)]
それによりリストの最後尾が現在のステートとなるため、現在のステートを取得する方法も修正します。[変更点(3)]

ChangeState関数に加えて、履歴を残しながらステートを追加するPushState関数、リストからステートを1つポップするPopState関数を追加します。[変更点(1),変更点(2)]

// 状態を管理するクラス
// テンプレート引数は状態を管理する対象の型
template<typename OwnerType>
class StateMachine
{
public:
	StateMachine()
		:m_fnChangeState([]() {})
	{

	}

	// ステートマシンの開始関数
	void Start(OwnerType* a_pOwner)
	{
		m_pOwner = a_pOwner;
		m_fnChangeState = []() {};
	}

	// 状態を変更する関数
	// 引き数はステートのコンストラクタに渡す値
	// ステートの変更処理は更新の直前に呼ぶ
	template<typename StateType, typename...ArgType>
	void ChangeState(ArgType...a_args)
	{
		StateChanger<StateType>(true, a_args...);
	}

	// 状態を追加する関数
	// 引き数はステートのコンストラクタに渡す値
	// ステートの変更処理は更新の直前に呼ぶ
	template<typename StateType, typename...ArgType> // 変更点(1) ステートを追加する関数を追加
	void PushState(ArgType...a_args)
	{
		StateChanger<StateType>(false, a_args...);
	}

	// 今のステートをポップする
	void PopState() // 変更点(2) 
	{
		m_fnChangeState =
			[&]()
			{
				// 一番後ろのステートを取得
				// 無ければreturn
				std::shared_ptr<StateBase<OwnerType>> spNowState = GetNowState();
				if (spNowState == nullptr)
				{
					return;
				}

				// 終了関数を呼び、ポップする
				spNowState->CallExit(m_pOwner);
				m_lStates.pop_back();
			};
	}

    // 今のステートを終了し、リストにあるステートをすべてクリアする
	void ClearState()
	{
		m_fnChangeState =
			[&]()
			{
				std::shared_ptr<StateBase<OwnerType>> spNowState = GetNowState();
				if (spNowState == nullptr)
				{
					return;
				}
                spNowState->CallExit(m_pOwner);
                m_lStates.clear();
			};
	}


	// 状態の更新
	void Update()
	{
		// ステートの変更命令があれば処理する
		m_fnChangeState();
		m_fnChangeState = []() {};

		std::shared_ptr<StateBase<OwnerType>> spBackState = GetNowState();
		if (spBackState != nullptr)
		{
			spBackState->CallUpdate(m_pOwner);
		}

	}

    // 現在有効なステートを取得
	std::shared_ptr<StateBase<OwnerType>> GetNowState() // 変更点(3)
	{
		if (m_lStates.empty())
		{
			return nullptr;
		}
		std::shared_ptr<StateBase<OwnerType>> spBackState = m_lStates.back();
		if (spBackState == nullptr)
		{
			return nullptr;
		}

		return spBackState;
	}

private:

	template<typename StateType, typename...ArgType>
	void StateChanger(bool a_isPop, ArgType...a_args)
	{
		// ステートの変更命令を格納する
		m_fnChangeState = [&]()
			{
				// オーナーがセットされてなければ何もしない
				if (m_pOwner == nullptr)
				{
					return;
				}

				std::shared_ptr<StateBase<OwnerType>> spBackState = GetNowState();

				// もしすでにステートがセットされてたら終了する
				if (spBackState != nullptr)
				{
					spBackState->CallExit(m_pOwner);

					if (a_isPop)
					{
						m_lStates.pop_back();
					}
				}

				// 新しいステートを作成
				std::shared_ptr<StateBase<OwnerType>> spNewState = std::make_shared<StateType>(a_args...);
				if (spNewState == nullptr)
				{
					return;
				}
				// 作成したステートをリストに追加
				m_lStates.emplace_back(spNewState);
				// 新しいステートにこのマシーンをセット
				spNewState->SetMachine(this);
				// ステートの開始
				spNewState->CallStart(m_pOwner);
			};
	}


private:

	// 状態の持ち主のポインタ
	OwnerType* m_pOwner = nullptr;

	// 今のステート
	std::list<std::shared_ptr<StateBase<OwnerType>>> m_lStates; // 変更点(4)

	// ステートの変更命令を保存しておく関数オブジェクト
	std::function<void()> m_fnChangeState;

};

おわりに

今回はステートマシンの紹介とその実装について解説しました。
皆さんもたくさんステートを切り替えてゲームを作ってみてください。

何か間違いや改善案などあれば教えていただけますと幸いです。

神戸電子専門学校ゲーム技術研究部

Discussion