ステートパターン(オブジェクト指向的)
C++ Stateパターン入門 センサー・状態遷移表・状態遷移図をクラス設計に変換しよう
- 今日のテーマ 前回は、モンスターAIを次の流れで整理しました。
センサーで状況を調べる
↓
フラグにする
↓
状態を決める
↓
状態遷移表を書く
↓
状態遷移図を書く
今回は、その状態遷移表と状態遷移図を、C++の設計に変換します。 今回のテーマは、
Stateパターン
です。
- 今日のゴール 今日のゴールは、次の3つです。
1. Stateパターンが何のためにあるのか理解する
2. switch case版との違いを理解する
3. 状態遷移表をStateクラスに変換する考え方を理解する
今回は座学なので、実際にプログラムを完成させることよりも、
なぜクラスに分けるのか
どのクラスが何を担当するのか
どこで状態を切り替えるのか
を理解することを目標にします。
- 前回のAIを思い出す 今回扱うモンスターAIは、次の流れです。
Patrol
↓ 視界センサーでプレイヤーを見つけた
Chase
↓ 攻撃範囲センサーで攻撃範囲に入った
Attack
↓ 視界センサーでプレイヤーを見失った
Search
↓ 探索時間センサーで時間切れ
Patrol
- センサーとフラグ
AIは、人間のように画面を見ているわけではありません。
AIは、センサーを使って状況を調べます。
センサー 調べること C++の例
視界センサー プレイヤーが見えているか
CheckCanSeePlayer()攻撃範囲センサー 攻撃できる距離かCheckAttackRange()探索時間センサー 探索時間が終わったかCheckSearchTimeOver()これらの結果を、フラグとして使えます。
bool isFindPlayer;
bool isAttackRange;
bool isSearchTimeOver;
- 状態遷移表
前回作った状態遷移表は、次のようになります。
現在の状態 条件 次の状態
Patrol
isFindPlayer == trueChase ChaseisAttackRange == trueAttack AttackisFindPlayer == falseSearch SearchisSearchTimeOver == truePatrol この表が、今回のC++設計のもとになります。
- まずは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;
}
}
- switch case版の考え方
switch case版では、Enemyクラスの中にすべての状態処理を書きます。
Enemy::Update()
├ Patrolの処理
├ Chaseの処理
├ Attackの処理
└ Searchの処理
これは最初は分かりやすいです。 しかし、状態が増えてくると、Enemyクラスがどんどん大きくなります。
- switch case版の困るところ 状態が増えると、次のような問題が出ます。
Enemy::Update() が長くなる
状態ごとの処理が混ざる
修正する場所を探しにくい
新しい状態を追加しづらい
例えば、次のような状態を追加したくなったらどうでしょうか。
Damage
Escape
Return
Dead
Stun
Guard
switch case の中身がどんどん長くなります。
- そこでStateパターン Stateパターンは、
状態ごとの処理を、状態ごとのクラスに分ける方法
です。
つまり、switch case で分けていた部分を、クラスに分けます。
- switch case版とStateパターン版の違い switch case版
Enemy
└ Update()
├ Patrolの処理
├ Chaseの処理
├ Attackの処理
└ Searchの処理
Stateパターン版
Enemy
└ 現在のStateを持つ
PatrolState
ChaseState
AttackState
SearchState
Stateパターンでは、
状態1つにつき、クラス1つ
にします。
- Stateパターンの全体像
Enemy
|
| 現在の状態を持つ
v
EnemyStateBase
↑
├ PatrolState
├ ChaseState
├ AttackState
└ SearchState
Enemy は、現在の状態クラスを1つだけ持ちます。
そして、毎フレームこうします。
今のStateのUpdateを呼ぶ
- 親クラス EnemyStateBase まず、すべての状態クラスの親を作ります。
class Enemy;
class EnemyStateBase
{
public:
virtual ~EnemyStateBase() = default;
virtual void Update(Enemy& enemy) = 0;
};
- 親クラスの意味
class Enemy;
これは前方宣言です。
Enemyというクラスを後で使います
という意味です。
virtual ~EnemyStateBase() = default;
これはデストラクタです。
親クラスのポインタで子クラスを消すので、virtual を付けます。
virtual void Update(Enemy& enemy) = 0;
これは、
すべての状態クラスはUpdateを持ちなさい
という意味です。
= 0 が付いているので、このクラスはそのまま使うのではなく、継承して使います。
- 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();
};
- state_とは何か
EnemyStateBase* state_;
これは、現在の状態です。 例えば、
今は PatrolState
今は ChaseState
今は AttackState
のように、現在の状態を指します。
- nextState_とは何か
EnemyStateBase* nextState_;
これは、次に切り替える予定の状態です。
なぜ state_ だけではなく、nextState_ も持つのでしょうか。
理由は、
StateのUpdate中に、今実行中のStateをすぐ消すと危険だから
です。
- 危険なChangeState 次のような書き方は危険です。
void Enemy::ChangeState(EnemyStateBase* nextState)
{
delete state_;
state_ = nextState;
}
StateのUpdate中にこれを呼ぶと、今実行中のStateを消してしまうことがあります。
PatrolState::Update中
↓
ChangeState(new ChaseState())
↓
delete state_
↓
実行中のPatrolStateが消える
これは分かりにくいバグの原因になります。
- 安全な考え方 安全な考え方は、次の通りです。
StateのUpdate中
↓
次の状態を予約するだけ
↓
Updateが終わる
↓
Enemyが古いStateを消して入れ替える
そのために、nextState_ を使います。
- ChangeState
void Enemy::ChangeState(EnemyStateBase* nextState)
{
delete nextState_;
nextState_ = nextState;
}
ここでは、まだ現在の state_ を消しません。
次の状態を nextState_ に予約するだけです。
- ApplyStateChange
void Enemy::ApplyStateChange()
{
if (nextState_ == nullptr)
{
return;
}
delete state_;
state_ = nextState_;
nextState_ = nullptr;
}
ここで初めて、古い状態を消して、新しい状態に入れ替えます。
- Enemy::Update
void Enemy::Update()
{
if (state_ != nullptr)
{
state_->Update(*this);
}
ApplyStateChange();
}
流れはこうです。
現在のStateのUpdateを実行する
↓
Updateが終わる
↓
必要なら状態を切り替える
- Enemyの生成と破棄
Enemy::Enemy()
{
state_ = new PatrolState();
}
Enemy::~Enemy()
{
delete state_;
delete nextState_;
}
最初は PatrolState から始めます。
Enemyが消えるときに、Stateも消します。
- 所有者のルール
new/deleteを使うときは、所有者を決めることが大切です。 今回のルールはこれです。
Stateを所有するのはEnemy
StateをdeleteするのもEnemy
Stateクラス自身はdeleteしない
Stateクラスは、次のStateを new して Enemy に渡すだけです。
- 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に切り替える
- 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に切り替える
- 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に切り替える
- 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に戻る
- 状態遷移表からStateクラスへの変換
状態遷移表の1行は、Stateクラスの中の
if文になります。 例: 現在の状態 条件 次の状態 PatrolCheckCanSeePlayer()Chase これは、次のように変換できます。
class PatrolState : public EnemyStateBase
{
public:
void Update(Enemy& enemy) override
{
enemy.Patrol();
if (enemy.CheckCanSeePlayer())
{
enemy.ChangeState(new ChaseState());
}
}
};
- switch case版との対応
switch case版 Stateパターン版
case EnemyState::Patrol:PatrolStatecase EnemyState::Chase:ChaseStatecase EnemyState::Attack:AttackStatecase EnemyState::Search:SearchStatestate_ = EnemyState::Chase;ChangeState(new ChaseState());switch (state_)state_->Update(*this);
- 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();
}
};
のように新しいクラスを追加できます。
- Stateパターンの注意点 Stateパターンは便利ですが、注意点もあります。
クラスが増える
最初はswitch caseより難しく見える
new/deleteの管理が必要
そのため、最初は必ず
switch case版
↓
Stateパターン版
の順番で考えると分かりやすいです。
- 座学で一番大事な理解 Stateパターンを難しく考えすぎる必要はありません。 まずは、こう理解してください。
switch caseで分けていた状態ごとの処理を、
クラスごとに分けたもの
これがStateパターンです。
- 確認問題 問1 Stateパターンは、何をクラスに分ける方法ですか。
問2
EnemyStateBase は何のためにありますか。
問3
state_ は何を表していますか。
問4
nextState_ は何のためにありますか。
問5
次の状態遷移表の1行を、Stateクラスの中の処理として説明しなさい。
現在の状態 条件 次の状態
Chase CheckAttackRange() Attack
問6
今回の設計で、Stateを delete する責任を持つのはどのクラスですか。
- まとめ 今回のポイントは次の通りです。
AIはセンサーで状況を調べる
センサーの結果を使って状態を切り替える
状態遷移表は、C++の状態管理に変換できる
switch case版は状態遷移の基本を理解しやすい
Stateパターンは、状態ごとの処理をクラスに分ける方法
Stateをnew/deleteで管理する場合、所有者を決めることが重要
今回の設計では、Stateを所有するのはEnemy
- 発展:本来はunique_ptrも使える
今回の資料では、
new/deleteの管理を学ぶために、生ポインタを使いました。 実際のC++では、std::unique_ptrを使うとより安全です。
std::unique_ptr<EnemyStateBase> state_;
std::unique_ptr<EnemyStateBase> nextState_;
ただし、スマートポインタを使う場合でも、
誰が所有するのか
いつ入れ替えるのか
どのクラスが責任を持つのか
を理解することは重要です。
No Comments