تطوير 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 وانتقال شكل عقدة. يُمكِّن اتصال (اتصالات) الإدخال و / أو الإخراج العقدة من الارتباط بأي أو جميع العقد الأخرى في الرسم البياني الخاص بنا.
لنتخيل عقدة بثلاث قيم إدخال: اثنان عشوائي وواحد منطقي. ستخرج العقدة إحدى قيمتي الإدخال من النوع التعسفي ، اعتمادًا على ما إذا كان الإدخال المنطقي صحيحًا أم خطأ.
Branch العقدة
لتحويل 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 كما يلي:
Enemy
هذا هو! الآن لدينا FSM معياري مع محرر رسومي. يُظهر النقر فوق الزر " تشغيل " أن العدو الذي تم إنشاؤه بيانياً يعمل تمامًا مثل عدو ScriptableObject الذي تم إنشاؤه مسبقًا.
المضي قدمًا: تحسين ولايات ميكرونيزيا الموحدة
كلمة تحذير: كلما طورت ذكاءً اصطناعيًا أكثر تعقيدًا للعبتك ، يزداد عدد الحالات والانتقالات ، وتصبح FSM مربكة ويصعب قراءتها. ينمو المحرر الرسومي ليشبه شبكة من الخطوط التي تنشأ في حالات متعددة وتنتهي عند انتقالات متعددة - والعكس صحيح ، مما يجعل من الصعب تصحيح أخطاء FSM.
كما في البرنامج التعليمي السابق ، ندعوك لجعل الكود خاصًا بك ، وتحسين لعبتك المتخفية ، ومعالجة هذه المخاوف. تخيل مدى فائدة ترميز عقد الولاية الخاصة بك بالألوان للإشارة إلى ما إذا كانت العقدة نشطة أم غير نشطة ، أو تغيير حجم العقد RemainInState والعقد Initial للحد من مساحة الشاشة الخاصة بها.
هذه التحسينات ليست تجميلية فقط. تساعد مراجع اللون والحجم في تحديد مكان ووقت التصحيح. الرسم البياني الذي يسهل على العين هو أيضًا أبسط في التقييم والتحليل والفهم. أي خطوات تالية متروكة لك - مع وجود أساس محرر الرسوم لدينا ، لا يوجد حد لتحسينات تجربة المطور التي يمكنك إجراؤها.
مزيد من القراءة على مدونة Toptal Engineering:
- الأخطاء العشرة الأكثر شيوعًا التي يرتكبها مطورو الوحدة
- الوحدة مع MVC: كيفية رفع مستوى تطوير لعبتك
- إتقان الكاميرات ثنائية الأبعاد في الوحدة: برنامج تعليمي لمطوري الألعاب
- أفضل ممارسات Unity والنصائح المقدمة من Toptal Developers
تعرب مدونة Toptal Engineering عن امتنانها لـ Goran Lalic لخبرته ومراجعته الفنية لهذه المقالة.
