Développement Unity AI : un didacticiel sur les machines à états finis
Publié: 2022-03-11Dans 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 :
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.
É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 :
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 :
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
Decisionrenvoie true. - Un autre état vers lequel passer si la
Decisiondonne 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 :
- É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.
- É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 :
-
PatrolPoints, qui suit les points de patrouille. -
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 :
Ensuite, nous compléterons la boîte de dialogue de l'inspecteur Chase State comme suit :
Ensuite, nous allons compléter la boîte de dialogue Patrol State :
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 :
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.
