デザイン パターンの美しさ 64 ステート モード: ゲームで一般的に使用されるステート マシンとワークフロー エンジンはどのように実装されていますか?

64 | ステート モード: ゲームやワークフロー エンジンで一般的に使用されるステート マシンはどのように実装されていますか?

今日から、状態パターンの学習を開始します。実際のソフトウェア開発では、状態パターンはあまり一般的に使用されませんが、使用できるシナリオで大きな役割を果たすことができます。そういう意味では先ほど話した合体モードと少し似ています。

ステート パターンは一般的にステート マシンの実装に使用され、ステート マシンはゲームやワークフロー エンジンなどのシステム開発でよく使用されます。ただし、ステート マシンを実現する方法は多数ありますが、ステート モード以外によく使用されるのは、分岐ロジック方式とルックアップ テーブル方式です。今日は、これらの実装方法について詳しく説明し、それらの長所と短所、およびアプリケーション シナリオを比較します。

早速、今日から本格的に勉強を始めましょう!

有限ステート マシンとは

有限状態機械、英訳は状態機械と呼ばれる FSM と略される有限状態機械です。ステート マシンには、状態 (State)、イベント (Event)、アクション (Action) の 3 つのコンポーネントがあります。このうち、イベントは遷移条件(Transition Condition)とも呼ばれます。イベントは、状態の遷移とアクションの実行をトリガーします。ただし、アクションは必須ではなく、何もアクションを実行せずに状態を転送するだけでかまいません。

今与えられたステートマシンの定義について、具体的な例を挙げてさらに説明します。

「スーパーマリオ」ゲーム プレイしたことがあるかどうかわかりませんか? ゲームでは、マリオはスモールマリオ、スーパーマリオ、ファイヤーマリオ、ケープマリオなど、さまざまな形に変身できます。異なるゲーム プロットの下で、各フォームは互いに変形し、それに応じてポイントが増減します。例えば、初期形はリトルマリオで、きのこを食べるとスーパーマリオになって100点アップします。

実際、マリオのフォームの変換はステート マシンです。このうち、マリオのさまざまな形態がステートマシンの「状態」、ゲームのプロット (きのこを食べるなど) がステートマシンの「イベント」、ポイントの加減がステートマシンの「アクション」です。ステートマシン。たとえば、きのこを食べるイベントは、リトル マリオからスーパー マリオへの状態遷移をトリガーし、アクションの実行をトリガーします (100 ポイントを増やします)。

次の説明を容易にするために、ゲームの背景を単純化し、一部の状態とイベントのみを保持しました。単純化された状態遷移を次の図に示します。

ここに画像の説明を挿入

上記のステート マシンを実装するには、どのようにプログラムすればよいでしょうか。つまり、上記の状態遷移図をどのようにコード化すればよいのでしょうか。

以下のようなスケルトンコードを書きました。このうち、obtainMushRoom()、obtainCape()、obtainFireFlower()、meetMonster()関数は、現在の状態やイベントに応じてステータスを更新したり、ポイントを増減したりできます。ただし、当分の間、特定のコードの実装は示していません。面接の質問としてそれを取り、それを完成させてから、以下の私の説明を読んで、あなたの利益がさらに大きくなるようにすることができます.

public enum State {
  SMALL(0),
  SUPER(1),
  FIRE(2),
  CAPE(3);

  private int value;

  private State(int value) {
    this.value = value;
  }

  public int getValue() {
    return this.value;
  }
}

public class MarioStateMachine {
  private int score;
  private State currentState;

  public MarioStateMachine() {
    this.score = 0;
    this.currentState = State.SMALL;
  }

  public void obtainMushRoom() {
    //TODO
  }

  public void obtainCape() {
    //TODO
  }

  public void obtainFireFlower() {
    //TODO
  }

  public void meetMonster() {
    //TODO
  }

  public int getScore() {
    return this.score;
  }

  public State getCurrentState() {
    return this.currentState;
  }
}

public class ApplicationDemo {
  public static void main(String[] args) {
    MarioStateMachine mario = new MarioStateMachine();
    mario.obtainMushRoom();
    int score = mario.getScore();
    State state = mario.getCurrentState();
    System.out.println("mario score: " + score + "; state: " + state);
  }
}

ステートマシンの実装方法1:分岐論理方式

ステート マシンの実装方法について、3 つの方法をまとめました。その中で、最も単純で直接的な実装方法は、状態遷移図を参照して、各状態遷移をそのままコードに変換することです。このように書かれたコードには、if-elseやswitch-caseの分岐判定ロジック、さらには入れ子になった分岐判定ロジックが多く含まれるため、仮にこの方法を分岐ロジック方式と名付けました。

この実装案に従って、上記のスケルトン コードを完成させます。完成したコードは次のとおりです。

public class MarioStateMachine {
  private int score;
  private State currentState;

  public MarioStateMachine() {
    this.score = 0;
    this.currentState = State.SMALL;
  }

  public void obtainMushRoom() {
    if (currentState.equals(State.SMALL)) {
      this.currentState = State.SUPER;
      this.score += 100;
    }
  }

  public void obtainCape() {
    if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER) ) {
      this.currentState = State.CAPE;
      this.score += 200;
    }
  }

  public void obtainFireFlower() {
    if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER) ) {
      this.currentState = State.FIRE;
      this.score += 300;
    }
  }

  public void meetMonster() {
    if (currentState.equals(State.SUPER)) {
      this.currentState = State.SMALL;
      this.score -= 100;
      return;
    }

    if (currentState.equals(State.CAPE)) {
      this.currentState = State.SMALL;
      this.score -= 200;
      return;
    }

    if (currentState.equals(State.FIRE)) {
      this.currentState = State.SMALL;
      this.score -= 300;
      return;
    }
  }

  public int getScore() {
    return this.score;
  }

  public State getCurrentState() {
    return this.currentState;
  }
}

単純なステート マシンの場合、分岐ロジックのこの実装は受け入れられます。ただし、複雑なステート マシンの場合、この実装では特定の状態遷移を省略したり書き間違えたりするのは非常に簡単です。また、コードはif-elseやswitch-caseの分岐判定ロジックが多く、可読性や保守性に劣ります。ある日、ステート マシンの特定の状態遷移を変更すると、長い分岐ロジックで対応するコードを見つけて変更する必要があり、間違いを修正してバグを導入するのは簡単です。

ステートマシンの実装方法2:ルックアップテーブル方式

実際、上記の実装方法は、複雑なステート マシンには適していないハード コードにいくぶん似ており、ステート マシンの 2 番目の実装方法であるルックアップ テーブル方式の方が適しています。次に、テーブル ルックアップ メソッドを使用してスケルトン コードを完成させる方法を見てみましょう。

実際、ステート マシンは、状態遷移図で表すだけでなく、次のように 2 次元のテーブルで表すこともできます。この 2 次元のテーブルでは、最初の次元は現在の状態を表し、2 番目の次元はイベントを表し、値は現在の状態が遷移する新しい状態と、イベントが通過した後に実行されるアクションを表します。

ここに画像の説明を挿入

分岐ロジックの実装と比較して、ルックアップ テーブル方式のコード実装はより明確であり、可読性と保守性が優れています。ステート マシンを変更する場合、transitionTable と actionTable の 2 つの 2 次元配列のみを変更する必要があります。実際、これら 2 つの 2 次元配列を構成ファイルに格納すると、ステート マシンを変更する必要があるときに、コードを変更する必要さえなく、構成ファイルを変更するだけです。具体的なコードは次のとおりです。

public enum Event {
  GOT_MUSHROOM(0),
  GOT_CAPE(1),
  GOT_FIRE(2),
  MET_MONSTER(3);

  private int value;

  private Event(int value) {
    this.value = value;
  }

  public int getValue() {
    return this.value;
  }
}

public class MarioStateMachine {
  private int score;
  private State currentState;

  private static final State[][] transitionTable = {
          {SUPER, CAPE, FIRE, SMALL},
          {SUPER, CAPE, FIRE, SMALL},
          {CAPE, CAPE, CAPE, SMALL},
          {FIRE, FIRE, FIRE, SMALL}
  };

  private static final int[][] actionTable = {
          {+100, +200, +300, +0},
          {+0, +200, +300, -100},
          {+0, +0, +0, -200},
          {+0, +0, +0, -300}
  };

  public MarioStateMachine() {
    this.score = 0;
    this.currentState = State.SMALL;
  }

  public void obtainMushRoom() {
    executeEvent(Event.GOT_MUSHROOM);
  }

  public void obtainCape() {
    executeEvent(Event.GOT_CAPE);
  }

  public void obtainFireFlower() {
    executeEvent(Event.GOT_FIRE);
  }

  public void meetMonster() {
    executeEvent(Event.MET_MONSTER);
  }

  private void executeEvent(Event event) {
    int stateValue = currentState.getValue();
    int eventValue = event.getValue();
    this.currentState = transitionTable[stateValue][eventValue];
    this.score += actionTable[stateValue][eventValue];
  }

  public int getScore() {
    return this.score;
  }

  public State getCurrentState() {
    return this.currentState;
  }

}

ステート マシンの実装方法 3: ステート モード

ルックアップ テーブル メソッドのコード実装では、イベントによってトリガーされるアクションは積分の単純な加算と減算であるため、int 型の 2 次元配列 actionTable を使用してそれを表すことができます。 2 次元配列は、積分の加算と減算を表します。ただし、実行するアクションがそれほど単純ではなく、一連の複雑な論理操作 (ポイントの加算と減算、データベースへの書き込み、場合によってはメッセージ通知の送信など) である場合、そのような単純な 2 つを使用することはできません。 -次元配列を Expressed に。つまり、ルックアップ テーブル方式の実装には一定の制限があります。

分岐ロジックの実装にはこの問題はありませんが、分岐判断ロジックが増えるなど、前述の他の問題があり、コードの可読性と保守性が低下します。実際、状態パターンを使用して分岐論理方式の問題を解決できます。

状態モードは、イベント トリガー状態遷移とアクション実行を異なる状態クラスに分割することで、分岐判断ロジックを回避します。この文を理解するためにコードを組み合わせます。

ステート パターンを使用して、MarioStateMachine クラスを完成させましょう。完成したコードは次のとおりです。

その中で、IMario はすべてのイベントを定義する状態のインターフェイスです。SmallMario、SuperMario、CapeMario、および FireMario は、ステート マシンの 4 つの状態に対応する IMario インターフェイスの実装クラスです。以前は、状態遷移とアクション実行のすべてのコード ロジックが MarioStateMachine クラスに集中していましたが、これらのコード ロジックは、これら 4 つの状態クラスに分散されます。

public interface IMario { //所有状态类的接口
  State getName();
  //以下是定义的事件
  void obtainMushRoom();
  void obtainCape();
  void obtainFireFlower();
  void meetMonster();
}

public class SmallMario implements IMario {
  private MarioStateMachine stateMachine;

  public SmallMario(MarioStateMachine stateMachine) {
    this.stateMachine = stateMachine;
  }

  @Override
  public State getName() {
    return State.SMALL;
  }

  @Override
  public void obtainMushRoom() {
    stateMachine.setCurrentState(new SuperMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 100);
  }

  @Override
  public void obtainCape() {
    stateMachine.setCurrentState(new CapeMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 200);
  }

  @Override
  public void obtainFireFlower() {
    stateMachine.setCurrentState(new FireMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 300);
  }

  @Override
  public void meetMonster() {
    // do nothing...
  }
}

public class SuperMario implements IMario {
  private MarioStateMachine stateMachine;

  public SuperMario(MarioStateMachine stateMachine) {
    this.stateMachine = stateMachine;
  }

  @Override
  public State getName() {
    return State.SUPER;
  }

  @Override
  public void obtainMushRoom() {
    // do nothing...
  }

  @Override
  public void obtainCape() {
    stateMachine.setCurrentState(new CapeMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 200);
  }

  @Override
  public void obtainFireFlower() {
    stateMachine.setCurrentState(new FireMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 300);
  }

  @Override
  public void meetMonster() {
    stateMachine.setCurrentState(new SmallMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() - 100);
  }
}

// 省略CapeMario、FireMario类...

public class MarioStateMachine {
  private int score;
  private IMario currentState; // 不再使用枚举来表示状态

  public MarioStateMachine() {
    this.score = 0;
    this.currentState = new SmallMario(this);
  }

  public void obtainMushRoom() {
    this.currentState.obtainMushRoom();
  }

  public void obtainCape() {
    this.currentState.obtainCape();
  }

  public void obtainFireFlower() {
    this.currentState.obtainFireFlower();
  }

  public void meetMonster() {
    this.currentState.meetMonster();
  }

  public int getScore() {
    return this.score;
  }

  public State getCurrentState() {
    return this.currentState.getName();
  }

  public void setScore(int score) {
    this.score = score;
  }

  public void setCurrentState(IMario currentState) {
    this.currentState = currentState;
  }
}

上記のコードの実装を理解することは難しくありませんが、MarioStateMachine と各状態クラスの間に双方向の依存関係があることだけを強調します。MarioStateMachine が各ステート クラスに依存するのは当然ですが、逆に各ステート クラスが MarioStateMachine に依存するのはなぜでしょうか。これは、各状態クラスが MarioStateMachine の 2 つの変数、score および currentState を更新する必要があるためです。

実際、上記のコードは引き続き最適化できます。状態クラスをシングルトンとして設計できます。結局、状態クラスにはメンバー変数が含まれていません。しかし、状態クラスがシングルトンとして設計されている場合、MarioStateMachine をコンストラクターで渡すことができず、状態クラスは MarioStateMachine に依存しているため、この問題を解決するにはどうすればよいでしょうか。

実際講義 42 のシングルトン モードの説明で、いくつかの解決策について言及しました。戻って確認してください。ここで、関数パラメーターを介して MarioStateMachine を状態クラスに渡すことができます。この設計思想に従って、上記のコードをリファクタリングします。リファクタリング後のコードは次のようになります。

public interface IMario {
  State getName();
  void obtainMushRoom(MarioStateMachine stateMachine);
  void obtainCape(MarioStateMachine stateMachine);
  void obtainFireFlower(MarioStateMachine stateMachine);
  void meetMonster(MarioStateMachine stateMachine);
}

public class SmallMario implements IMario {
  private static final SmallMario instance = new SmallMario();
  private SmallMario() {}
  public static SmallMario getInstance() {
    return instance;
  }

  @Override
  public State getName() {
    return State.SMALL;
  }

  @Override
  public void obtainMushRoom(MarioStateMachine stateMachine) {
    stateMachine.setCurrentState(SuperMario.getInstance());
    stateMachine.setScore(stateMachine.getScore() + 100);
  }

  @Override
  public void obtainCape(MarioStateMachine stateMachine) {
    stateMachine.setCurrentState(CapeMario.getInstance());
    stateMachine.setScore(stateMachine.getScore() + 200);
  }

  @Override
  public void obtainFireFlower(MarioStateMachine stateMachine) {
    stateMachine.setCurrentState(FireMario.getInstance());
    stateMachine.setScore(stateMachine.getScore() + 300);
  }

  @Override
  public void meetMonster(MarioStateMachine stateMachine) {
    // do nothing...
  }
}

// 省略SuperMario、CapeMario、FireMario类...

public class MarioStateMachine {
  private int score;
  private IMario currentState;

  public MarioStateMachine() {
    this.score = 0;
    this.currentState = SmallMario.getInstance();
  }

  public void obtainMushRoom() {
    this.currentState.obtainMushRoom(this);
  }

  public void obtainCape() {
    this.currentState.obtainCape(this);
  }

  public void obtainFireFlower() {
    this.currentState.obtainFireFlower(this);
  }

  public void meetMonster() {
    this.currentState.meetMonster(this);
  }

  public int getScore() {
    return this.score;
  }

  public State getCurrentState() {
    return this.currentState.getName();
  }

  public void setScore(int score) {
    this.score = score;
  }

  public void setCurrentState(IMario currentState) {
    this.currentState = currentState;
  }
}

実際には、ゲームのようなより複雑なステート マシンには、より多くのステートが含まれます. 最初にテーブル ルックアップ メソッドを使用することをお勧めします.ステート モードでは多くのステート クラスが導入されるため、コードの保守がより困難になります. 逆に、EC注文やテイクアウト注文などのステートマシンは状態数が少なく比較的単純な状態遷移ですが、イベントによってトリガーされるアクションに含まれるビジネスロジックはより複雑になる可能性があるため、より推奨されます。達成する状態パターン。

キーレビュー

では、本日の内容は以上です。集中する必要があることをまとめて一緒に確認しましょう。

今日は、状態パターンについて説明しました。インターネット上にはさまざまな状態パターンの定義がありますが、状態パターンは状態マシンの実装であることを覚えておく必要があります。ステート マシンは有限ステート マシンとも呼ばれ、状態、イベント、アクションの 3 つの部分で構成されます。このうち、イベントは遷移条件とも呼ばれます。イベントは、状態の遷移とアクションの実行をトリガーします。ただし、アクションは必須ではなく、何もアクションを実行せずに状態を転送するだけでかまいません。

ステート マシンについて、今日は 3 つの実装方法をまとめました。

最初の実装は、分岐ロジック メソッドと呼ばれます。if-else または switch-case 分岐ロジックを使用し、状態遷移図を参照して、各状態遷移を文字どおりコードに変換します。単純なステート マシンの場合、この実装は最も単純で簡単であり、最初の選択肢です。

2 番目の実装は、ルックアップ テーブル方式と呼ばれます。多くの状態と複雑な状態遷移を持つステート マシンの場合は、ルックアップ テーブル方式が適しています。状態遷移図を 2 次元配列で表現すると、コードの可読性と保守性が大幅に向上します。

3 番目の実装は状態パターンと呼ばれます。状態が少なく、状態遷移が比較的単純なステート マシンの場合、イベントによってトリガーされるアクションに含まれるビジネス ロジックはより複雑になる可能性があるため、この実装方法をお勧めします。

クラスディスカッション

状態モードのコード実装にはまだいくつかの問題があります. たとえば, すべてのイベント関数は状態インターフェイスで定義されているため, 状態クラスが 1 つまたはいくつかのイベントをサポートする必要がない場合でも,すべてのイベント機能を実装する必要があります。それだけでなく、状態インターフェイスにイベントを追加すると、それに応じてすべての状態クラスを変更する必要があります。これらの問題に対する解決策はありますか?

メッセージを残して、あなたの考えを私と共有してください。何かを得た場合は、この記事を友達と共有してください。

おすすめ

転載: blog.csdn.net/fegus/article/details/130519268