Développement Unity AI : un didacticiel sur les machines à états finis

Publié: 2022-03-11

Dans le monde compétitif du jeu, les développeurs s'efforcent d'offrir une expérience utilisateur divertissante à ceux qui interagissent avec les personnages non-joueurs (PNJ) que nous créons. Les développeurs peuvent offrir cette interactivité en utilisant des machines à états finis (FSM) pour créer des solutions d'IA qui simulent l'intelligence dans nos PNJ.

Les tendances de l'IA se sont déplacées vers les arbres comportementaux, mais les FSM restent pertinents. Ils sont incorporés, à un titre ou à un autre, dans pratiquement tous les jeux électroniques.

Anatomie d'un FSM

Un FSM est un modèle de calcul dans lequel un seul parmi un nombre fini d'états hypothétiques peut être actif à la fois. Un FSM passe d'un état à un autre, répondant à des conditions ou à des entrées. Ses principaux composants comprennent :

Composant La description
État L'une d'un ensemble fini d'options indiquant l'état général actuel d'un FSM ; tout état donné comprend un ensemble d'actions associées
action Que fait un état lorsque le FSM l'interroge
Décision La logique établissant quand une transition a lieu
Transition Le processus de changement d'état

Bien que nous nous concentrions sur les FSM du point de vue de la mise en œuvre de l'IA, des concepts tels que les machines à états d'animation et les états généraux du jeu relèvent également du FSM.

Visualiser un FSM

Prenons l'exemple du jeu d'arcade classique Pac-Man. Dans l'état initial du jeu (l'état "poursuite"), les PNJ sont des fantômes colorés qui poursuivent et finissent par dépasser le joueur. Les fantômes passent à l'état d'évasion chaque fois que le joueur mange une pastille de puissance et expérimente une mise sous tension, acquérant la capacité de manger les fantômes. Les fantômes, maintenant de couleur bleue, échappent au joueur jusqu'à ce que la mise sous tension expire et que les fantômes reviennent à l'état de poursuite, dans lequel leurs comportements et couleurs d'origine sont restaurés.

Un fantôme Pac-Man est toujours dans l'un des deux états suivants : chasser ou esquiver. Naturellement, nous devons fournir deux transitions, l'une de la poursuite à l'évasion, l'autre de l'évasion à la poursuite :

Diagramme : à gauche, l'état de poursuite. Une flèche (indiquant que le joueur a mangé la pastille énergétique) mène à l'état d'évasion à droite. Une deuxième flèche (indiquant que la pastille de puissance a expiré) ramène à l'état de poursuite à gauche.
Transitions entre les états fantômes de Pac-Man

La machine à états finis, de par sa conception, interroge l'état actuel, qui interroge la ou les décisions et actions de cet état. Le diagramme suivant représente notre exemple Pac-Man et montre une décision qui vérifie l'état de la mise sous tension du joueur. Si une mise sous tension a commencé, les PNJ passent de la poursuite à l'évasion. Si une mise sous tension est terminée, les PNJ passent de l'évasion à la poursuite. Enfin, s'il n'y a pas de changement de mise sous tension, aucune transition ne se produit.

Diagramme en forme de losange représentant un cycle : Commençant à gauche, il y a un état de poursuite impliquant une action correspondante. L'état de poursuite pointe alors vers le haut, où il y a une décision : si le joueur a mangé une pastille de puissance, nous continuons vers l'état d'évasion et évitons l'action à droite. L'état d'évasion indique une décision en bas : si la pastille de puissance a expiré, nous revenons à notre point de départ.
Composants du FSM Pac-Man Ghost

Évolutivité

Les FSM nous libèrent pour construire une IA modulaire. Par exemple, avec une seule nouvelle action, nous pouvons créer un PNJ avec un nouveau comportement. Ainsi, nous pouvons attribuer une nouvelle action - la consommation d'une pastille de puissance - à l'un de nos fantômes Pac-Man, lui donnant la capacité de manger des pastilles de puissance tout en évitant le joueur. Nous pouvons réutiliser les actions, décisions et transitions existantes pour prendre en charge ce comportement.

Étant donné que les ressources nécessaires pour développer un PNJ unique sont minimes, nous sommes bien placés pour répondre aux exigences de projet en constante évolution de plusieurs PNJ uniques. D'un autre côté, un nombre excessif d'états et de transitions peut nous emmêler dans une machine à états spaghetti - un FSM dont la surabondance de connexions rend difficile le débogage et la maintenance.

Implémentation d'un FSM dans Unity

Pour montrer comment implémenter une machine à états finis dans Unity, créons un jeu furtif simple. Notre architecture incorporera des ScriptableObject , qui sont des conteneurs de données qui peuvent stocker et partager des informations dans toute l'application, de sorte que nous n'avons pas besoin de les reproduire. Les ScriptableObject sont capables d'un traitement limité, comme l'appel d'actions et l'interrogation de décisions. En plus de la documentation officielle de Unity, l'ancienne discussion sur l' architecture de jeu avec des objets scriptables reste une excellente ressource si vous souhaitez approfondir.

Avant d'ajouter l'IA à ce projet initial prêt à compiler, considérons l'architecture proposée :

Schéma : Sept cases qui se connectent les unes aux autres, décrites par ordre d'apparition, de gauche/haut : La case intitulée BaseStateMachine comprend + CurrentState : BaseState. BaseStateMachine se connecte à BaseState avec une flèche bidirectionnelle. La case intitulée BaseState comprend + Execute(BaseStateMachine): void. BaseState se connecte à BaseStateMachine avec une flèche bidirectionnelle. Les flèches unidirectionnelles de State et RemainInState se connectent à BaseState. La zone intitulée État comprend + Execute(BaseStateMachine): void, + Actions: List<Action> et + Transition: List<Transition>. State se connecte à BaseState avec une flèche monodirectionnelle, à Action avec une flèche monodirectionnelle étiquetée "1" et à Transition avec une flèche monodirectionnelle étiquetée "1". La case intitulée RemainInState comprend + Execute(BaseStateMachine): void. RemainInState se connecte à BaseState avec une flèche unidirectionnelle. La case intitulée Action comprend + Execute(BaseStateMachine): void. Une flèche unidirectionnelle étiquetée "1" de State se connecte à Action. La zone intitulée Transition comprend + Decide(BaseStateMachine): void, + TransitionDecision: Decision, + TrueState: BaseState et + FalseState: BaseState. Transition se connecte à Décision avec une flèche unidirectionnelle. Une flèche monodirectionnelle étiquetée "1" de State se connecte à Transition. La case intitulée Decision comprend + Decide(BaseStateMachine): bool.
Architecture FSM proposée

Dans notre exemple de jeu, l'ennemi (un PNJ représenté par une capsule bleue) patrouille. Lorsque l'ennemi voit le joueur (représenté par une capsule grise), l'ennemi commence à suivre le joueur :

Schéma : Cinq cases qui se connectent les unes aux autres, décrites dans l'ordre d'apparition, de gauche à haut : La case intitulée Patrouille se connecte à la case intitulée SI le joueur est en ligne de mire avec une flèche unidirectionnelle, et à la case intitulée Action de patrouille avec une flèche unidirectionnelle intitulée "état". La case étiquetée IF player est dans la ligne de mire, avec une elabel supplémentaire "décision", juste en dessous de la case. La case étiquetée SI le joueur est en ligne de mire se connecte à la case étiquetée Chase avec une flèche unidirectionnelle. Une flèche monodirectionnelle partant de la case Patrol se connecte à la case SI le joueur est en ligne de mire. La boîte étiquetée Chase se connecte à la boîte étiquetée Chase Action avec une flèche unidirectionnelle étiquetée « état ». Une flèche monodirectionnelle de la case étiquetée SI le joueur est en ligne de mire se connecte à la case étiquetée Chase. Une flèche fléchée unidirectionnelle partant de la case Patrol se connecte à la case Patrol Action. Une flèche fléchée unidirectionnelle partant de la case Chase se connecte à la case Chase Action.
Composants principaux de notre exemple de jeu furtif FSM

Contrairement à Pac-Man, l'ennemi dans notre jeu ne reviendra pas à l'état par défaut ("patrouille") une fois qu'il aura suivi le joueur.

Création de cours

Commençons par créer nos classes. Dans un nouveau dossier de scripts , nous ajouterons tous les blocs de construction architecturaux proposés en tant que scripts C#.

Implémentation de la classe BaseStateMachine

La classe BaseStateMachine est le seul MonoBehavior que nous ajouterons pour accéder à nos PNJ activés par l'IA. Par souci de simplicité, notre BaseStateMachine sera simple. Si nous le voulions, cependant, nous pourrions ajouter un FSM personnalisé hérité qui stocke des paramètres supplémentaires et des références à des composants supplémentaires. Notez que le code ne se compilera pas correctement tant que nous n'aurons pas ajouté notre classe BaseState , ce que nous ferons plus tard dans notre tutoriel.

Le code de BaseStateMachine fait référence à l'état actuel et l'exécute pour effectuer les actions et voir si une transition est justifiée :

 using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private void Awake() { CurrentState = _initialState; } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } } }

Implémentation de la classe BaseState

Notre état est du type BaseState , que nous dérivons d'un ScriptableObject . BaseState inclut une seule méthode, Execute , prenant BaseStateMachine comme argument et lui transmettant des actions et des transitions. Voici à quoi ressemble BaseState :

 using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } }

Implémentation des classes State et RemainInState

Nous dérivons maintenant deux classes de BaseState . Tout d'abord, nous avons la classe State , qui stocke les références aux actions et aux transitions, comprend deux listes (une pour les actions, l'autre pour les transitions), et remplace et appelle la base Execute sur les actions et les transitions :

 using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/State")] public sealed class State : BaseState { public List<FSMAction> Action = new List<FSMAction>(); public List<Transition> Transitions = new List<Transition>(); public override void Execute(BaseStateMachine machine) { foreach (var action in Action) action.Execute(machine); foreach(var transition in Transitions) transition.Execute(machine); } } }

Deuxièmement, nous avons la classe RemainInState , qui indique au FSM quand ne pas effectuer de transition :

 using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } }

Notez que ces classes ne seront pas compilées tant que nous n'aurons pas ajouté les FSMAction , Decision et Transition .

Implémentation de la classe FSMAction

Dans le diagramme d'architecture FSM proposée, la classe FSMAction de base est étiquetée "Action". Cependant, nous allons créer la classe FSMAction de base et utiliser le nom FSMAction (car Action est déjà utilisé par l'espace de noms .NET System ).

FSMAction , un ScriptableObject , ne peut pas traiter les fonctions indépendamment, nous allons donc le définir comme une classe abstraite. Au fur et à mesure que notre développement progresse, nous pouvons exiger qu'une seule action serve plus d'un État. Heureusement, nous pouvons associer FSMAction à autant d'états d'autant de FSM que nous le souhaitons.

La classe abstraite FSMAction ressemble à ceci :

 using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } }

Mise en œuvre des classes de Decision et de Transition

Pour terminer notre FSM, nous allons définir deux autres classes. Tout d'abord, nous avons Decision , une classe abstraite à partir de laquelle toutes les autres décisions définiraient leur comportement personnalisé :

 using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } }

La deuxième classe, Transition , contient l'objet Decision et deux états :

  • Un état vers lequel effectuer la transition si la Decision renvoie true.
  • Un autre état vers lequel passer si la Decision donne false.

Il ressemble à ceci :

 using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Transition")] public sealed class Transition : ScriptableObject { public Decision Decision; public BaseState TrueState; public BaseState FalseState; public void Execute(BaseStateMachine stateMachine) { if(Decision.Decide(stateMachine) && !(TrueState is RemainInState)) stateMachine.CurrentState = TrueState; else if(!(FalseState is RemainInState)) stateMachine.CurrentState = FalseState; } } }

Tout ce que nous avons construit jusqu'à présent devrait être compilé sans aucune erreur. Si vous rencontrez des problèmes, vérifiez votre version de Unity Editor, qui peut provoquer des erreurs si elle n'est pas à jour. Assurez-vous que tous les fichiers ont été correctement clonés à partir du dossier de projet d'origine et que toutes les variables accessibles publiquement ne sont pas déclarées privées.

Création d'actions et de décisions personnalisées

Maintenant que le gros du travail est fait, nous sommes prêts à implémenter des actions et des décisions personnalisées dans un nouveau dossier de scripts .

Implémentation des classes Patrol et Chase

Lorsque nous analysons le diagramme FSM des composants de base de notre exemple de jeu furtif, nous constatons que notre PNJ peut être dans l'un des deux états suivants :

  1. État de patrouille — Associés à l'état sont :
    • Une action : le PNJ visite des points de patrouille aléatoires dans le monde entier.
    • Une transition : le PNJ vérifie si le joueur est en vue et, si c'est le cas, passe à l'état de poursuite.
    • Une décision : le PNJ vérifie si le joueur est en vue.
  2. État Chase — Associé à l'état, il y a :
    • Une action : le PNJ poursuit le joueur.

Nous pouvons réutiliser notre implémentation de transition existante via l'interface graphique de Unity, comme nous le verrons plus tard. Cela nous laisse deux actions ( PatrolAction et ChaseAction ) et une décision à coder.

L'action d'état de patrouille (qui dérive de la base FSMAction ) remplace la méthode Execute pour obtenir deux composants :

  1. PatrolPoints , qui suit les points de patrouille.
  2. NavMeshAgent , l'implémentation de Unity pour la navigation dans l'espace 3D.

Le remplacement vérifie ensuite si l'agent AI a atteint sa destination et, si c'est le cas, se déplace vers la destination suivante. Il ressemble à ceci :

 using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Patrol")] public class PatrolAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var patrolPoints = stateMachine.GetComponent<PatrolPoints>(); if (patrolPoints.HasReached(navMeshAgent)) navMeshAgent.SetDestination(patrolPoints.GetNext().position); } } }

Nous pouvons envisager de mettre en cache les composants PatrolPoints et NavMeshAgent . La mise en cache nous permettrait de partager des ScriptableObject pour les actions entre les agents sans l'impact sur les performances de l'exécution de GetComponent sur chaque requête de la machine à états finis.

Pour être clair, nous ne pouvons pas mettre en cache les instances de composant dans la méthode Execute . Donc, à la place, nous ajouterons une méthode GetComponent personnalisée à BaseStateMachine . Notre GetComponent personnalisé mettrait en cache l'instance la première fois qu'elle est appelée, renvoyant l'instance mise en cache lors d'appels consécutifs. Pour référence, voici l'implémentation de BaseStateMachine avec mise en cache :

 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() { CurrentState = _initialState; _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } 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; } } }

Comme son homologue PatrolAction , la classe ChaseAction remplace la méthode Execute pour obtenir les composants PatrolPoints et NavMeshAgent . En revanche, après avoir vérifié si l'agent IA a atteint sa destination, l'action de classe ChaseAction définit la destination sur Player.position :

 using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Chase")] public class ChaseAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var enemySightSensor = stateMachine.GetComponent<EnemySightSensor>(); navMeshAgent.SetDestination(enemySightSensor.Player.position); } } }

Implémentation de la classe InLineOfSightDecision

La dernière pièce est la classe InLineOfSightDecision , qui hérite de la Decision de base et obtient le composant EnemySightSensor pour vérifier si le joueur est dans la ligne de mire du PNJ :

 using Demo.Enemy; using Demo.FSM; using UnityEngine; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Decisions/In Line Of Sight")] public class InLineOfSightDecision : Decision { public override bool Decide(BaseStateMachine stateMachine) { var enemyInLineOfSight = stateMachine.GetComponent<EnemySightSensor>(); return enemyInLineOfSight.Ping(); } } }

Associer des comportements à des états

Nous sommes enfin prêts à attacher des comportements à l'agent Enemy . Ceux-ci sont créés dans la fenêtre de projet de l'éditeur Unity.

Ajout des états Patrol et Chase

Créons deux états et nommons-les "Patrol" et "Chase":

  • Clic droit > Créer > FSM > État

Pendant qu'ici, créons également un objet RemainInState :

  • Clic droit > Créer > FSM > Rester dans l'état

Il est maintenant temps de créer les actions que nous venons de coder :

  • Clic droit > Créer > FSM > Action > Patrouille
  • Clic droit > Créer > FSM > Action > Chase

Pour coder la Decision :

  • Clic droit > Créer > FSM > Décisions > En ligne de mire

Pour activer une transition de PatrolState à ChaseState , créons d'abord l'objet scriptable de transition :

  • Clic droit > Créer > FSM > Transition
  • Choisissez un nom que vous aimez. J'ai appelé le mien Spotted Enemy.

Nous remplirons la fenêtre d'inspection résultante comme suit :

L'écran Spotted Enemy (Transition) comprend quatre lignes : la valeur du script est définie sur "Transition" et est grisée. La valeur de la décision est définie sur "LineOfSightDecision (In Line Of Sight)". La valeur de True State est définie sur "ChaseState (State)". La valeur de False State est définie sur "RemainInState (Remain In State)".
Remplir la fenêtre d'inspection de l'ennemi repéré (transition)

Ensuite, nous compléterons la boîte de dialogue de l'inspecteur Chase State comme suit :

L'écran Chase State (State) commence par une étiquette "Open". A côté de l'étiquette "Script" "State" est sélectionné. A côté de l'étiquette "Action", "1" est sélectionné. Dans le menu déroulant "Action", "Element 0 Chase Action (Chase Action)" est sélectionné. Il y a un signe plus et un signe moins qui suit. À côté du libellé "Transitions", "0" est sélectionné. Dans la liste déroulante "Transitions", "La liste est vide" s'affiche. Il y a un signe plus et un signe moins qui suit.
Remplissage de la fenêtre de l'inspecteur d'état de poursuite

Ensuite, nous allons compléter la boîte de dialogue Patrol State :

L'écran Patrol State (State) commence par une étiquette "Open". A côté de l'étiquette "Script" "State" est sélectionné. A côté de l'étiquette "Action", "1" est sélectionné. Dans la liste déroulante "Action", "Action de patrouille de l'élément 0 (action de patrouille)" est sélectionné. Il y a un signe plus et moins qui suit. À côté de l'étiquette "Transitions", "1" est sélectionné. Dans la liste déroulante "Transitions", "Element 0 SpottedEnemy (Transition)" s'affiche. Il y a un signe plus et un signe moins qui suit.
Remplissage de la fenêtre de l'inspecteur d'état de patrouille

Enfin, nous ajouterons le composant BaseStateMachine à l'objet ennemi : dans la fenêtre Project de l'éditeur Unity, ouvrez l'asset SampleScene, sélectionnez l'objet Enemy dans le panneau Hierarchy et, dans la fenêtre Inspector, sélectionnez Add Component > Base State Machine :

L'écran Base State Machine (Script) : à côté de l'étiquette "Script" grisée, "BaseStateMachine" est sélectionné et grisé. A côté de l'étiquette « État initial », « PatrolState (État) » est sélectionné.
Ajout du composant Base State Machine (Script)

Pour tout problème, vérifiez que vos objets de jeu sont correctement configurés. Par exemple, confirmez que l'objet Enemy inclut le composant de script PatrolPoints et les objets Point1 , Point2 , etc. Ces informations peuvent être perdues avec une version incorrecte de l'éditeur.

Vous êtes maintenant prêt à jouer à l'exemple de jeu et à observer que l'ennemi suivra le joueur lorsque celui-ci entrera dans la ligne de mire de l'ennemi.

Utiliser les FSM pour créer une expérience utilisateur amusante et interactive

Dans ce didacticiel sur les machines à états finis, nous avons créé une IA hautement modulaire basée sur FSM (et le référentiel GitHub correspondant) que nous pouvons réutiliser dans de futurs projets. Grâce à cette modularité, nous pouvons toujours ajouter de la puissance à notre IA en introduisant de nouveaux composants.

Mais notre architecture ouvre également la voie à une conception FSM d'abord graphique, ce qui élèverait notre expérience de développeur à un nouveau niveau de professionnalisme. Nous pourrions alors créer des FSM pour nos jeux plus rapidement et avec une meilleure précision créative.