為 Unity 準備一個泛用的狀態模式 – Preparing a State Pattern for Unity

State pattern 是我使用次數最多的設計模式,在 Unity 遊戲的開發中被我用於程式流程的控制、規則的彈性編輯、簡易 AI 的撰寫、腳色控制器的設計等,是應用相當廣泛的模式。

因為次數太多了,索性事先設計好基本的類別,當需要的時候便可以隨時隨地使用 State pattern,本文便是藉機介紹了 State pattern 及我的通用作法。

State pattern 簡介

聽到 State pattern 大家應該會聯想到有限狀態機 (FSM,finite-state machine),但兩者嚴格來說是不完全相同的,FSM 本身是個設計概念,被應用在諸多領域,實作方式百百種;而 State pattern 只是 FSM 在物件導向中的一個實現方案。

用一句話來形容 State pattern 就是「透過不同狀態的切換,讓類別展現不同的行為」,這句話之中可以看出 State pattern 的兩個面向,分離不同的行為模式以及整合(切換)不同的行為狀態

使用 State pattern (或狀態機) 的好處是:

  • 方便拆分程式碼,讓單一份程式只需專注在他當下的工作。
  • 容易改動運作流程 – 只要適當使用,狀態之間的轉移次序是相當彈性的。
  • 管理資源容易 – 每個狀態所使用的資源,可以在狀態結束前自行釋放。

而缺點是:

  • 不易從程式方面限制使用方式,需要開發者主動遵守狀態機的規格,錯誤使用的狀態機可能會失去上述所有優點。特別在合作開發時更要注意不同人對於狀態機的使用是否達成共識。
  • 每多一個狀態都要增加一個類別,可能會有類別數量狂增的情況。

State pattern 實踐方向

要實踐一個 State pattern 的通用作法,就相當於要設計一個簡易的 FSM。

FSM

上圖取自 Wiki – Finite-state_machine 條目

在物件導向程式設計中,FSM 沒有一個公認的實作方式,有些比較嚴謹的實作方式會定義轉移路徑,也有些實作方式會暫存所有狀態,而我希望的是一個最簡化的設計,只為了隨時可以使用 State pattern,所以只準備了以下三個設計目標:

  • 有一個控制器 (Controller) 負責管理與切換狀態。
  • 每個狀態 (State) 都有三個階段 – 進入、運作、離開。
  • 控制器 (Controller) 必須被注入狀態 (State) 之中,避免當狀態決定執行切換時無從呼叫控制器。

首先是控制器 StateController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
using UnityEngine;
using System.Collections;

namespace DouduckGame {
public sealed class StateController {
private IState m_oCurrentState = null;
public IState CurrentState {
get {
return m_oCurrentState;
}
}

private bool m_bStarted = false;
private bool m_bTerminated = false;

public StateController() {}
public StateController(IState oStartState) : this() {
Start(oStartState);
}

public void Start(IState oState) {
if (m_bTerminated) {
Debug.LogError("[StateController] has been terminated");
return;
}
if (m_bStarted) {
Debug.LogError("[StateController] has been started");
return;
}
Debug.Log("[StateController] Start: " + oState.ToString());
m_bStarted = true;
m_oCurrentState = oState;
m_oCurrentState.SetProperty(this);
}

public void Terminate() {
if (m_oCurrentState != null) {
m_oCurrentState.StateEnd();
m_oCurrentState = null;
}
m_bTerminated = true;
}

public void TransTo(IState oState) {
if (m_bTerminated) {
Debug.LogError("[StateController] has been terminated");
return;
}
if (!m_bStarted) {
Debug.LogError("[StateController] need to be started first");
return;
}
Debug.Log("[StateController] TransTo: " + oState.ToString());
if (m_oCurrentState != null) {
m_oCurrentState.StateEnd();
}
m_oCurrentState = oState;
m_oCurrentState.SetProperty(this);
}

public void StateUpdate() {
if (m_bTerminated || !m_bStarted) {
return;
}
if (m_oCurrentState != null) {
if (m_oCurrentState.AtStateBegin) {
m_oCurrentState.TouchStateBegin();
m_oCurrentState.StateBegin();
if (m_oCurrentState == null) {
return;
}
}
m_oCurrentState.StateUpdate();
}
}
}
}

StateController 總共設計了四個 public 方法:

  • void Start(IState) – 傳入一個狀態,作為最初的狀態開始狀態機的運作。必須呼叫此方法後,才能使用其他方法。
  • void Terminate() – 終止當前狀態,結束狀態機的運作,將不再可以使用其他三個方法。
  • void TransTo(IState) – 傳入一個狀態,則切換到該狀態,同時會執行上個狀態的 StateEnd()。
  • void StateUpdate() – 更新與執行狀態機的運作,會視需要呼叫當前狀態的 StateBegin(),並固定呼叫當前狀態的 StateUpdate()。

通常會搭配一個 Monobehaviour 腳本封裝 StateController ,然後再由 Monobehaviour.Update() 呼叫 StateUpdate() 來持續更新狀態機。

接著是狀態的介面(abstract class) IState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using UnityEngine;
using System.Collections;

namespace DouduckGame {
public abstract class IState {
private StateController m_StateController;
protected StateController Controller {
get {
return m_StateController;
}
}
private bool m_bAtStateBegin = true;
public bool AtStateBegin {
get {
return m_bAtStateBegin;
}
}

public void SetProperty(StateController oController) {
m_StateController = oController;
}

public void TouchStateBegin() {
m_bAtStateBegin = false;
}

protected void TransTo(IState oState) {
m_StateController.TransTo(oState);
}

public virtual void StateBegin() {}
public virtual void StateUpdate() {}
public virtual void StateEnd() {}

public override string ToString () {
return string.Format ("<IState>" + this.GetType().Name);
}
}
}

作為狀態的主要功能,共有三個 public virtual 方法:

  • void StateBegin() – 狀態的預備動作,會在第一次執行 StateUpdate() 之前執行一次。
  • void StateUpdate() – 狀態運作的核心,由 StateController.StateUpdate() 負責呼叫,使用邏輯與 Unity Monobehaviour 的 Update() 相似。
  • void StateEnd() – 當狀態即將被取代或結束時會呼叫,通常用於狀態的收尾工作、退訂 (反註冊) 事件系統、釋放相關資源等。

其實狀態的三個 virtual 方法使用邏輯便是對應到 Monobehaviour 的 Start()、Update() 及 OnDestory() 之間的關係,但由於透過 State pattern 進行了拆分,可以讓一個類別或者 Monobehaviour 依照情況扮演不同的腳色與功能。

除了三個主要功能,IState 也被**相依注入(Dependency Injection)**了負責控制的 StateController 本體,所以可以在狀態之中直接控制狀態的切換,不須使用 Singleton 或其他手段由外部取得控制。為了方便也實作了IState.TransTo(IState)可以直接呼叫。

剩餘未介紹的類別成員與方法,則是為了實作 StateController 而設計的 Flag 以及注入方法,有興趣的人可以再自行閱讀程式碼了解。

階層式的狀態機 Hierarchical Finite State Machine

HFSM

上圖取自 Wiki – Finite-state_machine 條目

原本為了實現上方巢狀設計的最簡單方法,就是另外放一個 StateController 在 IState 之中,實現父子關係般的狀態機結構。

不過經過測試與修改,我決定不直接採用巢狀的資料結構,而是自行實作 Stack 結構來儲存所有的狀態 (IHierarchicalState),為此而外設計了 HierarchicalStateController,總共兩個全新的類別。

使用 Stack 儲存狀態,在執行時與巢狀結構並無差異,皆是有著 LIFO 的特性,這點可以將狀態機的 UML 圖表重繪成 Tree graph 來輕易看出。而在有相同執行結果的情況下,Stack 儲存可以避開多餘的方法呼叫,避免在每一次 StateUpdate() 時產生遞迴一般的呼叫次序,降低執行上的負擔。

下方是兩個新類別的程式碼,其中在 HierarchicalStateController.TransTo() 中我使用 Level 參數來決定是否深入建立子狀態機,由 0 作為第一層的狀態,每建構一層子狀態機 Level 則加一。剩餘部分則與普通狀態機大同小異。

HierarchicalStateController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

namespace DouduckGame {
public sealed class HierarchicalStateController {

private List<IHierarchicalState> m_oCurrentState = null;
private bool m_bAtStateBegin = false;

private bool m_bStarted = false;
private bool m_bTerminated = false;

public HierarchicalStateController() {
m_oCurrentState = new List<IHierarchicalState> ();
}
public HierarchicalStateController(IHierarchicalState oStartState) : this() {
Start(oStartState);
}

public void Start(IHierarchicalState oState) {
if (m_bTerminated) {
Debug.LogError("[HStateController] has been terminated");
return;
}
if (m_bStarted) {
Debug.LogError("[HStateController] has been started");
return;
}
Debug.Log("[HStateController] Start: " + oState.ToString());
m_bStarted = true;
m_oCurrentState.Add(oState);
m_oCurrentState[0].SetProperty(this, 0);
}

public void Terminate() {
for (int i = m_oCurrentState.Count - 1; i >= 0; i--) {
m_oCurrentState[i].StateEnd();
}
m_oCurrentState.Clear();
m_bTerminated = true;
}

public void TransTo(int iLevel, IHierarchicalState oState) {
if (m_bTerminated) {
Debug.LogError("[StateController] has been terminated");
return;
}
if (!m_bStarted) {
Debug.LogError("[StateController] need to be started first");
return;
}
if (iLevel > m_oCurrentState.Count) {
Debug.LogError("[StateController] Level is too big");
return;
}

Debug.Log(string.Format("[StateController] Level {0:} transTo: {1:}", iLevel, oState.ToString()));
if (iLevel == m_oCurrentState.Count) {
m_oCurrentState.Add(oState);
m_oCurrentState [iLevel].SetProperty(this, iLevel);
} else {
for (int i = m_oCurrentState.Count - 1; i >= iLevel; i--) {
m_oCurrentState [i].StateEnd ();
m_oCurrentState.RemoveAt (i);
}
m_oCurrentState.Add(oState);
m_oCurrentState [iLevel].SetProperty(this, iLevel);
}
}

public void StateUpdate() {
if (m_bTerminated || !m_bStarted) {
return;
}
for (int i = 0; i < m_oCurrentState.Count; i++) {
if (m_oCurrentState[i].AtStateBegin) {
m_oCurrentState[i].TouchStateBegin();
m_oCurrentState[i].StateBegin();
if (m_oCurrentState == null) {
return;
}
}
m_oCurrentState[i].StateUpdate();
}
}
}
}

IHierarchicalState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
using UnityEngine;
using System.Collections;

namespace DouduckGame {
public abstract class IHierarchicalState {
private HierarchicalStateController m_StateController;
protected HierarchicalStateController Controller {
get {
return m_StateController;
}
}
private int m_iLevel = 0;
protected int StateLevel {
get {
return m_iLevel;
}
}
private bool m_bAtStateBegin = true;
public bool AtStateBegin {
get {
return m_bAtStateBegin;
}
}

public void SetProperty(HierarchicalStateController oController, int iLevel) {
m_StateController = oController;
m_iLevel = iLevel;
}

public void TouchStateBegin() {
m_bAtStateBegin = false;
}

protected void TransTo(IHierarchicalState oState) {
m_StateController.TransTo(m_iLevel, oState);
}

protected void TransTo(int iLevel, IHierarchicalState oState) {
m_StateController.TransTo(iLevel, oState);
}

protected void AddSubState(IHierarchicalState oState) {
m_StateController.TransTo(m_iLevel + 1, oState);
}

public virtual void StateBegin() {}
public virtual void StateUpdate() {}
public virtual void StateEnd() {}

public override string ToString () {
return string.Format ("<IHState>" + this.GetType().Name);
}
}
}