Les machines à états finis sont une manière courante de structurer un programme. Mais savez-vous comment en créer une efficace tout en bénéficiant des meilleures pratiques de programmation orientée objet?
Quels avantages pourriez-vous tirer de l’utilisation du State Pattern dans votre code?
- State Pattern vous permet de modifier son comportement au moment de l’exécution, autant de fois que vous le souhaitez
- State Pattern vous permet de définir des comportements indépendamment les uns des autres
- State Pattern réduit la complexité des structures conditionnelles
A titre d’exemple, je vais utiliser un petit jeu vidéo tactique au tour par tour que j’ai réalisé en Javascript (semblable à Advance Wars, ou aux échecs): il est composé d’une grille de bataille, sur laquelle se trouvent des unités appartenant à deux joueurs différents. Chaque joueur déplace ses unités pendant son tour et attaque les unités de l’autre joueur.
Comment reconnaître les machines à états?
Tout d’abord, vous devez reconnaître quand le State Pattern peut être appliqué pour profiter de tous ses avantages. Avec un peu d’entraînement, vous pourrez le visualiser dans presque n’importe quelle base de code!
Choisissons quelques-unes des user stories de mon jeu pour lancer notre développement:
- Si aucune unité n’est sélectionnée, je peux sélectionner une unité alliée.
- Si une unité alliée est sélectionnée, je peux la déplacer.
- Si une unité alliée s’est rapprochée d’une unité ennemie, je peux attaquer l’unité ennemie avec l’unité alliée.
Que se passe-t-il lorsque j’essaie de mettre en œuvre toutes ces user stories de manière procédurale? Je me retrouve alors avec un algorithme comme le suivant:
// Référence au joueur dont c'est au tour de jouer
let activePlayer
// Référence à une unité
let selectedUnit
/ **
* Déclenchement sur un événement de clic de souris.
*
* @param Tile tileUnderPointer Référence la case qui se trouve sous le pointeur de la souris au moment où l'événement de clic est émis.
* /
function pointerDown (tileUnderPointer) {
if (selectedUnit === null) {
if (tileUnderPointer.unit! == null &&
tileUnderPointer.unit.player === activePlayer &&
(tileUnderPointer.unit.hasMoved === false || tileUnderPointer.unit.hasAttacked === false)) {
selectedUnit = tileUnderPointer.unit
}
} else if (selectedUnit.hasMoved === false) {
if (tileUnderPointer.unit === null &&
calculerDistance (selectedUnit, tileUnderPointer) <= selectedUnit.moveRange) {
moveUnit (selectedUnit, tileUnderPointer)
} autre {
selectedUnit = null
}
} else if (selectedUnit.hasAttacked === false) {
if (tileUnderPointer.unit! == false &&
tileUnderPointer.unit.player! == activePlayer &&
calculerDistance (selectedUnit, tileUnderPointer) <= selectedUnit.attackRange) {
résoudreCombat (selectedUnit, tileUnderPointer.unit)
}
selectedUnit = null
}
}
Vous avez probablement remarqué que j’ai appelé plusieurs fonctions telles que calculerDistance()
et moveUnit()
dans le code ci-dessus. Je vous passe les détails de leurs implémentations car ce n’est pas le but de cet article (ou c’est juste une excuse à ma paresse…). Mais si vous êtes intéressé par le code source complet, vous trouverez un lien vers celui-ci à la fin de cet article¹.
Ce qui est important ici, c’est que j’ai donné deux responsabilités distinctes à la même fonction:
- Vérification de l’état dans lequel se trouve le programme (unité sélectionnée ou non, s’est déplacée ou non, a attaqué ou non)
- Calcul du résultat de l’action du joueur (un clic sur une tuile donnée de la grille)
Cette variable globale contrôlant le comportement de l’algorithme permet d’identifier une machine à états finis dans mon programme. La prochaine étape consistera à identifier les états, ainsi que les transitions d’un état à un autre. Allons-y.
États et transitions
Avant de vous donner ma définition personnelle d’une machine à états, lisons ce que notre source de vérité universelle (Wikipedia) dit à leur sujet²:
On rencontre couramment des automates finis dans de nombreux appareils qui réalisent des actions déterminées en fonction [d’une séquence] d’événements qui se présentent [dans un ordre donné].
Wikipédia – Automate fini
C’est une définition très formelle, mais voici les points qui me paraissent importants:
- Les actions disponibles dépendent de l’état dans lequel se trouve le programme (par exemple: j’ai déplacé une unité près d’un ennemi pour pouvoir attaquer).
- Les événements déclenchent des transitions d’un état à un autre (par exemple: la sélection d’une unité amie donne au joueur la possibilité de la déplacer).
Du coup je me pose la question de ce qui pourrait constituer une telle séquence dans mon jeu tactique…
Mais ceci ne représente qu’une séquence spécifique, alors qu’il pourrait y avoir de nombreuses séquences possibles…
Afin de représenter tous les différents états et transitions dans une machine à états, nous dessinons généralement des schémas comme celui-ci:
Une machine à états ne gère que les transitions qui mènent d’un état à un autre. Les états calculent leur propre logique métier en interne. Nous allons donc pouvoir profiter de la programmation orientée objet pour gérer chacun des différents comportements pouvant survenir à partir du même événement (le clic de l’utilisateur).
Encapsuler et déléguer
Après toute cette mise en place, il serait peut-être temps de donner une définition du State Pattern. En voici une tirée du livre Head First: Design Patterns³:
Le State Pattern permet à un objet de modifier son comportement lorsque son état interne change. L’objet paraîtra avoir changé de classe.
Tête la première: Design Patterns, 410.
Nous avons donc maintenant un objet, que nous appellerons le contexte. Le contexte contient un état en tant que propriété interne. Mais cet état est également un objet (on ne parle pas d’approche «orientée objet» pour rien…).
Voici mon implémentation pour le contexte et l’interface du State Pattern: cette interface ne sera utilisée que pour définir la structure de chacun de nos objets représentant un état.
class GameScene extends Phaser.Scene {
create() {
// ...
this.state = new SelectState ({activePlayer: this.player[0]s, cursor: cursor}, this)
this.input.on ('pointerdown', () => {
this.state.pointerDown (this.input.activePointer.position)
})
}
}
class GameState {
constructor (previousState, scène) {
this.activePlayer = previousState.activePlayer
this.scene = scène
this.cursor = previousState.cursor
}
pointerDown (position) {
// Cette méthode doit être surchargée dans les classes enfants.
}
}
GameScene
est notre contexte, qui a besoin que son comportement soit modifié. Il délègue donc la gestion de l’événement pointerdown
à la méthode pointerDown()
de l’objet qui représente son état actuel. Voici mon implémentation pour ces objets d’état:
class SelectState extends GameState {
// ...
pointerDown (targettedTile) {
let hoveredUnit = targettedTile.getUnit ();
if (hoveredUnit && hoveredUnit.player === this.activePlayer && false === hoveredUnit.hasMoved) {
this.scene.state = new MoveState (Object.assign (this, {startPosition: targettedTile, selectedUnit: hoveredUnit}), this.scene);
}
// sinon on reste sur SelectState
}
}
class MoveState extends GameState {
// ...
pointerDown (targettedTile) {
if (this.canMoveSelectedUnitTo (targettedTile)) {
this.moveSelectedUnit (targettedTile);
if (this.getEnemiesInRangeFrom (targettedTile) .length> 0) {
this.scene.state = new AttackState (this, this.scene);
} autre {
this.scene.state = new SelectState (this, this.scene);
}
} else {
this.scene.state = new SelectState (this, this.scene);
}
}
}
class AttackState extends GameState {
// ...
pointerDown (targettedTile) {
if (this.potentialTargets.find (potentialTarget => potentialTarget === targettedTile)) {
this.attackUnitOn (targettedTile);
}
this.scene.state = new SelectState (this, this.scene);
}
}
Étant donné que les classes d’état implémentent toutes une méthode nommée pointerDown()
, nous pouvons appeler cette méthode quel que soit l’état actuel. C’est le principe du polymorphisme.
Voici mes classes représentées par un diagramme UML:
Flexibilité et modification du code
Dans le code ci-dessus, vous avez probablement remarqué que j’ai écrit la transition d’un état à un autre à l’intérieur de la méthode pointerDown()
de chaque état. Ce n’est pas obligatoire, et il existe deux possibilités:
- Les états gèrent leurs transitions. Cela signifie que chaque fois que j’ajoute un nouvel état, je devrai modifier chaque état qui a une transition vers ce nouvel état.
- Le contexte gère toutes les transitions. Cela signifie qu’à chaque fois que j’ajoute un nouvel état, je devrai modifier le contexte qui contient le nouvel état.
Il n’y a pas de meilleure solution, car dans chaque scénario, je devrai modifier plusieurs classes, et pas uniquement celle que je voulais ajouter ou modifier en premier lieu. Le choix sera principalement dicté par la quantité de logique conditionnelle qui doit être effectuée pour gérer ces transitions.
- Si un état transite toujours vers un même autre état, je peux facilement définir les transitions dans le contexte.
- Au contraire, si un état a besoin de vérifier des conditions avant de choisir l’état vers lequel va transiter, il sera plus facile d’avoir ces conditions codées dans chaque état. Sinon cela risque d’encombrer le code du contexte.
Autres usages du State Pattern
Dans cet article, j’ai montré comment le State Pattern pouvait être appliqué à un jeu vidéo. Mais ce n’est pas le seul cas où vous pourriez l’appliquer!
Par exemple, un logiciel de manipulation d’images comme Photoshop aurait également besoin de gérer différents comportements en fonction de l’outil que l’utilisateur a sélectionné (le pinceau, la gomme, le lasso, etc.).
De plus, le State Pattern n’est pas limité à gérer uniquement les clics des utilisateurs! Prenez par exemple un lecteur vidéo, voici quelques états dans lesquels il pourrait se trouver: LoadingState, PlayingState, PausedState
.
Cela vous donne des idées sur les occasions où vous pourriez appliquer le State Pattern? N’hésitez pas à les partager dans les commentaires!
Sources
- ⬑ «Siege Wars», GitHub, dernière modification le 29 juillet 2020, https://github.com/raaaahman/siege-wars
- ⬑«Automate fini», Wikipedia, dernière modification le 20 février 2020, https://fr.wikipedia.org/wiki/Automate_fini
- ⬑Eric Freeman, Elisabeth Robson, Bert Bates, Kathy Sierra, «L’état des choses: State Pattern», dans Tête la première: Design Patterns, (O’Reilly Media, Inc., 2005), 397-440.