Développement Unity AI : un didacticiel FSM graphique basé sur xNode
Publié: 2022-08-12Dans « 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 :
- Dans Unity, cliquez sur Fenêtre > Gestionnaire de packages pour lancer la fenêtre Gestionnaire de packages.
- 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.
- 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.
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.
Pour créer les états
Chase
etPatrol
:Cliquez avec le bouton droit et choisissez État pour créer un nouveau nœud.
Nommez le nœud
Chase
.Revenez au menu déroulant, choisissez à nouveau État pour créer un deuxième nœud.
Nommez le nœud
Patrol
.Faites glisser et déposez les actions
Chase
etPatrol
existantes dans leurs états correspondants nouvellement créés.
Pour créer la transition :
Cliquez avec le bouton droit et choisissez Transition pour créer un nouveau nœud.
Attribuez l'objet
LineOfSightDecision
au champDecision
de la transition.
Pour créer le nœud
RemainInState
:- Cliquez avec le bouton droit et choisissez RemainInState pour créer un nouveau nœud.
Pour connecter le graphique :
Connectez la sortie
Transitions
du nœudPatrol
à l'entréeEntry
du nœudTransition
.Connectez la sortie
True State
du nœudTransition
à l'entréeEntry
du nœudChase
.Connectez la sortie
False State
du nœudTransition
à l'entréeEntry
du nœudRemain In State
.
Le graphique devrait ressembler à ceci :
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 :
- Cliquez avec le bouton droit et choisissez Nœud initial pour créer un nouveau nœud.
- Attachez la sortie du nœud FSM à l'entrée
Entry
du nœudPatrol
.
Le graphique devrait maintenant ressembler à ceci :
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 :
Ouvrez la ressource SampleScene.
Localisez notre objet de jeu
Enemy
dans la fenêtre de hiérarchie Unity.Remplacez le composant
BaseStateMachine
par le composantBaseStateMachineGraph
:Cliquez sur Ajouter un composant et sélectionnez le bon script
BaseStateMachineGraph
.Affectez notre graphe FSM,
EnemyGraph
, au champGraph
du composantBaseStateMachineGraph
.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 :
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.