為 Unity 準備一個泛用的狀態模式 – prepare 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。

450px-finite_state_machine_example_with_comments-svg
取自 Wiki – Finite-state_machine 條目

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

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

首先是控制器 StateController

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

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

隨著使用 State pattern 的次數增加,我漸漸發現有時候會有無法將工作完整切分成數個狀態的情況(有共用資源導致無法一刀兩段);或者即使拆開為數個狀態,卻無法讓每個狀態確實獨立,導致有些狀態之間的次序必須不可反轉才不會導致程式錯誤。這些情況迫使我放棄了一部分對 State pattern 所預訂的原則,也放棄了一些設計優點。

於是我開始研究使用階層式狀態機的方法,巢狀的狀態結構,讓狀態的設計規劃更彈性且分明。如果有數個狀態之間次序或者共用資源的必要性,便另外使用一個狀態進行包裝,由較高階的狀態負責管理次序跟資源,解決原本狀態設計受限的情況。

uml_state_machine_fig5
取自 Wiki – Finite-state_machine 條目

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

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

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

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

HierarchicalStateController

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

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

發表迴響

在下方填入你的資料或按右方圖示以社群網站登入:

WordPress.com Logo

您的留言將使用 WordPress.com 帳號。 登出 / 變更 )

Twitter picture

您的留言將使用 Twitter 帳號。 登出 / 變更 )

Facebook照片

您的留言將使用 Facebook 帳號。 登出 / 變更 )

Google+ photo

您的留言將使用 Google+ 帳號。 登出 / 變更 )

連結到 %s