Skip to main content

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

のように、プレイヤーの状態ごとにクラスを分けられます。