تطوير Unity AI: برنامج تعليمي رسومية FSM قائم على xNode
نشرت: 2022-08-12في "Unity AI Development: A Finite-State Machine Tutorial" ، أنشأنا لعبة تخفي بسيطة - ذكاء اصطناعي معياري قائم على FSM. في اللعبة ، يقوم عميل العدو بدوريات في مساحة الألعاب. عندما يكتشف اللاعب ، يغير العدو حالته ويتبع اللاعب بدلاً من الدوريات.
في هذه المرحلة الثانية من رحلة الوحدة الخاصة بنا ، سنقوم ببناء واجهة مستخدم رسومية (GUI) لإنشاء المكونات الأساسية لآلة الحالة المحدودة (FSM) لدينا بسرعة أكبر ، مع تجربة مطور Unity محسّنة.
تحديث سريع
تم بناء FSM المفصل في البرنامج التعليمي السابق من كتل معمارية مثل نصوص C #. أضفنا إجراءات وقرارات ScriptableObject
المخصصة كفئة. أتاح نهج ScriptableObject
إمكانية صيانة FSM بسهولة وتخصيصها. في هذا البرنامج التعليمي ، نستبدل ScriptableObjects ScriptableObject
الخاصة بالسحب والإفلات في FSM بخيار رسومي.
لقد كتبت أيضًا نصًا محدثًا لأولئك الذين يريدون جعل اللعبة أسهل للفوز. للتنفيذ ، ما عليك سوى استبدال النص البرمجي لاكتشاف اللاعب بهذا الذي يضيق مجال رؤية العدو.
الشروع في العمل مع xNode
سنقوم ببناء محرر رسومي باستخدام xNode ، وهو إطار عمل لأشجار السلوك القائمة على العقد والتي ستعرض بصريًا تدفق FSM لدينا. على الرغم من أن GraphView في Unity يمكنها إنجاز المهمة ، إلا أن واجهة برمجة التطبيقات الخاصة بها تجريبية وموثقة بشكل ضئيل. توفر واجهة مستخدم xNode تجربة مطور فائقة ، مما يسهل عملية إنشاء النماذج الأولية والتوسع السريع في FSM لدينا.
دعنا نضيف xNode إلى مشروعنا كتبعية Git باستخدام Unity Package Manager:
- في الوحدة ، انقر فوق Window> Package Manager لتشغيل نافذة مدير الحزم.
- انقر فوق + (علامة الجمع) في الزاوية العلوية اليسرى من النافذة وحدد إضافة حزمة من git URL لعرض حقل نصي.
- اكتب أو الصق
https://github.com/siccity/xNode.git
في مربع النص غير المسماة وانقر فوق الزر " إضافة ".
نحن الآن جاهزون للغوص بعمق وفهم المكونات الرئيسية لـ xNode:
فئة Node | يمثل العقدة ، الوحدة الأساسية في الرسم البياني. في هذا البرنامج التعليمي xNode ، نستمد من فئات Node class الجديدة التي تعلن أن العقد مجهزة بوظائف وأدوار مخصصة. |
فئة NodeGraph | يمثل مجموعة من العقد (مثيلات فئة Node ) والحواف التي تربطهم. في هذا البرنامج التعليمي xNode ، نستمد من NodeGraph فئة جديدة تعالج العقد وتقيّمها. |
فئة NodePort | يمثل بوابة اتصال ، منفذ من نوع الإدخال أو نوع الإخراج ، يقع بين مثيلات Node في NodeGraph . تعتبر فئة NodePort فريدة بالنسبة إلى xNode. |
سمة [Input] | إضافة السمة [Input] إلى المنفذ تعينه كمدخل ، مما يتيح للمنفذ تمرير القيم إلى العقدة التي هو جزء منها. فكر في السمة [Input] كمعامل دالة. |
سمة [Output] | إن إضافة السمة [Output] إلى المنفذ تعينه كإخراج ، مما يتيح للمنفذ تمرير القيم من العقدة التي هو جزء منها. فكر في السمة [Output] على أنها القيمة المرجعة للدالة. |
تصور بيئة بناء xNode
في Transition
، نعمل مع الرسوم البيانية حيث تأخذ كل State
وانتقال شكل عقدة. يُمكِّن اتصال (اتصالات) الإدخال و / أو الإخراج العقدة من الارتباط بأي أو جميع العقد الأخرى في الرسم البياني الخاص بنا.
لنتخيل عقدة بثلاث قيم إدخال: اثنان عشوائي وواحد منطقي. ستخرج العقدة إحدى قيمتي الإدخال من النوع التعسفي ، اعتمادًا على ما إذا كان الإدخال المنطقي صحيحًا أم خطأ.
لتحويل FSM الحالي إلى رسم بياني ، نقوم بتعديل فئتي State
و Transition
لتورث فئة Node
بدلاً من فئة ScriptableObject
. نقوم بإنشاء كائن رسم بياني من النوع NodeGraph
لاحتواء جميع كائنات State
Transition
الخاصة بنا.
تعديل BaseStateMachine
لاستخدامه كنوع أساسي
ابدأ في بناء الواجهة الرسومية عن طريق إضافة طريقتين افتراضيتين جديدتين إلى فئة BaseStateMachine
الموجودة لدينا:
Init | يعيّن الحالة الأولية لخاصية CurrentState |
Execute | ينفذ الحالة الحالية |
يتيح لنا إعلان هذه الأساليب كطرق افتراضية تجاوزها ، حتى نتمكن من تحديد السلوكيات المخصصة للفئات التي ترث فئة 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 | فئة AC # داخل FSMGraph |
في الوقت الحالي ، سيرث 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 عبر TransitionNode s المتصلة بمنفذ الإخراج الخاص بـ StateNode (تم استرداده بواسطة طريقة مساعدة). يستعلم StateNode كل واحد عما إذا كان سيتم نقل العقدة إلى حالة مختلفة أو ترك حالة العقدة كما هي. |
RemainInStateNode | يشير إلى أن العقدة يجب أن تظل في الحالة الحالية. |
TransitionNode | يتخذ قرار الانتقال إلى حالة مختلفة أو البقاء في نفس الحالة. |
في البرنامج التعليمي السابق للوحدة FSM ، يتكرر فصل State
على قائمة الانتقالات. هنا في xNode ، تعمل StateNode
State
للتكرار عبر العقد التي تم استردادها عبر طريقة مساعد GetAllOnPort
.
أضف الآن سمة [Output]
إلى الاتصالات الصادرة (عقد الانتقال) للإشارة إلى أنها يجب أن تكون جزءًا من واجهة المستخدم الرسومية. من خلال تصميم 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
بإحضار العقدة المرفقة بكل من منافذنا (عقدة حالة واحدة للانتقال إليها في الحالة الحقيقية وأخرى للانتقال إليها في الحالة الخاطئة):
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 الخاص بنا لعامل العدو في اللعبة. في نافذة مشروع الوحدة ، انقر بزر الماوس الأيمن فوق مجلد EnemyAI
واختر: إنشاء> FSM> رسم بياني FSM . لتسهيل التعرف على الرسم البياني ، دعنا نعيد تسميته EnemyGraph
.
في نافذة محرر xNode Graph ، انقر بزر الماوس الأيمن للكشف عن قائمة منسدلة تسرد الحالة والانتقال و RemainInState . إذا كانت النافذة غير مرئية ، فانقر نقرًا مزدوجًا فوق ملف EnemyGraph
لبدء تشغيل نافذة محرر xNode Graph.
لإنشاء دولتي
Chase
وPatrol
:انقر بزر الماوس الأيمن واختر الحالة لإنشاء عقدة جديدة.
اسم العقدة
Chase
.ارجع إلى القائمة المنسدلة ، اختر الحالة مرة أخرى لإنشاء عقدة ثانية.
اسم العقدة
Patrol
.قم بسحب وإسقاط إجراءات
Chase
وPatrol
الحالية إلى الحالات المطابقة التي تم إنشاؤها حديثًا.
لإنشاء الانتقال:
انقر بزر الماوس الأيمن واختر انتقال لإنشاء عقدة جديدة.
قم بتعيين كائن
LineOfSightDecision
إلى حقلDecision
الخاص بالانتقال.
لإنشاء عقدة
RemainInState
:- انقر بزر الماوس الأيمن واختر RemainInState لإنشاء عقدة جديدة.
لتوصيل الرسم البياني:
قم بتوصيل إخراج
Transitions
عقدةPatrol
بإدخالEntry
العقدةTransition
.قم بتوصيل إخراج
True State
الخاص بالعقدةTransition
بإدخالEntry
عقدةChase
.قم بتوصيل إخراج
False State
الخاص بالعقدةTransition
بإدخالEntry
عقدةRemain In State
.
يجب أن يبدو الرسم البياني كما يلي:
لا شيء في الرسم البياني يشير إلى العقدة - حالة Patrol
أو Chase
- هي العقدة الأولية. تكتشف فئة BaseStateMachineGraph
أربع عقد ولكن ، مع عدم وجود مؤشرات ، لا يمكنها اختيار الحالة الأولية.
لحل هذه المشكلة ، دعنا ننشئ:
FSMInitialNode | فئة يتم تسمية إخراجها الفردي من النوع StateNode باسم InitialNode |
يشير الناتج الأولي 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 بإدخال
Entry
عقدةPatrol
.
يجب أن يبدو الرسم البياني الآن كما يلي:
لجعل حياتنا أسهل ، سنضيف إلى FSMGraph
:
InitialState | ملكية |
في المرة الأولى التي نحاول فيها استرداد قيمة خاصية InitialState
، سوف يجتاز محصل الخاصية جميع العقد في الرسم البياني الخاص بنا أثناء محاولته العثور على 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
أساليب Init
Execute
في BaseStateMachine
. يؤدي تجاوز Init
إلى تعيين CurrentState
كحالة أولية للرسم البياني ، وتجاوز Execute
الاستدعاءات Execute
في CurrentState
:
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); } } }
الآن ، دعنا نطبق الرسم البياني على كائن العدو ، ونراه عمليًا.
اختبار الرسم البياني FSM
استعدادًا للاختبار ، في نافذة مشروع Unity Editor's Project:
افتح الأصل SampleScene.
حدد موقع كائن لعبة
Enemy
في نافذة التسلسل الهرمي للوحدة.استبدل مكون
BaseStateMachine
بمكونBaseStateMachineGraph
:انقر فوق إضافة مكون وحدد البرنامج النصي
BaseStateMachineGraph
الصحيح.قم بتعيين رسم FSM الخاص بنا ،
EnemyGraph
، إلى حقلGraph
لمكونBaseStateMachineGraph
.احذف مكون
BaseStateMachine
(حيث لم تعد هناك حاجة إليه) بالنقر بزر الماوس الأيمن وتحديد إزالة المكون .
يجب أن يبدو كائن لعبة Enemy
كما يلي:
هذا هو! الآن لدينا FSM معياري مع محرر رسومي. يُظهر النقر فوق الزر " تشغيل " أن العدو الذي تم إنشاؤه بيانياً يعمل تمامًا مثل عدو ScriptableObject
الذي تم إنشاؤه مسبقًا.
المضي قدمًا: تحسين ولايات ميكرونيزيا الموحدة
كلمة تحذير: كلما طورت ذكاءً اصطناعيًا أكثر تعقيدًا للعبتك ، يزداد عدد الحالات والانتقالات ، وتصبح FSM مربكة ويصعب قراءتها. ينمو المحرر الرسومي ليشبه شبكة من الخطوط التي تنشأ في حالات متعددة وتنتهي عند انتقالات متعددة - والعكس صحيح ، مما يجعل من الصعب تصحيح أخطاء FSM.
كما في البرنامج التعليمي السابق ، ندعوك لجعل الكود خاصًا بك ، وتحسين لعبتك المتخفية ، ومعالجة هذه المخاوف. تخيل مدى فائدة ترميز عقد الولاية الخاصة بك بالألوان للإشارة إلى ما إذا كانت العقدة نشطة أم غير نشطة ، أو تغيير حجم العقد RemainInState
والعقد Initial
للحد من مساحة الشاشة الخاصة بها.
هذه التحسينات ليست تجميلية فقط. تساعد مراجع اللون والحجم في تحديد مكان ووقت التصحيح. الرسم البياني الذي يسهل على العين هو أيضًا أبسط في التقييم والتحليل والفهم. أي خطوات تالية متروكة لك - مع وجود أساس محرر الرسوم لدينا ، لا يوجد حد لتحسينات تجربة المطور التي يمكنك إجراؤها.
مزيد من القراءة على مدونة Toptal Engineering:
- الأخطاء العشرة الأكثر شيوعًا التي يرتكبها مطورو الوحدة
- الوحدة مع MVC: كيفية رفع مستوى تطوير لعبتك
- إتقان الكاميرات ثنائية الأبعاد في الوحدة: برنامج تعليمي لمطوري الألعاب
- أفضل ممارسات Unity والنصائح المقدمة من Toptal Developers
تعرب مدونة Toptal Engineering عن امتنانها لـ Goran Lalic لخبرته ومراجعته الفنية لهذه المقالة.