Stateパターンワーク
C++ Stateパターン入門
信号機のStateクラスで考える
1. 今日の目的
この資料では、Stateパターンを信号機の例で学びます。
難しいゲームAIではなく、次の3つの状態だけを扱います。
赤
青
黄
信号機は、時間が来ると状態が変わります。
赤 → 青 → 黄 → 赤
このような「状態ごとに処理が違うもの」を、Stateパターンで書いてみます。
2. Stateパターンを一言でいうと
Stateパターンは、
状態ごとの処理を、状態ごとのクラスに分ける方法
です。
信号機なら、次のように分けます。
RedState
BlueState
YellowState
状態1つにつき、クラス1つです。
3. switch文で書くとどうなるか
まず、Stateパターンを使わずに書くと、次のようになります。
enum class SignalState
{
Red,
Blue,
Yellow
};
void Signal::Update()
{
switch (state_)
{
case SignalState::Red:
Red();
if (IsTimeOver())
{
state_ = SignalState::Blue;
}
break;
case SignalState::Blue:
Blue();
if (IsTimeOver())
{
state_ = SignalState::Yellow;
}
break;
case SignalState::Yellow:
Yellow();
if (IsTimeOver())
{
state_ = SignalState::Red;
}
break;
}
}
この書き方でも動きます。
ただし、状態が増えると switch の中が長くなります。
Stateパターンでは、この case ごとの処理をクラスに分けます。
4. Stateパターン版の全体像
Stateパターン版では、次のような形にします。
Signal
|
| 現在のStateを持つ
v
SignalStateBase
↑
├ RedState
├ BlueState
└ YellowState
Signal は、現在のStateを1つだけ持ちます。
毎フレーム、現在のStateの Update() を呼びます。
Signal::Update()
↓
今のStateのUpdate()を呼ぶ
↓
Stateの中で必要なら次のStateへ切り替える
5. 親クラス SignalStateBase
まず、すべてのStateの親クラスを作ります。
class Signal;
class SignalStateBase
{
public:
virtual ~SignalStateBase() = default;
virtual void Update(Signal& signal) = 0;
};
ポイントは3つです。
SignalStateBaseは、すべてのStateの親
Update()は、状態ごとの処理を書く場所
= 0 があるので、このクラスをそのまま使うのではなく継承して使う
6. Signalクラス
信号機本体のクラスです。
class Signal
{
private:
SignalStateBase* state_ = nullptr;
SignalStateBase* nextState_ = nullptr;
public:
Signal();
~Signal();
void Update();
void ChangeState(SignalStateBase* nextState);
void ApplyStateChange();
bool IsTimeOver();
void Red();
void Blue();
void Yellow();
};
state_ は、現在の状態を表します。
SignalStateBase* state_;
例えば、次のようなイメージです。
state_ -> RedState
state_ -> BlueState
state_ -> YellowState
7. Signal::Update
Signal::Update() は、とても短くなります。
void Signal::Update()
{
if (state_ != nullptr)
{
state_->Update(*this);
}
ApplyStateChange();
}
Signal は、現在のStateに処理を任せます。
今がRedStateなら、RedState::Update()
今がBlueStateなら、BlueState::Update()
今がYellowStateなら、YellowState::Update()
が呼ばれます。
8. ChangeState
状態を切り替える関数です。
ただし、ここでは今のStateをすぐには消しません。
次に切り替えるStateを予約します。
void Signal::ChangeState(SignalStateBase* nextState)
{
delete nextState_;
nextState_ = nextState;
}
なぜすぐに切り替えないのかを、次の章で確認します。
9. Update中に自分を消さないように注意
次のように、ChangeState() の中で今のStateをすぐに消す書き方は危険です。
void Signal::ChangeState(SignalStateBase* nextState)
{
delete state_;
state_ = nextState;
}
次のような流れになることがあります。
RedState::Update() 実行中
↓
signal.ChangeState(new BlueState()) を呼ぶ
↓
delete state_ が実行される
↓
今まさに実行中の RedState 自身を消してしまう
つまり、Stateの Update() の中で状態を切り替えると、
自分のUpdate中に、自分自身をdeleteしてしまう
可能性があります。
これは、かなり分かりにくいバグの原因になります。
10. 安全な考え方
安全にするなら、Stateの Update() 中は、すぐに消さないようにします。
考え方は次の通りです。
1. StateのUpdate中は、次のStateを予約するだけ
2. StateのUpdateが終わる
3. そのあとで古いStateを消して、新しいStateに入れ替える
イメージはこうです。
void Signal::Update()
{
if (state_ != nullptr)
{
state_->Update(*this);
}
ApplyStateChange();
}
ChangeState() では、今の state_ をすぐに消しません。
void Signal::ChangeState(SignalStateBase* nextState)
{
delete nextState_;
nextState_ = nextState;
}
Update() が終わったあとで、まとめて切り替えます。
void Signal::ApplyStateChange()
{
if (nextState_ == nullptr)
{
return;
}
delete state_;
state_ = nextState_;
nextState_ = nullptr;
}
このため、Signal クラスには nextState_ も用意します。
class Signal
{
private:
SignalStateBase* state_ = nullptr;
SignalStateBase* nextState_ = nullptr;
};
この資料では信号機のStateクラスを理解することが目的なので、まずは単純な形で説明しています。
ただし、実際に作るときは、
Update中に、今実行中のStateを先に消さない
という点を必ず意識します。
11. Signalの生成と破棄
最初は赤信号から始めます。
Signal::Signal()
{
state_ = new RedState();
}
最後に現在のStateを消します。
Signal::~Signal()
{
delete state_;
delete nextState_;
}
12. RedState
赤信号のStateクラスです。
class RedState : public SignalStateBase
{
public:
void Update(Signal& signal) override
{
signal.Red();
if (signal.IsTimeOver())
{
signal.ChangeState(new BlueState());
}
}
};
赤信号では、まず signal.Red() を呼びます。
時間が来たら、青信号に切り替えます。
RedState
↓ 時間が来た
BlueState
13. BlueState
青信号のStateクラスです。
class BlueState : public SignalStateBase
{
public:
void Update(Signal& signal) override
{
signal.Blue();
if (signal.IsTimeOver())
{
signal.ChangeState(new YellowState());
}
}
};
青信号では、まず signal.Blue() を呼びます。
時間が来たら、黄信号に切り替えます。
BlueState
↓ 時間が来た
YellowState
14. YellowState
黄信号のStateクラスです。
class YellowState : public SignalStateBase
{
public:
void Update(Signal& signal) override
{
signal.Yellow();
if (signal.IsTimeOver())
{
signal.ChangeState(new RedState());
}
}
};
黄信号では、まず signal.Yellow() を呼びます。
時間が来たら、赤信号に戻ります。
YellowState
↓ 時間が来た
RedState
15. 状態遷移表とStateクラス
信号機の状態遷移表は、次のようになります。
| 現在の状態 | 条件 | 次の状態 |
|---|---|---|
| Red | IsTimeOver() |
Blue |
| Blue | IsTimeOver() |
Yellow |
| Yellow | IsTimeOver() |
Red |
この表の1行が、Stateクラス1つに対応します。
例えば、
| 現在の状態 | 条件 | 次の状態 |
|---|---|---|
| Red | IsTimeOver() |
Blue |
は、次のコードになります。
class RedState : public SignalStateBase
{
public:
void Update(Signal& signal) override
{
signal.Red();
if (signal.IsTimeOver())
{
signal.ChangeState(new BlueState());
}
}
};
16. switch文版との対応
| switch文版 | Stateパターン版 |
|---|---|
case SignalState::Red: |
RedState |
case SignalState::Blue: |
BlueState |
case SignalState::Yellow: |
YellowState |
state_ = SignalState::Blue; |
ChangeState(new BlueState()); |
switch (state_) |
state_->Update(*this); |
Stateパターンは、switch文をなくすためのものというより、
状態ごとの処理を、別々のクラスに分ける方法
です。
17. この資料で覚えるC++のポイント
今回覚えるC++のポイントは、次の4つです。
1. 親クラスのポインタで子クラスを扱える
2. virtual関数を使うと、実体のクラスの関数が呼ばれる
3. overrideを書くと、上書きしていることが分かりやすい
4. Stateクラスを切り替えることで、動作を変えられる
5. Update中に、今実行中のStateを先に消さない
特に大事なのは、次の1行です。
state_->Update(*this);
この1行で、現在の状態に合った処理が呼ばれます。
18. まとめ
Stateパターンは、状態ごとの処理をクラスに分ける方法です。
信号機の例では、次のように分けました。
赤信号の処理 RedState
青信号の処理 BlueState
黄信号の処理 YellowState
Signal は現在のStateを持ちます。
SignalStateBase* state_;
毎フレーム、現在のStateの Update() を呼びます。
state_->Update(*this);
状態を切り替えたいときは、別のStateクラスに入れ替えます。
signal.ChangeState(new BlueState());
ただし、Stateの Update() 中にすぐ delete state_ すると、今実行中のState自身を消してしまう可能性があります。
実際にゲームで使う場合は、次のStateを予約しておき、Update() が終わったあとで切り替える形にすると安全です。
練習問題
問1
Stateパターンは、何をクラスに分ける方法ですか。
問2
信号機の例で、次の状態に対応するStateクラス名を書きなさい。
| 状態 | Stateクラス名 |
|---|---|
| 赤 | |
| 青 | |
| 黄 |
問3
次の空欄を埋めなさい。
class RedState : public SignalStateBase
{
public:
void Update(Signal& signal) override
{
signal.Red();
if (signal.IsTimeOver())
{
signal.ChangeState(new __________());
}
}
};
問4
次の状態遷移表を見て、BlueState の中で何をすればよいか説明しなさい。
| 現在の状態 | 条件 | 次の状態 |
|---|---|---|
| Blue | IsTimeOver() |
Yellow |
問5
次のコードの state_ は何を表していますか。
SignalStateBase* state_;
問6
次のコードでは、なぜ switch が必要ありませんか。
void Signal::Update()
{
if (state_ != nullptr)
{
state_->Update(*this);
}
}
問7
次の状態を追加したいとします。
点滅
状態遷移は次の通りです。
| 現在の状態 | 条件 | 次の状態 |
|---|---|---|
| Yellow | IsTimeOver() |
Blink |
| Blink | IsTimeOver() |
Red |
このとき、新しく必要になるStateクラス名を書きなさい。
問8
BlinkState のコードを完成させなさい。
class BlinkState : public SignalStateBase
{
public:
void Update(Signal& signal) override
{
signal.Blink();
if (signal.IsTimeOver())
{
signal.ChangeState(new __________());
}
}
};
問9
switch文版とStateパターン版の違いを、次の言葉を使って説明しなさい。
case
クラス
状態ごとの処理
問10
次のコードの危険な点を説明しなさい。
void Signal::ChangeState(SignalStateBase* nextState)
{
delete state_;
state_ = nextState;
}
ヒント:
StateのUpdate中にChangeStateを呼ぶ
今実行中のState
delete
問11
信号機以外で、Stateパターンが使えそうなゲームの例を1つ考えなさい。
例:
プレイヤーの状態
敵AI
シーン管理
解答例
問1 解答例
状態ごとの処理を、状態ごとのクラスに分ける方法。
問2 解答例
| 状態 | Stateクラス名 |
|---|---|
| 赤 | RedState |
| 青 | BlueState |
| 黄 | YellowState |
問3 解答例
signal.ChangeState(new BlueState());
問4 解答例
BlueState では、まず signal.Blue() を呼びます。
その後、signal.IsTimeOver() が true なら、YellowState に切り替えます。
class BlueState : public SignalStateBase
{
public:
void Update(Signal& signal) override
{
signal.Blue();
if (signal.IsTimeOver())
{
signal.ChangeState(new YellowState());
}
}
};
問5 解答例
state_ は、現在の状態を表しています。
信号機の場合、現在が赤なら RedState、青なら BlueState、黄なら YellowState を指します。
問6 解答例
state_ の中身が、現在のStateクラスを指しているからです。
state_->Update(*this); と書けば、現在のStateに合った Update() が呼ばれます。
そのため、Signal::Update() の中で switch を書く必要がありません。
問7 解答例
BlinkState
問8 解答例
class BlinkState : public SignalStateBase
{
public:
void Update(Signal& signal) override
{
signal.Blink();
if (signal.IsTimeOver())
{
signal.ChangeState(new RedState());
}
}
};
問9 解答例
switch文版では、case ごとに状態ごとの処理を書きます。
Stateパターン版では、状態ごとの処理をクラスに分けます。
例えば、赤信号の処理は case SignalState::Red: ではなく、RedState クラスに書きます。
問10 解答例
危険な点は、Stateの Update() 中に ChangeState() を呼んだ場合、今実行中のState自身を delete してしまう可能性があることです。
例えば、RedState::Update() の中で ChangeState(new BlueState()) を呼ぶと、delete state_ によって実行中の RedState を消してしまう可能性があります。
安全にするには、ChangeState() では次のStateを予約するだけにして、Update() が終わったあとで入れ替えます。
問11 解答例
例:
プレイヤーの状態
IdleState
RunState
JumpState
AttackState
DamageState
のように、プレイヤーの状態ごとにクラスを分けられます。
No Comments