Skip to main content

ステートパターン(オブジェクト指向的)

C++ Stateパターン入門 センサー・状態遷移表・状態遷移図をクラス設計に変換しよう

  1. 今日のテーマ 前回は、モンスターAIを次の流れで整理しました。
センサーで状況を調べる
↓
フラグにする
↓
状態を決める
↓
状態遷移表を書く
↓
状態遷移図を書く

今回は、その状態遷移表と状態遷移図を、C++の設計に変換します。 今回のテーマは、

Stateパターン

です。

  1. 今日のゴール 今日のゴールは、次の3つです。
1. Stateパターンが何のためにあるのか理解する
2. switch case版との違いを理解する
3. 状態遷移表をStateクラスに変換する考え方を理解する

今回は座学なので、実際にプログラムを完成させることよりも、

なぜクラスに分けるのか
どのクラスが何を担当するのか
どこで状態を切り替えるのか

を理解することを目標にします。

  1. 前回のAIを思い出す 今回扱うモンスターAIは、次の流れです。
Patrol
  ↓ 視界センサーでプレイヤーを見つけた
Chase
  ↓ 攻撃範囲センサーで攻撃範囲に入った
Attack
  ↓ 視界センサーでプレイヤーを見失った
Search
  ↓ 探索時間センサーで時間切れ
Patrol

  1. センサーとフラグ AIは、人間のように画面を見ているわけではありません。 AIは、センサーを使って状況を調べます。 センサー 調べること C++の例 視界センサー プレイヤーが見えているか CheckCanSeePlayer() 攻撃範囲センサー 攻撃できる距離か CheckAttackRange() 探索時間センサー 探索時間が終わったか CheckSearchTimeOver() これらの結果を、フラグとして使えます。
bool isFindPlayer;
bool isAttackRange;
bool isSearchTimeOver;

  1. 状態遷移表 前回作った状態遷移表は、次のようになります。 現在の状態 条件 次の状態 Patrol isFindPlayer == true Chase Chase isAttackRange == true Attack Attack isFindPlayer == false Search Search isSearchTimeOver == true Patrol この表が、今回のC++設計のもとになります。

  1. まずはswitch case版で考える Stateパターンの前に、まずは switch case 版を確認します。
enum class EnemyState
{
    Patrol,
    Chase,
    Attack,
    Search
};
void Enemy::Update()
{
    switch (state_)
    {
    case EnemyState::Patrol:
        Patrol();

        if (CheckCanSeePlayer())
        {
            state_ = EnemyState::Chase;
        }
        break;

    case EnemyState::Chase:
        Chase();

        if (CheckAttackRange())
        {
            state_ = EnemyState::Attack;
        }
        break;

    case EnemyState::Attack:
        Attack();

        if (!CheckCanSeePlayer())
        {
            state_ = EnemyState::Search;
        }
        break;

    case EnemyState::Search:
        Search();

        if (CheckSearchTimeOver())
        {
            state_ = EnemyState::Patrol;
        }
        break;
    }
}

  1. switch case版の考え方 switch case 版では、Enemyクラスの中にすべての状態処理を書きます。
Enemy::Update()
├ Patrolの処理
├ Chaseの処理
├ Attackの処理
└ Searchの処理

これは最初は分かりやすいです。 しかし、状態が増えてくると、Enemyクラスがどんどん大きくなります。

  1. switch case版の困るところ 状態が増えると、次のような問題が出ます。
Enemy::Update() が長くなる
状態ごとの処理が混ざる
修正する場所を探しにくい
新しい状態を追加しづらい

例えば、次のような状態を追加したくなったらどうでしょうか。

Damage
Escape
Return
Dead
Stun
Guard

switch case の中身がどんどん長くなります。

  1. そこでStateパターン Stateパターンは、
状態ごとの処理を、状態ごとのクラスに分ける方法

です。 つまり、switch case で分けていた部分を、クラスに分けます。

  1. switch case版とStateパターン版の違い switch case版
Enemy
└ Update()
   ├ Patrolの処理
   ├ Chaseの処理
   ├ Attackの処理
   └ Searchの処理

Stateパターン版

Enemy
└ 現在のStateを持つ

PatrolState
ChaseState
AttackState
SearchState

Stateパターンでは、

状態1つにつき、クラス1つ

にします。

  1. Stateパターンの全体像
Enemy
  |
  | 現在の状態を持つ
  v

EnemyStateBase
  ↑
  ├ PatrolState
  ├ ChaseState
  ├ AttackState
  └ SearchState

Enemy は、現在の状態クラスを1つだけ持ちます。 そして、毎フレームこうします。

今のStateのUpdateを呼ぶ

  1. 親クラス EnemyStateBase まず、すべての状態クラスの親を作ります。
class Enemy;

class EnemyStateBase
{
public:
    virtual ~EnemyStateBase() = default;

    virtual void Update(Enemy& enemy) = 0;
};

  1. 親クラスの意味
class Enemy;

これは前方宣言です。

Enemyというクラスを後で使います

という意味です。

virtual ~EnemyStateBase() = default;

これはデストラクタです。 親クラスのポインタで子クラスを消すので、virtual を付けます。

virtual void Update(Enemy& enemy) = 0;

これは、

すべての状態クラスはUpdateを持ちなさい

という意味です。 = 0 が付いているので、このクラスはそのまま使うのではなく、継承して使います。

  1. Enemyクラス Enemyクラスは、現在の状態を持ちます。
class Enemy
{
private:
    EnemyStateBase* state_ = nullptr;
    EnemyStateBase* nextState_ = nullptr;

public:
    Enemy();
    ~Enemy();

    void Update();

    void ChangeState(EnemyStateBase* nextState);
    void ApplyStateChange();

    bool CheckCanSeePlayer();
    bool CheckAttackRange();
    bool CheckSearchTimeOver();

    void Patrol();
    void Chase();
    void Attack();
    void Search();
};

  1. state_とは何か
EnemyStateBase* state_;

これは、現在の状態です。 例えば、

今は PatrolState
今は ChaseState
今は AttackState

のように、現在の状態を指します。

  1. nextState_とは何か
EnemyStateBase* nextState_;

これは、次に切り替える予定の状態です。 なぜ state_ だけではなく、nextState_ も持つのでしょうか。 理由は、

StateのUpdate中に、今実行中のStateをすぐ消すと危険だから

です。

  1. 危険なChangeState 次のような書き方は危険です。
void Enemy::ChangeState(EnemyStateBase* nextState)
{
    delete state_;
    state_ = nextState;
}

StateのUpdate中にこれを呼ぶと、今実行中のStateを消してしまうことがあります。

PatrolState::Update中
↓
ChangeState(new ChaseState())
↓
delete state_
↓
実行中のPatrolStateが消える

これは分かりにくいバグの原因になります。

  1. 安全な考え方 安全な考え方は、次の通りです。
StateのUpdate中
↓
次の状態を予約するだけ
↓
Updateが終わる
↓
Enemyが古いStateを消して入れ替える

そのために、nextState_ を使います。

  1. ChangeState
void Enemy::ChangeState(EnemyStateBase* nextState)
{
    delete nextState_;
    nextState_ = nextState;
}

ここでは、まだ現在の state_ を消しません。 次の状態を nextState_ に予約するだけです。

  1. ApplyStateChange
void Enemy::ApplyStateChange()
{
    if (nextState_ == nullptr)
    {
        return;
    }

    delete state_;
    state_ = nextState_;
    nextState_ = nullptr;
}

ここで初めて、古い状態を消して、新しい状態に入れ替えます。

  1. Enemy::Update
void Enemy::Update()
{
    if (state_ != nullptr)
    {
        state_->Update(*this);
    }

    ApplyStateChange();
}

流れはこうです。

現在のStateのUpdateを実行する
↓
Updateが終わる
↓
必要なら状態を切り替える

  1. Enemyの生成と破棄
Enemy::Enemy()
{
    state_ = new PatrolState();
}

Enemy::~Enemy()
{
    delete state_;
    delete nextState_;
}

最初は PatrolState から始めます。 Enemyが消えるときに、Stateも消します。

  1. 所有者のルール new/delete を使うときは、所有者を決めることが大切です。 今回のルールはこれです。
Stateを所有するのはEnemy
StateをdeleteするのもEnemy
Stateクラス自身はdeleteしない

Stateクラスは、次のStateを new して Enemy に渡すだけです。


  1. PatrolState
class PatrolState : public EnemyStateBase
{
public:
    void Update(Enemy& enemy) override
    {
        enemy.Patrol();

        if (enemy.CheckCanSeePlayer())
        {
            enemy.ChangeState(new ChaseState());
        }
    }
};

これは、状態遷移表のこの行に対応しています。 現在の状態 条件 次の状態 Patrol isFindPlayer == true Chase 意味:

巡回する
↓
プレイヤーを見つけたら
↓
ChaseStateに切り替える

  1. ChaseState
class ChaseState : public EnemyStateBase
{
public:
    void Update(Enemy& enemy) override
    {
        enemy.Chase();

        if (enemy.CheckAttackRange())
        {
            enemy.ChangeState(new AttackState());
        }
    }
};

これは、状態遷移表のこの行に対応しています。 現在の状態 条件 次の状態 Chase isAttackRange == true Attack 意味:

追いかける
↓
攻撃範囲に入ったら
↓
AttackStateに切り替える

  1. AttackState
class AttackState : public EnemyStateBase
{
public:
    void Update(Enemy& enemy) override
    {
        enemy.Attack();

        if (!enemy.CheckCanSeePlayer())
        {
            enemy.ChangeState(new SearchState());
        }
    }
};

これは、状態遷移表のこの行に対応しています。 現在の状態 条件 次の状態 Attack isFindPlayer == false Search 意味:

攻撃する
↓
プレイヤーを見失ったら
↓
SearchStateに切り替える

  1. SearchState
class SearchState : public EnemyStateBase
{
public:
    void Update(Enemy& enemy) override
    {
        enemy.Search();

        if (enemy.CheckSearchTimeOver())
        {
            enemy.ChangeState(new PatrolState());
        }
    }
};

これは、状態遷移表のこの行に対応しています。 現在の状態 条件 次の状態 Search isSearchTimeOver == true Patrol 意味:

探す
↓
探索時間が終わったら
↓
PatrolStateに戻る

  1. 状態遷移表からStateクラスへの変換 状態遷移表の1行は、Stateクラスの中の if文 になります。 例: 現在の状態 条件 次の状態 Patrol CheckCanSeePlayer() Chase これは、次のように変換できます。
class PatrolState : public EnemyStateBase
{
public:
    void Update(Enemy& enemy) override
    {
        enemy.Patrol();

        if (enemy.CheckCanSeePlayer())
        {
            enemy.ChangeState(new ChaseState());
        }
    }
};

  1. switch case版との対応 switch case版 Stateパターン版 case EnemyState::Patrol: PatrolState case EnemyState::Chase: ChaseState case EnemyState::Attack: AttackState case EnemyState::Search: SearchState state_ = EnemyState::Chase; ChangeState(new ChaseState()); switch (state_) state_->Update(*this);

  1. Stateパターンの良いところ 良いところ1:状態ごとに処理を分けられる
PatrolState
ChaseState
AttackState
SearchState

のように分かれるので、どこに何が書いてあるか分かりやすくなります。

良いところ2:Enemy::Updateが短くなる

void Enemy::Update()
{
    if (state_ != nullptr)
    {
        state_->Update(*this);
    }

    ApplyStateChange();
}

Enemyは、現在のStateに処理を任せるだけになります。

良いところ3:状態を追加しやすい 例えば、逃げる状態を追加するなら、

class EscapeState : public EnemyStateBase
{
public:
    void Update(Enemy& enemy) override
    {
        enemy.Escape();
    }
};

のように新しいクラスを追加できます。

  1. Stateパターンの注意点 Stateパターンは便利ですが、注意点もあります。
クラスが増える
最初はswitch caseより難しく見える
new/deleteの管理が必要

そのため、最初は必ず

switch case版
↓
Stateパターン版

の順番で考えると分かりやすいです。

  1. 座学で一番大事な理解 Stateパターンを難しく考えすぎる必要はありません。 まずは、こう理解してください。
switch caseで分けていた状態ごとの処理を、
クラスごとに分けたもの

これがStateパターンです。

  1. 確認問題 問1 Stateパターンは、何をクラスに分ける方法ですか。

問2 EnemyStateBase は何のためにありますか。

問3 state_ は何を表していますか。

問4 nextState_ は何のためにありますか。

問5 次の状態遷移表の1行を、Stateクラスの中の処理として説明しなさい。 現在の状態 条件 次の状態 Chase CheckAttackRange() Attack

問6 今回の設計で、Stateを delete する責任を持つのはどのクラスですか。

  1. まとめ 今回のポイントは次の通りです。
AIはセンサーで状況を調べる
センサーの結果を使って状態を切り替える
状態遷移表は、C++の状態管理に変換できる
switch case版は状態遷移の基本を理解しやすい
Stateパターンは、状態ごとの処理をクラスに分ける方法
Stateをnew/deleteで管理する場合、所有者を決めることが重要
今回の設計では、Stateを所有するのはEnemy

  1. 発展:本来はunique_ptrも使える 今回の資料では、new/delete の管理を学ぶために、生ポインタを使いました。 実際のC++では、std::unique_ptr を使うとより安全です。
std::unique_ptr<EnemyStateBase> state_;
std::unique_ptr<EnemyStateBase> nextState_;

ただし、スマートポインタを使う場合でも、

誰が所有するのか
いつ入れ替えるのか
どのクラスが責任を持つのか

を理解することは重要です。