The Beauty of Design Patterns 64-State Mode: How are the state machines commonly used in games and workflow engines implemented?

64 | State mode: How is the state machine commonly used in games and workflow engines implemented?

From today, we start to learn the state pattern. In actual software development, the state pattern is not very commonly used, but it can play a big role in the scenarios where it can be used. From this point of view, it is a bit like the combination mode we talked about before.

State patterns are generally used to implement state machines, and state machines are often used in system development such as games and workflow engines. However, there are many ways to realize the state machine. In addition to the state mode, the more commonly used methods are the branch logic method and the look-up table method. Today, we will talk about these implementation methods in detail, and compare their advantages and disadvantages and application scenarios.

Without further ado, let's officially start today's study!

What is a Finite State Machine?

Finite state machine, English translation is Finite State Machine, abbreviated as FSM, referred to as state machine. The state machine has three components: state (State), event (Event), action (Action). Among them, an event is also called a transition condition (Transition Condition). Events trigger the transition of states and the execution of actions. However, the action is not necessary, and it is possible to just transfer the state without performing any action.

For the definition of the state machine just given, I will further explain it with a specific example.

"Super Mario" game I don't know if you have played it? In the game, Mario can transform into various forms, such as Small Mario, Super Mario, Fire Mario, Cape Mario and so on. Under different game plots, each form will transform into each other, and the points will be increased or decreased accordingly. For example, the initial form is Little Mario, and after eating mushrooms, it will become Super Mario and increase 100 points.

In fact, the transformation of Mario's form is a state machine. Among them, the different forms of Mario are the "states" in the state machine, the game plot (such as eating mushrooms) is the "event" in the state machine, and the addition and subtraction of points are the "actions" in the state machine. For example, the event of eating mushrooms will trigger a state transfer: from Little Mario to Super Mario, and trigger the execution of an action (increase 100 points).

In order to facilitate the next explanation, I simplified the game background and only kept some states and events. The simplified state transition is shown in the figure below:

insert image description here

How do we program to implement the above state machine? In other words, how to translate the above state transition diagram into code?

I wrote a skeleton code as below. Among them, the functions obtainMushRoom(), obtainCape(), obtainFireFlower(), and meetMonster() can update the status and increase or decrease points according to the current status and events. However, I have not given the specific code implementation for the time being. You can take it as an interview question, try to complete it, and then read my explanation below, so that your gains will be even greater.

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);
  }
}

State machine implementation method 1: branch logic method

For how to implement the state machine, I have summarized three ways. Among them, the simplest and most direct implementation method is to refer to the state transition diagram and translate each state transition into code as it is. The code written in this way will contain a lot of if-else or switch-case branch judgment logic, or even nested branch judgment logic, so I temporarily named this method the branch logic method.

According to this implementation idea, I will complete the skeleton code above. The completed code is as follows:

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;
  }
}

For simple state machines, this implementation of branching logic is acceptable. However, for a complex state machine, it is very easy to omit or miswrite a certain state transition in this implementation. In addition, the code is filled with a lot of if-else or switch-case branch judgment logic, which is poor in readability and maintainability. If one day we modify a certain state transition in the state machine, we need to find the corresponding code in the lengthy branch logic and modify it. It is easy to correct mistakes and introduce bugs.

State machine implementation method 2: look-up table method

In fact, the above implementation method is somewhat similar to hard code, which is not suitable for complex state machines, and the second implementation method of state machines, the look-up table method, is more suitable. Next, let's take a look at how to use the table lookup method to complete the skeleton code.

In fact, in addition to being represented by a state transition diagram, a state machine can also be represented by a two-dimensional table, as shown below. In this two-dimensional table, the first dimension represents the current state, the second dimension represents the event, and the value represents the new state that the current state transitions to and the action it performs after the event passes through.

insert image description here

Compared with the implementation of branch logic, the code implementation of the look-up table method is clearer, and the readability and maintainability are better. When modifying the state machine, we only need to modify the two two-dimensional arrays of transitionTable and actionTable. In fact, if we store these two two-dimensional arrays in the configuration file, when we need to modify the state machine, we don't even need to modify any code, just modify the configuration file. The specific code is as follows:

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;
  }

}

State machine implementation method three: state mode

In the code implementation of the look-up table method, the action triggered by the event is just a simple addition and subtraction of the integral, so we can use a two-dimensional array actionTable of type int to represent it, and the value in the two-dimensional array represents the addition and subtraction of the integral. However, if the action to be performed is not so simple, but a series of complex logical operations (such as adding and subtracting points, writing to the database, and possibly sending message notifications, etc.), we cannot use such a simple two-dimensional array to Expressed. That is to say, the implementation of the look-up table method has certain limitations.

Although the implementation of branch logic does not have this problem, it has other problems mentioned above, such as more branch judgment logic, resulting in poor code readability and maintainability. In fact, we can use the state pattern to solve the problems of the branch logic method.

The state mode avoids branch judgment logic by splitting the event-triggered state transition and action execution into different state classes. We still combine the code to understand this sentence.

Using the state pattern, let's complete the MarioStateMachine class. The completed code is as follows.

Among them, IMario is the interface of the state, which defines all events. SmallMario, SuperMario, CapeMario, and FireMario are the implementation classes of the IMario interface, which correspond to the four states in the state machine. In the past, all the code logic of state transition and action execution was concentrated in the MarioStateMachine class. Now, these code logics are dispersed into these 4 state classes.

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;
  }
}

The above code implementation is not difficult to understand. I only emphasize one point, that is, there is a two-way dependency between MarioStateMachine and each state class. It is natural for MarioStateMachine to rely on each state class, but, conversely, why do each state class depend on MarioStateMachine? This is because each state class needs to update two variables in MarioStateMachine, score and currentState.

In fact, the above code can continue to be optimized, we can design the state class as a singleton, after all, the state class does not contain any member variables. However, when the state class is designed as a singleton, we cannot pass MarioStateMachine through the constructor, and the state class depends on MarioStateMachine, so how to solve this problem?

In fact, in the explanation of the singleton mode in Lecture 42, we mentioned several solutions, you can go back and check it out. Here, we can pass MarioStateMachine into the state class through function parameters. According to this design idea, we refactor the above code. The code after refactoring looks like this:

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;
  }
}

In fact, a more complex state machine like a game contains more states. I recommend using the table lookup method first, and the state mode will introduce a lot of state classes, which will make the code more difficult to maintain. On the contrary, state machines of the type such as e-commerce order and takeaway order have few states and relatively simple state transition, but the business logic contained in the action triggered by the event may be more complicated, so it is more recommended Use the state pattern to achieve.

key review

Well, that's all for today's content. Let's summarize and review together, what you need to focus on.

Today we covered the state pattern. Although there are various definitions of state patterns on the Internet, you just need to remember that state patterns are an implementation of state machines. A state machine is also called a finite state machine, and it consists of three parts: state, event, and action. Among them, an event is also called a transition condition. Events trigger the transition of states and the execution of actions. However, the action is not necessary, and it is possible to just transfer the state without performing any action.

For the state machine, today we have summarized three implementation methods.

The first implementation is called the branching logic method. Use if-else or switch-case branch logic, refer to the state transition diagram, and literally translate each state transition into code as it is. For simple state machines, this implementation is the simplest and most straightforward, and is the first choice.

The second implementation is called look-up table method. For a state machine with many states and complex state transitions, the look-up table method is more suitable. Representing the state transition diagram through a two-dimensional array can greatly improve the readability and maintainability of the code.

The third implementation is called the state pattern. For a state machine with few states and relatively simple state transition, but the business logic contained in the action triggered by the event may be more complex, we prefer this implementation method.

class disscussion

There are still some problems in the code implementation of the state mode. For example, all event functions are defined in the state interface, which leads to the fact that even if a state class does not need to support one or some of the events, it must implement all event function. Not only that, adding an event to the state interface, all state classes have to be modified accordingly. Do you have any solutions for these problems?

Welcome to leave a message and share your thoughts with me. If you gain something, you are welcome to share this article with your friends.

Guess you like

Origin blog.csdn.net/fegus/article/details/130519268