C++で汎用的な状態管理クラスを実装する方法 前編
この記事は、神戸電子専門学校 ゲーム技研部 Advent Calendar 2024の14日目の記事です。
はじめに
プレイヤーや敵を作っているうちにどんどん要素が増えていき、更新関数やクラスの中身が膨れ上がって把握しきれなくなってしまったことはないでしょうか。そんな時は、ステートパターンというデザインパターンを使うことで要素(状態)ごとにクラスを分割することができます。
今回は、そんなステートパターンを使った状態管理を汎用的に使うためのクラスの作り方について、前編ではシンプルだけど少し使いづらい実装を、後編では複雑だけど使いやすい実装を紹介していきます。
必要な前提知識
前編はC++のポリモーフィズムとスマートポインタについて、後編からはテンプレートの基本について知ってると読みやすいと思います。
ステートパターンとは
ステートパターンとは、オブジェクトの処理を状態ごとに分離し、その中から常に一つの状態を取るようにすることで振る舞いを制御するデザインパターンです。
このパターンを用いることでクラスごとに状態を分離することができるため、状態の追加、編集がやりやすくなります。
この記事では、状態のことをステート、状態を管理するクラスのことをステートマシンと呼んでいきます。
シンプルなステートマシン
StateBase
まずはすべてのステートの基底となるクラスを作ります。
class StateBase
{
public:
// ステートが始まるときに一度だけ呼ばれる関数
virtual void OnStart() {}
// ステートが更新されるときに呼ばれる関数
virtual void OnUpdate() {}
// ステートが終了する時に一度だけ呼ばれる関数
virtual void OnExit() {}
};
新しくステートを作るときはこのクラスを継承し、更新処理と開始、終了処理をオーバーライドして実装します。
開始処理にはアニメーションのセットや当たり判定の設定、更新処理には移動処理やほかのステートに遷移する条件、終了処理にはこのステート専用の設定やインスタンスの開放などを書くといいでしょう。
StateMachine
次に、ステートを管理するクラスを作ります。
class StateMachine
{
public:
// ステートを変更する
void ChangeState(std::shared_ptr<StateBase> a_spNewState)
{
// すでにステートがセットされてたら終了する
if (m_spNowState != nullptr)
{
m_spNowState->OnExit();
m_spNowState = nullptr;
}
// 新しいステートをセットする
m_spNowState = a_spNewState;
// 新しいステートを開始する
m_spNowState->OnStart();
}
// 更新関数
void Update()
{
if (m_spNowState != nullptr)
{
m_spNowState->OnUpdate();
}
}
private:
std::shared_ptr<StateBase> m_spNowState = nullptr;
};
ChangeState関数に切り替えたいステートを渡すと状態を変更します。
このクラスを状態を管理したいオブジェクトに持たせることで、常に一つの状態であることを強制することができます。
使い方
最初に状態を管理したいオブジェクトのメンバにStateMachineを追加します。
そしてオブジェクトの更新関数でStateMachineのUpdateを呼ぶことで、管理したステートの更新を呼ぶことができます。
class Player :public GameObject
{
public:
void Start()
{
// 初期状態のステートをセット
auto spStandState = std::make_shared<Player_StandState>();
ChangeState(spStandState);
}
void Update()
{
m_machine.Update();
}
void ChangeState(std::shared_ptr<PlayerStateBase> a_spState)
{
a_spState->SetOwner(this);
m_machine.ChangeState(a_spState);
}
private:
// ステートマシン
StateMachine m_machine;
};
状態を作成するときはクラスを作成し、StateBaseを継承します。
(下記のサンプルではPlayer専用のステートの基底クラスを作成し、そこからプレイヤーにアクセスできるようにしています。)
// プレイヤー専用のステートの基底クラス
class PlayerStateBase :public StateBase
{
public:
void SetOwner(Player* a_pPlayer)
{
m_pPlayer = a_pPlayer;
}
protected:
Player* m_pPlayer = nullptr;
};
次に、そのステートの初期化処理、更新処理、終了処理を基底クラスの仮想関数をオーバーライドして実装します。
ステートからほかのステートに遷移したいときは、新しいステートのインスタンスを作成した後にステートマシンにアクセスし、ChangeState関数を呼びます。
問題点の項目でも後述しますが、関数を呼んだ後にreturnを呼ばないと不正なメモリアクセスをしてしまう可能性があるため注意してください。
// プレイヤーの待機状態
class Player_StandState :public PlayerStateBase
{
public:
void OnStart()override
{
// 前方向に力をセットし続ける
m_pPlayer->SetForce(0, 0, 0);
// アニメーションを変更
m_pPlayer->ChangeAnimation("Stand");
}
void OnUpdate()override
{
// Wキーが押されていたら走り出す
if (Input::IsHold("W"))
{
auto spStandState = std::make_shared<Player_RunState>();
m_pPlayer->ChangeState(spStandState);
return;
}
// Spaceキーが押されていたらジャンプする
if (Input::IsHold("Space"))
{
auto spStandState = std::make_shared<Player_JumpState>();
m_pPlayer->ChangeState(spStandState);
return;
}
}
void OnExit()override
{
}
};
// プレイヤーのダッシュ状態
class Player_RunState :public PlayerStateBase
{
public:
void OnStart()override
{
// アニメーションを変更
m_pPlayer->ChangeAnimation("Run");
}
void OnUpdate()override
{
// 前方向に力をセットし続ける
m_pPlayer->SetForce(0, 0, 1);
// Wキーが押されなくなったらStandに戻る
if (!Input::IsHold("W"))
{
auto spStandState = std::make_shared<Player_StandState>();
m_pPlayer->ChangeState(spStandState);
return;
}
}
void OnExit()override
{
}
};
// プレイヤーのジャンプ状態
class Player_JumpState :public PlayerStateBase
{
public:
void OnStart()override
{
// アニメーションを変更
m_pPlayer->ChangeAnimation("Jump");
// 上方向に衝撃を加える
m_pPlayer->AddImpact(0, 10, 0);
}
void OnUpdate()override
{
// プレイヤーが地面と触れたらStandに遷移
if (m_pPlayer->IsTouch("Ground"))
{
auto spStandState = std::make_shared<Player_StandState>();
m_pPlayer->ChangeState(spStandState);
return;
}
// Wキーが押されている間
if (Input::IsHold("W"))
{
// 前方向に力をセットし続ける
m_pPlayer->SetForce(0, 0, 1);
}
}
void OnExit()override
{
}
};
問題点
ここまでシンプルなステートマシンの実装について解説してきました。
この実装でもプレイヤーなどのキャラを作ることはできますが、いくつか問題があります。
一つ目は、ステートの更新中にステートを変更したら更新処理中にメモリが解放されてしまうという点です。
今の実装ではChangeState関数を呼んだ直後に今のステートを破棄して新しいステートをセットしているため、実行中のステート内でこの関数を呼ぶと処理の途中でメモリが破棄されてしまうことになります。
ChangeStateのたびにreturnを書かないといけなかったり、コードの順番によって処理が呼ばれたり呼ばれなかったりするのはエラーの原因になりやすいため避けたいです。
auto spStandState = std::make_shared<Player_StandState>();
m_pPlayer->ChangeState(spStandState);
return; // ←を書き忘れるとエラーの原因になる
二つ目は、ステートの遷移や持ち主へのアクセスが面倒という点です。
今の実装では、StateBaseにステートマシンやその持ち主へアクセスする手段が実装されていません。そのため、使う側が毎回そのような仕組みを実装しなければいけません。
また、ステートを遷移するたびに手動でインスタンスを作成するのも面倒です。
ということで、後編ではこれらの問題を解決した汎用的なステートマシンの実装について解説します。
Discussion