Unity AI 開發:基於 xNode 的圖形 FSM 教程
已發表: 2022-08-12在“Unity AI 開發:有限狀態機教程”中,我們創建了一個簡單的隱形遊戲——基於模塊化 FSM 的 AI。 在遊戲中,一名敵方特工在遊戲空間內巡邏。 當它發現玩家時,敵人會改變狀態並跟隨玩家而不是巡邏。
在 Unity 之旅的第二站中,我們將構建一個圖形用戶界面 (GUI),以更快地創建有限狀態機 (FSM) 的核心組件,並改善 Unity 開發人員體驗。
快速復習
上一教程中詳述的 FSM 是由 C# 腳本形式的架構塊構建的。 我們將自定義ScriptableObject
操作和決策添加為類。 ScriptableObject
方法允許易於維護和定制的 FSM。 在本教程中,我們將 FSM 的拖放ScriptableObject
替換為圖形選項。
我還為那些想要讓遊戲更容易獲勝的人編寫了一個更新的腳本。 要實施,只需將玩家檢測腳本替換為縮小敵人視野的腳本即可。
開始使用 xNode
我們將使用 xNode 構建我們的圖形編輯器,這是一個基於節點的行為樹的框架,它將直觀地顯示我們 FSM 的流程。 儘管 Unity 的 GraphView 可以完成這項工作,但它的 API 是實驗性的,而且文檔很少。 xNode 的用戶界面提供了卓越的開發人員體驗,促進了 FSM 的原型設計和快速擴展。
讓我們使用 Unity 包管理器將 xNode 作為 Git 依賴項添加到我們的項目中:
- 在 Unity 中,單擊窗口 > 包管理器以啟動包管理器窗口。
- 單擊窗口左上角的+ (加號)並選擇Add package from git URL以顯示文本字段。
- 在未標記的文本框中鍵入或粘貼
https://github.com/siccity/xNode.git
,然後單擊添加按鈕。
現在我們準備深入了解 xNode 的關鍵組件:
Node 類 | 表示一個節點,一個圖的最基本單位。 在這個 xNode 教程中,我們從Node 類派生新類,這些類聲明了配備自定義功能和角色的節點。 |
NodeGraph 類 | 表示節點的集合( Node 類實例)和連接它們的邊。 在本 xNode 教程中,我們從NodeGraph 派生了一個新類,用於操作和評估節點。 |
NodePort 類 | 表示一個通信門,一個輸入或輸出類型的端口,位於NodeGraph 中的Node 實例之間。 NodePort 類是 xNode 獨有的。 |
[Input] 屬性 | 將[Input] 屬性添加到端口會將其指定為輸入,從而使端口能夠將值傳遞給它所屬的節點。 將[Input] 屬性視為函數參數。 |
[Output] 屬性 | 向端口添加[Output] 屬性會將其指定為輸出,從而使端口能夠從它所屬的節點傳遞值。 將[Output] 屬性視為函數的返回值。 |
可視化 xNode 構建環境
在 xNode 中,我們使用圖,其中每個State
和Transition
都採用節點的形式。 輸入和/或輸出連接使節點能夠與我們圖中的任何或所有其他節點相關聯。
讓我們想像一個具有三個輸入值的節點:兩個任意值和一個布爾值。 該節點將輸出兩個任意類型的輸入值之一,具體取決於布爾輸入是真還是假。
要將現有的 FSM 轉換為圖,我們修改State
和Transition
類以繼承Node
類而不是ScriptableObject
類。 我們創建一個NodeGraph
類型的圖形對象來包含我們所有的State
和Transition
對象。
修改BaseStateMachine
以用作基本類型
通過向我們現有的BaseStateMachine
類添加兩個新的虛擬方法開始構建圖形界面:
Init | 將初始狀態分配給CurrentState 屬性 |
Execute | 執行當前狀態 |
將這些方法聲明為 virtual 允許我們覆蓋它們,因此我們可以定義繼承BaseStateMachine
類的類的自定義行為以進行初始化和執行:
using System; using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private Dictionary<Type, Component> _cachedComponents; private void Awake() { Init(); _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { Execute(); } public virtual void Init() { CurrentState = _initialState; } public virtual void Execute() { CurrentState.Execute(this); } // Allows us to execute consecutive calls of GetComponent in O(1) time public new T GetComponent<T>() where T : Component { if(_cachedComponents.ContainsKey(typeof(T))) return _cachedComponents[typeof(T)] as T; var component = base.GetComponent<T>(); if(component != null) { _cachedComponents.Add(typeof(T), component); } return component; } } }
接下來,在我們的FSM
文件夾下,讓我們創建:
FSMGraph | 一個文件夾 |
BaseStateMachineGraph | FSMGraph 中的 AC# 類 |
目前, BaseStateMachineGraph
將僅繼承BaseStateMachine
類:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }
在創建基礎節點類型之前,我們無法向BaseStateMachineGraph
添加功能; 接下來讓我們這樣做。
實現NodeGraph
並創建基本節點類型
在我們新創建的FSMGraph
文件夾下,我們將創建:
FSMGraph | 一類 |
目前, FSMGraph
將僅繼承NodeGraph
類(不添加任何功能):
using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }
在為節點創建類之前,讓我們添加:
FSMNodeBase | 一個類將被我們的所有節點用作基類 |
FSMNodeBase
類將包含一個名為Entry
的FSMNodeBase
類型的輸入,以使我們能夠將節點彼此連接。
我們還將添加兩個輔助函數:
GetFirst | 檢索連接到請求輸出的第一個節點 |
GetAllOnPort | 檢索連接到請求的輸出的所有剩餘節點 |
using System.Collections.Generic; using XNode; namespace Demo.FSM.Graph { public abstract class FSMNodeBase : Node { [Input(backingValue = ShowBackingValue.Never)] public FSMNodeBase Entry; protected IEnumerable<T> GetAllOnPort<T>(string fieldName) where T : FSMNodeBase { NodePort port = GetOutputPort(fieldName); for (var portIndex = 0; portIndex < port.ConnectionCount; portIndex++) { yield return port.GetConnection(portIndex).node as T; } } protected T GetFirst<T>(string fieldName) where T : FSMNodeBase { NodePort port = GetOutputPort(fieldName); if (port.ConnectionCount > 0) return port.GetConnection(0).node as T; return null; } } }
最終,我們將擁有兩種類型的狀態節點; 讓我們添加一個類來支持這些:
BaseStateNode | 支持StateNode 和RemainInStateNode 的基類 |
namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }
接下來,修改BaseStateMachineGraph
類:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }
在這裡,我們隱藏了從基類繼承的CurrentState
屬性,並將其類型從BaseState
更改為BaseStateNode
。
為我們的 FSM 圖創建構建塊
接下來,為了形成 FSM 的主要構建塊,讓我們在FSMGraph
文件夾中添加三個新類:
StateNode | 表示代理的狀態。 在執行時, StateNode 迭代連接到StateNode 輸出端口的TransitionNode (由輔助方法檢索)。 StateNode 查詢每個節點是將節點轉換到不同狀態還是保持節點狀態不變。 |
RemainInStateNode | 指示節點應保持在當前狀態。 |
TransitionNode | 做出轉換到不同狀態或保持相同狀態的決定。 |
在之前的 Unity FSM 教程中, State
類迭代了轉換列表。 在 xNode 中, StateNode
充當State
的等效項,以遍歷通過我們的GetAllOnPort
輔助方法檢索到的節點。
現在將[Output]
屬性添加到傳出連接(轉換節點)以指示它們應該是 GUI 的一部分。 根據 xNode 的設計,屬性的值源自源節點:包含標有[Output]
屬性的字段的節點。 由於我們使用[Output]
和[Input]
屬性來描述將由 xNode GUI 設置的關係和連接,因此我們不能像往常一樣對待這些值。 考慮我們如何迭代Actions
與Transitions
:
using System.Collections.Generic; namespace Demo.FSM.Graph { [CreateNodeMenu("State")] public sealed class StateNode : BaseStateNode { public List<FSMAction> Actions; [Output] public List<TransitionNode> Transitions; public void Execute(BaseStateMachineGraph baseStateMachine) { foreach (var action in Actions) action.Execute(baseStateMachine); foreach (var transition in GetAllOnPort<TransitionNode>(nameof(Transitions))) transition.Execute(baseStateMachine); } } }
在這種情況下, Transitions
輸出可以附加多個節點; 我們必須調用GetAllOnPort
輔助方法來獲取[Output]
連接的列表。
到目前為止, RemainInStateNode
是我們最簡單的類。 不執行任何邏輯, RemainInStateNode
僅向我們的代理(在我們的遊戲中為敵人)指示保持其當前狀態:
namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }
此時, TransitionNode
類仍然不完整,不會編譯。 一旦我們更新類,相關的錯誤就會清除。
要構建TransitionNode
,我們需要繞過 xNode 的要求,即輸出的值源自源節點——就像我們在構建StateNode
時所做的那樣。 StateNode
和TransitionNode
之間的一個主要區別是TransitionNode
的輸出可能只附加到一個節點。 在我們的例子中, GetFirst
將獲取連接到我們每個端口的一個節點(在 true 情況下轉換到一個狀態節點,在 false 情況下轉換到另一個狀態節點):
namespace Demo.FSM.Graph { [CreateNodeMenu("Transition")] public sealed class TransitionNode : FSMNodeBase { public Decision Decision; [Output] public BaseStateNode TrueState; [Output] public BaseStateNode FalseState; public void Execute(BaseStateMachineGraph stateMachine) { var trueState = GetFirst<BaseStateNode>(nameof(TrueState)); var falseState = GetFirst<BaseStateNode>(nameof(FalseState)); var decision = Decision.Decide(stateMachine); if (decision && !(trueState is RemainInStateNode)) { stateMachine.CurrentState = trueState; } else if(!decision && !(falseState is RemainInStateNode)) stateMachine.CurrentState = falseState; } } }
讓我們看一下我們代碼的圖形結果。
創建可視化圖表
整理完所有 FSM 類後,我們可以繼續為遊戲的敵方代理創建 FSM Graph。 在 Unity 項目窗口中,右鍵單擊EnemyAI
文件夾並選擇: Create > FSM > FSM Graph 。 為了讓我們的圖更容易識別,讓我們將其重命名為EnemyGraph
。
在 xNode Graph 編輯器窗口中,右鍵單擊以顯示一個下拉菜單,其中列出了State 、 Transition和RemainInState 。 如果該窗口不可見,請雙擊EnemyGraph
文件以啟動 xNode Graph 編輯器窗口。
要創建
Chase
和Patrol
狀態:右鍵單擊並選擇State以創建一個新節點。
將節點命名為
Chase
。返回下拉菜單,再次選擇State以創建第二個節點。
將節點命名為
Patrol
。將現有的
Chase
和Patrol
操作拖放到它們新創建的相應狀態。
要創建過渡:
右鍵單擊並選擇轉換以創建一個新節點。
將
LineOfSightDecision
對象分配給轉換的Decision
字段。
創建
RemainInState
節點:- 右鍵單擊並選擇RemainInState以創建一個新節點。
要連接圖表:
將
Patrol
節點的Transitions
輸出連接到Transition
節點的Entry
輸入。將
Transition
節點的True State
輸出連接到Chase
節點的Entry
輸入。將
Transition
節點的False State
輸出連接到Remain In State
節點的Entry
輸入。
該圖應如下所示:
圖中沒有任何內容表明哪個節點( Patrol
或Chase
狀態)是我們的初始節點。 BaseStateMachineGraph
類檢測到四個節點,但沒有指示符,無法選擇初始狀態。
為了解決這個問題,讓我們創建:
FSMInitialNode | InitialNode 類型的單個輸出名為StateNode 的類 |
我們的輸出InitialNode
表示初始狀態。 接下來,在FSMInitialNode
中,創建:
NextNode | 使我們能夠獲取連接到InitialNode 輸出的節點的屬性 |
using XNode; namespace Demo.FSM.Graph { [CreateNodeMenu("Initial Node"), NodeTint("#00ff52")] public class FSMInitialNode : Node { [Output] public StateNode InitialNode; public StateNode NextNode { get { var port = GetOutputPort("InitialNode"); if (port == null || port.ConnectionCount == 0) return null; return port.GetConnection(0).node as StateNode; } } } }
現在我們創建了FSMInitialNode
類,我們可以將它連接到初始狀態的Entry
輸入,並通過NextNode
屬性返回初始狀態。
讓我們回到我們的圖表並添加初始節點。 在 xNode 編輯器窗口中:
- 右鍵單擊並選擇初始節點以創建一個新節點。
- 將FSM Node的輸出附加到
Patrol
節點的Entry
輸入。
該圖現在應如下所示:
為了讓我們的生活更輕鬆,我們將添加到FSMGraph
:
InitialState | 一處房產 |
當我們第一次嘗試檢索InitialState
屬性的值時,該屬性的 getter 將遍歷我們圖中的所有節點,因為它試圖找到FSMInitialNode
。 找到FSMInitialNode
,我們使用NextNode
屬性來查找我們的初始狀態節點:
using System.Linq; using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public sealed class FSMGraph : NodeGraph { private StateNode _initialState; public StateNode InitialState { get { if (_initialState == null) _initialState = FindInitialStateNode(); return _initialState; } } private StateNode FindInitialStateNode() { var initialNode = nodes.FirstOrDefault(x => x is FSMInitialNode); if (initialNode != null) { return (initialNode as FSMInitialNode).NextNode; } return null; } } }
接下來,在BaseStateMachineGraph
中,讓我們引用FSMGraph
並覆蓋BaseStateMachine
的Init
和Execute
方法。 覆蓋Init
將CurrentState
設置為圖的初始狀態,覆蓋Execute
調用CurrentState
上的Execute
:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { [SerializeField] private FSMGraph _graph; public new BaseStateNode CurrentState { get; set; } public override void Init() { CurrentState = _graph.InitialState; } public override void Execute() { ((StateNode)CurrentState).Execute(this); } } }
現在,讓我們將圖表應用到 Enemy 對象,並查看它的實際效果。
測試 FSM 圖
在準備測試時,在 Unity 編輯器的項目窗口中:
打開 SampleScene 資產。
在 Unity 層次結構窗口中找到我們的
Enemy
遊戲對象。將
BaseStateMachine
組件替換為BaseStateMachineGraph
組件:單擊添加組件並選擇正確的
BaseStateMachineGraph
腳本。將我們的 FSM 圖
EnemyGraph
分配給BaseStateMachineGraph
組件的Graph
字段。通過右鍵單擊並選擇Remove Component來刪除
BaseStateMachine
組件(因為它不再需要)。
Enemy
遊戲對象應如下所示:
而已! 現在我們有了一個帶有圖形編輯器的模塊化 FSM。 單擊Play按鈕顯示以圖形方式創建的敵人 AI 的工作方式與我們之前創建的ScriptableObject
敵人完全一樣。
砥礪前行:優化我們的 FSM
提醒一句:當您為遊戲開發更複雜的 AI 時,狀態和轉換的數量會增加,並且 FSM 會變得混亂且難以閱讀。 圖形編輯器變得類似於一個由多個狀態開始並在多個轉換處終止的線網,反之亦然,這使得 FSM 難以調試。
與之前的教程一樣,我們邀請您編寫自己的代碼,優化您的潛行遊戲,並解決這些問題。 想像一下,對狀態節點進行顏色編碼以指示節點是活動還是非活動,或者調整RemainInState
和Initial
節點的大小以限制其屏幕空間會有多大幫助。
這種增強不僅僅是裝飾性的。 顏色和尺寸參考將有助於確定調試的地點和時間。 直觀的圖表也更易於評估、分析和理解。 任何後續步驟都取決於您 — 在我們的圖形編輯器的基礎上,您可以進行的開發人員體驗改進沒有限制。
進一步閱讀 Toptal 工程博客:
- Unity 開發人員最常犯的 10 個錯誤
- Unity 與 MVC:如何升級您的遊戲開發
- 在 Unity 中掌握 2D 相機:遊戲開發者教程
- Toptal 開發人員提供的 Unity 最佳實踐和技巧
Toptal 工程博客對 Goran Lalic 對本文的專業知識和技術評論表示感謝。