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 依賴項添加到我們的項目中:

  1. 在 Unity 中,單擊窗口 > 包管理器以啟動包管理器窗口。
  2. 單擊窗口左上角的+ (加號)並選擇Add package from git URL以顯示文本字段。
  3. 在未標記的文本框中鍵入或粘貼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 中,我們使用圖,其中每個StateTransition都採用節點的形式。 輸入和/或輸出連接使節點能夠與我們圖中的任何或所有其他節點相關聯。

讓我們想像一個具有三個輸入值的節點:兩個任意值和一個布爾值。 該節點將輸出兩個任意類型的輸入值之一,具體取決於布爾輸入是真還是假。

由中心的大矩形表示的 Branch 節點包含偽代碼“If C == True A Else B”。左側是三個矩形,每個矩形都有一個指向分支節點的箭頭:“A(任意)”、“B(任意)”和“C(布爾)”。最後,Branch 節點有一個指向“輸出”矩形的箭頭。
一個示例Branch節點

要將現有的 FSM 轉換為圖,我們修改StateTransition類以繼承Node類而不是ScriptableObject類。 我們創建一個NodeGraph類型的圖形對象來包含我們所有的StateTransition對象。

修改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類將包含一個名為EntryFSMNodeBase類型的輸入,以使我們能夠將節點彼此連接。

我們還將添加兩個輔助函數:

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 支持StateNodeRemainInStateNode的基類
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 設置的關係和連接,因此我們不能像往常一樣對待這些值。 考慮我們如何迭代ActionsTransitions

 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時所做的那樣。 StateNodeTransitionNode之間的一個主要區別是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 編輯器窗口中,右鍵單擊以顯示一個下拉菜單,其中列出了StateTransitionRemainInState 。 如果該窗口不可見,請雙擊EnemyGraph文件以啟動 xNode Graph 編輯器窗口。

  1. 要創建ChasePatrol狀態:

    1. 右鍵單擊並選擇State以創建一個新節點。

    2. 將節點命名為Chase

    3. 返回下拉菜單,再次選擇State以創建第二個節點。

    4. 將節點命名為Patrol

    5. 將現有的ChasePatrol操作拖放到它們新創建的相應狀態。

  2. 要創建過渡:

    1. 右鍵單擊並選擇轉換以創建一個新節點。

    2. LineOfSightDecision對象分配給轉換的Decision字段。

  3. 創建RemainInState節點:

    1. 右鍵單擊並選擇RemainInState以創建一個新節點。
  4. 要連接圖表:

    1. Patrol節點的Transitions輸出連接到Transition節點的Entry輸入。

    2. Transition節點的True State輸出連接到Chase節點的Entry輸入。

    3. Transition節點的False State輸出連接到Remain In State節點的Entry輸入。

該圖應如下所示:

四個節點表示為四個矩形,每個矩形的左上角都有 Entry 輸入圓圈。從左到右,巡邏狀態節點顯示一個動作:巡邏動作。 Patrol 狀態節點還在其右下角包含一個 Transitions 輸出圓圈,該圓圈連接到 Transition 節點的 Entry 圓圈。過渡節點顯示一個決策:LineOfSight。它的右下角有兩個輸出圓圈,True State 和 False State。 True State 連接到我們的第三個結​​構的入口圈,Chase 狀態節點。 Chase 狀態節點顯示一個動作:Chase Action。 Chase 狀態節點有一個Transitions 輸出圓圈。 Transition 的兩個輸出圓中的第二個,False State,連接到我們的第四個也是最後一個結構的 Entry 圓,RemainInState 節點(出現在 Chase 狀態節點下方)。
初步了解我們的 FSM 圖

圖中沒有任何內容表明哪個節點( PatrolChase狀態)是我們的初始節點。 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 編輯器窗口中:

  1. 右鍵單擊並選擇初始節點以創建一個新節點。
  2. FSM Node的輸出附加到Patrol節點的Entry輸入。

該圖現在應如下所示:

與我們上一張圖相同的圖表,在其他四個矩形的左側添加了一個 FSM 節點綠色矩形。它有一個初始節點輸出(由藍色圓圈表示),連接到巡邏節點的“條目”輸入(由深紅色圓圈表示)。
我們的 FSM 圖,初始節點附加到巡邏狀態

為了讓我們的生活更輕鬆,我們將添加到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並覆蓋BaseStateMachineInitExecute方法。 覆蓋InitCurrentState設置為圖的初始狀態,覆蓋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 編輯器的項目窗口中:

  1. 打開 SampleScene 資產。

  2. 在 Unity 層次結構窗口中找到我們的Enemy遊戲對象。

  3. BaseStateMachine組件替換為BaseStateMachineGraph組件:

    1. 單擊添加組件並選擇正確的BaseStateMachineGraph腳本。

    2. 將我們的 FSM 圖EnemyGraph分配給BaseStateMachineGraph組件的Graph字段。

    3. 通過右鍵單擊並選擇Remove Component來刪除BaseStateMachine組件(因為它不再需要)。

Enemy遊戲對象應如下所示:

從上到下,在 Inspector 屏幕中,Enemy 旁邊有一個檢查。在標籤下拉列表中選擇“玩家”,在層下拉列表中選擇“敵人”。變換下拉菜單顯示位置、旋轉和縮放。 Capsule 下拉菜單被壓縮,Mesh Renderer、Capsule Collider 和 Nav Mesh Agent 下拉菜單顯示為壓縮狀態,並在其左側打勾。 Enemy Sight Sensor 下拉菜單顯示 Script and Ignore Mask。 PatrolPoints 下拉菜單顯示腳本和四個 PatrolPoints。 Base State Machine Graph (Script) 下拉列表旁邊有一個複選標記。 Script顯示“BaseStateMachineGraph”,Initial State顯示“None(Base State),Graph顯示“EnemyGraph(FSM Graph)。”最後,Blue Enemy(Material)下拉框被壓縮,下方出現“Add Component”按鈕它。
我們的Enemy遊戲對象

而已! 現在我們有了一個帶有圖形編輯器的模塊化 FSM。 單擊Play按鈕顯示以圖形方式創建的敵人 AI 的工作方式與我們之前創建的ScriptableObject敵人完全一樣。

砥礪前行:優化我們的 FSM

提醒一句:當您為遊戲開發更複雜的 AI 時,狀態和轉換的數量會增加,並且 FSM 會變得混亂且難以閱讀。 圖形編輯器變得類似於一個由多個狀態開始並在多個轉換處終止的線網,反之亦然,這使得 FSM 難以調試。

與之前的教程一樣,我們邀請您編寫自己的代碼,優化您的潛行遊戲,並解決這些問題。 想像一下,對狀態節點進行顏色編碼以指示節點是活動還是非活動,或者調整RemainInStateInitial節點的大小以限制其屏幕空間會有多大幫助。

這種增強不僅僅是裝飾性的。 顏色和尺寸參考將有助於確定調試的地點和時間。 直觀的圖表也更易於評估、分析和理解。 任何後續步驟都取決於您 — 在我們的圖形編輯器的基礎上,您可以進行的開發人員體驗改進沒有限制。

進一步閱讀 Toptal 工程博客:

  • Unity 開發人員最常犯的 10 個錯誤
  • Unity 與 MVC:如何升級您的遊戲開發
  • 在 Unity 中掌握 2D 相機:遊戲開發者教程
  • Toptal 開發人員提供的 Unity 最佳實踐和技巧

Toptal 工程博客對 Goran Lalic 對本文的專業知識和技術評論表示感謝。