Développement Unity AI : un didacticiel FSM graphique basé sur xNode

Publié: 2022-08-12

Dans « Unity AI Development : A Finite-state Machine Tutorial », nous avons créé un jeu furtif simple, une IA modulaire basée sur FSM. Dans le jeu, un agent ennemi patrouille dans l'espace de jeu. Lorsqu'il repère le joueur, l'ennemi change d'état et suit le joueur au lieu de patrouiller.

Dans cette deuxième étape de notre parcours Unity, nous allons créer une interface utilisateur graphique (GUI) pour créer plus rapidement les composants de base de notre machine à états finis (FSM) et avec une expérience de développement Unity améliorée.

Un rappel rapide

Le FSM détaillé dans le tutoriel précédent a été construit à partir de blocs architecturaux sous forme de scripts C#. Nous avons ajouté des actions et des décisions ScriptableObject personnalisées en tant que classes. L'approche ScriptableObject permis un FSM facilement maintenable et personnalisable. Dans ce didacticiel, nous remplaçons les ScriptableObject s glisser-déposer du FSM par une option graphique.

J'ai également écrit un script mis à jour pour ceux d'entre vous qui veulent rendre le jeu plus facile à gagner. Pour l'implémenter, il suffit de remplacer le script de détection du joueur par celui-ci qui rétrécit le champ de vision de l'ennemi.

Premiers pas avec xNode

Nous allons construire notre éditeur graphique en utilisant xNode, un cadre pour les arbres de comportement basés sur des nœuds qui affichera visuellement le flux de notre FSM. Bien que GraphView d'Unity puisse accomplir le travail, son API est à la fois expérimentale et peu documentée. L'interface utilisateur de xNode offre une expérience de développement supérieure, facilitant le prototypage et l'expansion rapide de notre FSM.

Ajoutons xNode à notre projet en tant que dépendance Git à l'aide du gestionnaire de packages Unity :

  1. Dans Unity, cliquez sur Fenêtre > Gestionnaire de packages pour lancer la fenêtre Gestionnaire de packages.
  2. Cliquez sur + (le signe plus) dans le coin supérieur gauche de la fenêtre et sélectionnez Ajouter un package à partir de l'URL git pour afficher un champ de texte.
  3. Tapez ou collez https://github.com/siccity/xNode.git dans la zone de texte sans étiquette et cliquez sur le bouton Ajouter .

Nous sommes maintenant prêts à approfondir et à comprendre les composants clés de xNode :

Classe de Node Représente un nœud, l'unité la plus fondamentale d'un graphique. Dans ce didacticiel xNode, nous dérivons de la classe Node de nouvelles classes qui déclarent des nœuds dotés de fonctionnalités et de rôles personnalisés.
Classe NodeGraph Représente une collection de nœuds (instances de classe Node ) et les arêtes qui les connectent. Dans ce tutoriel xNode, nous dérivons de NodeGraph une nouvelle classe qui manipule et évalue les nœuds.
Classe NodePort Représente une porte de communication, un port de type input ou de type output, situé entre des instances Node dans un NodeGraph . La classe NodePort est unique à xNode.
Attribut [Input] L'ajout de l'attribut [Input] à un port le désigne comme une entrée, permettant au port de transmettre des valeurs au nœud dont il fait partie. Considérez l'attribut [Input] comme un paramètre de fonction.
Attribut [Output] L'ajout de l'attribut [Output] à un port le désigne comme une sortie, permettant au port de transmettre des valeurs du nœud dont il fait partie. Considérez l'attribut [Output] comme la valeur de retour d'une fonction.

Visualisation de l'environnement de construction xNode

Dans xNode, nous travaillons avec des graphes où chaque State et Transition prend la forme d'un nœud. Les connexions d'entrée et/ou de sortie permettent au nœud d'être lié à tout ou partie des autres nœuds de notre graphe.

Imaginons un nœud avec trois valeurs d'entrée : deux arbitraires et une booléenne. Le nœud produira l'une des deux valeurs d'entrée de type arbitraire, selon que l'entrée booléenne est vraie ou fausse.

Le nœud Branche, représenté par un grand rectangle au centre, inclut le pseudocode "If C == True A Else B." Sur la gauche se trouvent trois rectangles, chacun ayant une flèche pointant vers le nœud Branche : "A (arbitraire)", "B (arbitraire)" et "C (booléen)". Le nœud Branche, enfin, a une flèche qui pointe vers un rectangle "Sortie".
Un exemple de nœud Branch

Pour convertir notre FSM existant en graphe, nous modifions les classes State et Transition pour hériter de la classe Node au lieu de la classe ScriptableObject . Nous créons un objet graphique de type NodeGraph pour contenir tous nos objets State et Transition .

Modification de BaseStateMachine à utiliser comme type de base

Commencez à créer l'interface graphique en ajoutant deux nouvelles méthodes virtuelles à notre classe BaseStateMachine existante :

Init Attribue l'état initial à la propriété CurrentState
Execute Exécute l'état actuel

Déclarer ces méthodes comme virtual nous permet de les remplacer, afin que nous puissions définir les comportements personnalisés des classes héritant de la classe BaseStateMachine pour l'initialisation et l'exécution :

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

Ensuite, sous notre dossier FSM , créons :

FSMGraph Un fichier
BaseStateMachineGraph Classe AC# dans FSMGraph

Pour le moment, BaseStateMachineGraph héritera uniquement de la classe BaseStateMachine :

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }

Nous ne pouvons pas ajouter de fonctionnalité à BaseStateMachineGraph tant que nous n'avons pas créé notre type de nœud de base ; faisons cela ensuite.

Implémentation NodeGraph et création d'un type de nœud de base

Dans notre dossier FSMGraph nouvellement créé, nous allons créer :

FSMGraph Une classe

Pour l'instant, FSMGraph héritera uniquement de la classe NodeGraph (sans fonctionnalité supplémentaire) :

 using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }

Avant de créer des classes pour nos nœuds, ajoutons :

FSMNodeBase Une classe à utiliser comme classe de base par tous nos nœuds

La classe FSMNodeBase contiendra une entrée nommée Entry de type FSMNodeBase pour nous permettre de connecter les nœuds les uns aux autres.

Nous ajouterons également deux fonctions d'assistance :

GetFirst Récupère le premier nœud connecté à la sortie demandée
GetAllOnPort Récupère tous les nœuds restants qui se connectent à la sortie demandée
 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; } } }

En fin de compte, nous aurons deux types de nœuds d'état ; ajoutons une classe pour les supporter :

BaseStateNode Une classe de base pour prendre en charge à la fois StateNode et RemainInStateNode
 namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }

Modifiez ensuite la classe BaseStateMachineGraph :

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }

Ici, nous avons masqué la propriété CurrentState héritée de la classe de base et changé son type de BaseState en BaseStateNode .

Création de blocs de construction pour notre graphique FSM

Ensuite, pour former les principaux blocs de construction de notre FSM, ajoutons trois nouvelles classes à notre dossier FSMGraph :

StateNode Représente l'état d'un agent. Lors de l'exécution, StateNode itère sur les TransitionNode s connectés au port de sortie du StateNode (récupéré par une méthode d'assistance). StateNode demande à chacun s'il faut faire passer le nœud à un état différent ou laisser l'état du nœud tel quel.
RemainInStateNode Indique qu'un nœud doit rester dans l'état actuel.
TransitionNode Prend la décision de passer à un état différent ou de rester dans le même état.

Dans le didacticiel Unity FSM précédent, la classe State itère sur la liste des transitions. Ici, dans xNode, StateNode sert d'équivalent de State pour itérer sur les nœuds récupérés via notre méthode d'assistance GetAllOnPort .

Ajoutez maintenant un attribut [Output] aux connexions sortantes (les nœuds de transition) pour indiquer qu'elles doivent faire partie de l'interface graphique. De par la conception de xNode, la valeur de l'attribut provient du nœud source : le nœud contenant le champ marqué avec l'attribut [Output] . Comme nous utilisons les attributs [Output] et [Input] pour décrire les relations et les connexions qui seront définies par l'interface graphique xNode, nous ne pouvons pas traiter ces valeurs comme nous le ferions normalement. Considérez comment nous itérons à travers les Actions par rapport aux 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); } } }

Dans ce cas, la sortie Transitions peut avoir plusieurs nœuds qui lui sont attachés ; nous devons appeler la méthode d'assistance GetAllOnPort pour obtenir une liste des connexions [Output] .

RemainInStateNode est, de loin, notre classe la plus simple. N'exécutant aucune logique, RemainInStateNode indique simplement à notre agent - dans le cas de notre jeu, l'ennemi - de rester dans son état actuel :

 namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }

À ce stade, la classe TransitionNode est encore incomplète et ne sera pas compilée. Les erreurs associées disparaîtront une fois que nous aurons mis à jour la classe.

Pour construire TransitionNode , nous devons contourner l'exigence de xNode selon laquelle la valeur de la sortie provient du nœud source, comme nous l'avons fait lorsque nous avons construit StateNode . Une différence majeure entre StateNode et TransitionNode est que la sortie de TransitionNode peut être attachée à un seul nœud. Dans notre cas, GetFirst récupérera le nœud attaché à chacun de nos ports (un nœud d'état vers lequel passer dans le vrai cas et un autre vers lequel passer dans le faux cas) :

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

Examinons les résultats graphiques de notre code.

Création du graphique visuel

Une fois toutes les classes FSM triées, nous pouvons procéder à la création de notre graphique FSM pour l'agent ennemi du jeu. Dans la fenêtre du projet Unity, faites un clic droit sur le dossier EnemyAI et choisissez : Create > FSM > FSM Graph . Pour rendre notre graphique plus facile à identifier, renommez-le EnemyGraph .

Dans la fenêtre de l'éditeur xNode Graph, cliquez avec le bouton droit pour afficher un menu déroulant répertoriant State , Transition et RemainInState . Si la fenêtre n'est pas visible, double-cliquez sur le fichier EnemyGraph pour lancer la fenêtre de l'éditeur xNode Graph.

  1. Pour créer les états Chase et Patrol :

    1. Cliquez avec le bouton droit et choisissez État pour créer un nouveau nœud.

    2. Nommez le nœud Chase .

    3. Revenez au menu déroulant, choisissez à nouveau État pour créer un deuxième nœud.

    4. Nommez le nœud Patrol .

    5. Faites glisser et déposez les actions Chase et Patrol existantes dans leurs états correspondants nouvellement créés.

  2. Pour créer la transition :

    1. Cliquez avec le bouton droit et choisissez Transition pour créer un nouveau nœud.

    2. Attribuez l'objet LineOfSightDecision au champ Decision de la transition.

  3. Pour créer le nœud RemainInState :

    1. Cliquez avec le bouton droit et choisissez RemainInState pour créer un nouveau nœud.
  4. Pour connecter le graphique :

    1. Connectez la sortie Transitions du nœud Patrol à l'entrée Entry du nœud Transition .

    2. Connectez la sortie True State du nœud Transition à l'entrée Entry du nœud Chase .

    3. Connectez la sortie False State du nœud Transition à l'entrée Entry du nœud Remain In State .

Le graphique devrait ressembler à ceci :

Quatre nœuds représentés par quatre rectangles, chacun avec des cercles d'entrée d'entrée sur leur côté supérieur gauche. De gauche à droite, le nœud État de patrouille affiche une action : Action de patrouille. Le nœud d'état Patrol comprend également un cercle de sortie Transitions sur son côté inférieur droit qui se connecte au cercle d'entrée du nœud Transition. Le nœud Transition affiche une décision : LineOfSight. Il a deux cercles de sortie sur son côté inférieur droit, True State et False State. True State se connecte au cercle d'entrée de notre troisième structure, le nœud d'état Chase. Le nœud d'état Chase affiche une action : Chase Action. Le nœud d'état Chase a un cercle de sortie Transitions. Le deuxième des deux cercles de sortie de Transition, False State, se connecte au cercle Entry de notre quatrième et dernière structure, le nœud RemainInState (qui apparaît sous le nœud Chase state).
Premier regard sur notre graphique FSM

Rien dans le graphique n'indique quel nœud - l'état Patrol ou Chase - est notre nœud initial. La classe BaseStateMachineGraph détecte quatre nœuds mais, en l'absence d'indicateurs, ne peut pas choisir l'état initial.

Pour résoudre ce problème, créons :

FSMInitialNode Une classe dont la sortie unique de type StateNode est nommée InitialNode

Notre sortie InitialNode indique l'état initial. Ensuite, dans FSMInitialNode , créez :

NextNode Une propriété pour nous permettre de récupérer le nœud connecté à la sortie 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; } } } }

Maintenant que nous avons créé la classe FSMInitialNode , nous pouvons la connecter à l' Entry de l'état initial et renvoyer l'état initial via la propriété NextNode .

Revenons à notre graphique et ajoutons le nœud initial. Dans la fenêtre de l'éditeur xNode :

  1. Cliquez avec le bouton droit et choisissez Nœud initial pour créer un nouveau nœud.
  2. Attachez la sortie du nœud FSM à l'entrée Entry du nœud Patrol .

Le graphique devrait maintenant ressembler à ceci :

Le même graphique que dans notre image précédente, avec un rectangle vert FSM Node ajouté à gauche des quatre autres rectangles. Il a une sortie de nœud initial (représentée par un cercle bleu) qui se connecte à l'entrée "Entrée" du nœud de patrouille (représentée par un cercle rouge foncé).
Notre graphique FSM avec le nœud initial attaché à l'état de patrouille

Pour nous faciliter la vie, nous allons ajouter à FSMGraph :

InitialState Une propriété

La première fois que nous essayons de récupérer la valeur de la propriété InitialState , le getter de la propriété traversera tous les nœuds de notre graphique en essayant de trouver FSMInitialNode . Une fois que FSMInitialNode est localisé, nous utilisons la propriété NextNode pour trouver notre nœud d'état initial :

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

Ensuite, dans notre BaseStateMachineGraph , référençons FSMGraph et remplaçons les méthodes Init et Execute de notre BaseStateMachine . Le remplacement de Init définit CurrentState comme l'état initial du graphe, et le remplacement d' Execute appelle Execute on 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); } } }

Maintenant, appliquons le graphique à l'objet Enemy et voyons-le en action.

Test du graphe FSM

En préparation des tests, dans la fenêtre Projet de l'éditeur Unity :

  1. Ouvrez la ressource SampleScene.

  2. Localisez notre objet de jeu Enemy dans la fenêtre de hiérarchie Unity.

  3. Remplacez le composant BaseStateMachine par le composant BaseStateMachineGraph :

    1. Cliquez sur Ajouter un composant et sélectionnez le bon script BaseStateMachineGraph .

    2. Affectez notre graphe FSM, EnemyGraph , au champ Graph du composant BaseStateMachineGraph .

    3. Supprimez le composant BaseStateMachine (car il n'est plus nécessaire) en cliquant avec le bouton droit de la souris et en sélectionnant Supprimer le composant .

L'objet de jeu Enemy devrait ressembler à ceci :

De haut en bas, dans l'écran Inspecteur, il y a une coche à côté d'Ennemi. "Joueur" est sélectionné dans la liste déroulante Tag, "Enemy" est sélectionné dans la liste déroulante Layer. La liste déroulante Transformer affiche la position, la rotation et l'échelle. Le menu déroulant Capsule est compressé et les listes déroulantes Mesh Renderer, Capsule Collider et Nav Mesh Agent apparaissent compressées avec une coche à leur gauche. La liste déroulante Enemy Sight Sensor affiche le script et le masque d'ignorance. La liste déroulante PatrolPoints affiche le script et quatre PatrolPoints. Il y a une coche à côté de la liste déroulante Base State Machine Graph (Script). Le script affiche "BaseStateMachineGraph", l'état initial affiche "None (Base State) et le graphique affiche "EnemyGraph (FSM Graph)." Enfin, la liste déroulante Blue Enemy (Material) est compressée et un bouton "Add Component" apparaît en dessous ce.
Notre objet de jeu Enemy

C'est ça! Nous avons maintenant un FSM modulaire avec un éditeur graphique. Cliquer sur le bouton Play montre que l'IA ennemie créée graphiquement fonctionne exactement comme notre ennemi ScriptableObject précédemment créé.

Aller de l'avant : optimiser notre FSM

Un mot d'avertissement : au fur et à mesure que vous développez une IA plus sophistiquée pour votre jeu, le nombre d'états et de transitions augmente, et le FSM devient déroutant et difficile à lire. L'éditeur graphique se développe pour ressembler à un réseau de lignes qui proviennent de plusieurs états et se terminent à plusieurs transitions, et vice versa, ce qui rend le FSM difficile à déboguer.

Comme dans le tutoriel précédent, nous vous invitons à vous approprier le code, à optimiser votre jeu furtif, et à répondre à ces soucis. Imaginez à quel point il serait utile de coder en couleur vos nœuds d'état pour indiquer si un nœud est actif ou inactif, ou de redimensionner les nœuds RemainInState et Initial pour limiter leur écran.

De telles améliorations ne sont pas simplement cosmétiques. Les références de couleur et de taille aideraient à identifier où et quand déboguer. Un graphique agréable à l'œil est également plus simple à évaluer, à analyser et à comprendre. Toutes les étapes suivantes dépendent de vous. Avec la base de notre éditeur graphique en place, il n'y a pas de limite aux améliorations que vous pouvez apporter à l'expérience des développeurs.

Lectures complémentaires sur le blog Toptal Engineering :

  • Les 10 erreurs les plus courantes commises par les développeurs Unity
  • Unity avec MVC : comment améliorer le développement de votre jeu
  • Maîtriser les caméras 2D dans Unity : un tutoriel pour les développeurs de jeux
  • Meilleures pratiques et astuces Unity par les développeurs Toptal

Le blog Toptal Engineering exprime sa gratitude à Goran Lalic pour son expertise et sa révision technique de cet article.