Comment utiliser XState pour gérer l'état d'un jeu mobile React Native?

author
Andréas Hanss · Apr 5, 2022
dev | 7 min
Image descriptive

XState est une bibliothèque permettant de créer, d'interpréter et d'exécuter des machines à états finis et des diagrammes d'états, ainsi que de gérer les invocations de ces machines en tant qu'acteurs. Les concepts informatiques fondamentaux suivants sont importants pour savoir comment faire le meilleur usage de XState, et en général pour tous vos projets logiciels actuels et futurs.

Cela tombe bien car lorsque nous avons réalisé Jerry, l'application de blagues qui vous propose de rire entre amis nous avons eu besoin de gérer des transitions d'états pour gérer les différentes phases qui s'enchainent pour le jeu.

Dans cet article nous allons détailler comment ont été modélisés les états et les transitions entre les 3 phases de jeu d'une des premières version de Jerry :

  • La pioche d'une blague aléatoire
  • Le lancement d'une chronomètre où il ne faut pas rire
  • Le choix du perdant

Comme nous le suggère la méthodologie pour déterminer une machine à état commençons par définir la liste des états, les évènements (permettant de déclencher les transitions) et l'état initial.

La définition de l'état de notre machine

  • choose_joke : L'état dans lequel on tire au hasard une blague.
  • timer_running : L'état dans lequel le décompte de temps est affiché
  • choose_loser : L'état dans lequel on choisit si quelqu'un à rigolé à la plaisanterie et qui déclenchera le gain d'un point pour l'équipe gagnante !

Et le contexte, qui sont des variables sur lesquelles on peut agir, parfois aussi nommé état, mais au sens plus large.

1
type GameEngineContext = {
2
current_joke: string | null;
3
color?: string;
4
/**
5
* !!! Maximum 2 teams
6
*/
7
teams_score: Record<string, number>;
8
};

current_joke nous permet de récupérer une chaine de caractères qui représente notre blague actuelle, color sert à donner la couleur de fond utilisée pour afficher la blague, celle-ci est tirée aléatoirement à chaque cycle et enfin teams_score sert à définir l'avatar de chaque équipe ainsi que le nombre de points dans la partie.

La définition des évènements de notre machine

  • GET_NEW_JOKE : Est un évènement qui permet de tirer une nouvelle blague
  • START_TIMER : Est un évènement qui permet de démarrer le décompte de temps
  • SELECT_WINNER : Est un évènement qui permet d'indiquer le gagnant du duel en cours.

Commençons par partager le code de notre machine et expliquons chaque éléments les uns après les autres.

1
import _ from "lodash";
2
import { assign, createMachine, DoneInvokeEvent } from "xstate";
3
import { jokeAPIJokeProvider } from "../joke/provider/BlaguesAPIJokeProvider";
4
5
type GameEngineContext = {
6
current_joke: string | null;
7
color?: string;
8
/**
9
* !!! Maximum 2 teams
10
*/
11
teams_score: Record<string, number>;
12
};
13
14
type GameEngineEvents =
15
| { type: "GET_NEW_JOKE" }
16
| { type: "START_TIMER" }
17
| { type: "SELECT_WINNER"; winner: string };
18
19
export const GameEngineMachineFactory = () =>
20
createMachine<GameEngineContext, GameEngineEvents>(
21
{
22
id: "game_engine",
23
initial: "choose_joke",
24
context: {
25
current_joke: null,
26
teams_score: {
27
"🐊": 0,
28
"🦁": 0,
29
},
30
},
31
states: {
32
choose_joke: {
33
entry: "getRandomBackgroundColor",
34
invoke: {
35
id: "getNewJoke",
36
src: "getJoke",
37
onDone: {
38
actions: assign<GameEngineContext, DoneInvokeEvent<string>>({
39
current_joke: (_, joke) => joke.data,
40
}),
41
},
42
},
43
on: {
44
START_TIMER: "timer_running",
45
GET_NEW_JOKE: "choose_joke",
46
},
47
},
48
timer_running: {
49
after: {
50
LAUGH_TIMER: "choose_loser",
51
},
52
},
53
choose_loser: {
54
on: {
55
SELECT_WINNER: [
56
{
57
cond: "winnerIsNotNull",
58
target: "choose_joke",
59
actions: "updateTeamsPoints",
60
},
61
{ target: "choose_joke" },
62
],
63
},
64
},
65
},
66
},
67
{
68
actions: {
69
getRandomBackgroundColor: assign<GameEngineContext, GameEngineEvents>({
70
color: () => _.sample(["bg-cyan-500", "bg-green-400", "bg-red-400"]),
71
}),
72
updateTeamsPoints: assign({
73
teams_score: (ctx, event) => {
74
if (event.type !== "SELECT_WINNER") return ctx.teams_score;
75
76
return Object.assign(ctx.teams_score, {
77
[event.winner]: ctx.teams_score[event.winner] + 1,
78
});
79
},
80
}),
81
},
82
services: {
83
getJoke: jokeAPIJokeProvider.getJoke,
84
},
85
guards: {
86
winnerIsNotNull: (ctx, event) =>
87
event.type === "SELECT_WINNER"
88
? Object.keys(ctx.teams_score).includes(event.winner)
89
: false,
90
},
91
delays: {
92
LAUGH_TIMER: __DEV__ ? 2000 : 10000,
93
},
94
}
95
);

Le truc bien avec XState c'est que l'on peut utiliser le vizualizer pour récupérer un diagramme représentant les états et transitions de notre machine. Pour le code ci-dessus, voici le diagramme obtenu.

Diagramme

Pour plus de détails et de contrôle il est possible de suivre ce lien pour consulter le diagramme en détails.

Explication des parties de notre machine à états

Tout d'abord on définit deux interfaces Typescript qui nous servirons à définir d'une part le contexte (équivalent de l'état qui peut changer) de notre machine, comme vu plus haut, mais également les évènements qui permettront de déclencher les transitions, grâce à cela on obtient un typage sécurisé en utilisant la généricité de la méthode createMachine.

1
type GameEngineContext = {
2
current_joke: string | null;
3
color?: string;
4
/**
5
* !!! Maximum 2 teams
6
*/
7
teams_score: Record<string, number>;
8
};
9
10
type GameEngineEvents =
11
| { type: "GET_NEW_JOKE" }
12
| { type: "START_TIMER" }
13
| { type: "SELECT_WINNER"; winner: string };

Ensuite, on continue en donnant un identifiant à la machine avec la clé id, cela sert à des fins de debug. Un état initial, dans notre cas choose_joke et un contexte par défaut qui sera probablement surchargé afin que l'on puisse injecter de la donnée.

1
id: "game_engine",
2
initial: "choose_joke",
3
context: {
4
current_joke: null,
5
teams_score: {
6
"🐊": 0,
7
"🦁": 0,
8
},
9
}

S'en suit la définition de nos différents états, commençons par choose_joke

La phase de tirage d'une blague

1
states: {
2
choose_joke: {
3
entry: "getRandomBackgroundColor",
4
invoke: {
5
id: "getNewJoke",
6
src: "getJoke",
7
onDone: {
8
actions: assign<GameEngineContext, DoneInvokeEvent<string>>({
9
current_joke: (_, joke) => joke.data,
10
}),
11
},
12
},
13
on: {
14
START_TIMER: "timer_running",
15
GET_NEW_JOKE: "choose_joke",
16
},
17
}
18
}

On voit ici que plusieurs hooks sont définis :

  • entry : permet de définir une action à exécuter à l'entrée dans l'état choose_joke. Dans notre cas on fait appel à une fonction getRandomBackgroundColor qui nous permettra de changer le contexte avec une nouvelle couleur. Cette fonction est définie par défaut dans le second argument de notre méthode createMachine, à savoir la configuration, qui notons-le peut être surcharger au moment ou nous allons exécuter notre machine. Ici rien de sorcier, on utilise lodash pour tirer aléatoirement une couleur de fond d'écran, puis on utilise la méthode assign de XState pour fusionner la donnée dans le contexte actuel de la machine.
1
actions: {
2
getRandomBackgroundColor: assign<GameEngineContext, GameEngineEvents>({
3
color: () => _.sample(["bg-cyan-500", "bg-green-400", "bg-red-400"]),
4
})
5
}

Ensuite nous avons invoke, ce qu'on appelle un service dans XState.

  • id permet de cibler ce service plus tard et de mieux le repérer dans le générateur de diagramme.
  • src permet d'indiquer la fonction qui retourne une promesse à exécuter dans le cadre de ce service, ici pour un meilleur debug et une meilleur visualisation on utilise encore une référence textuelle à une fonction qui renvoie une blague de manière asynchrone
  • onDone est l'action à exécuter quand la promesse résous, dans notre car on déclenche une action qui vient modifier le contexte de notre machine pour mettre à jour l'interface. On pourrait également transitionner vers un autre état si l'on voulait plus de granularité, ce n'est pas le cas ici.
  • Cela n'a pas été fait mais on aurait également pu gérer un onError pour le cas où la promesse échoue.

1
invoke: {
2
id: "getNewJoke",
3
src: "getJoke",
4
onDone: {
5
actions: assign<GameEngineContext, DoneInvokeEvent<string>>({
6
current_joke: (_, joke) => joke.data,
7
}),
8
},
9
},

Enfin nous avons la clé on qui permet de définir les réponses aux évènements qui correspondent à notre définition.

  • START_TIMER permet de déclencher le changement de phase vers l'état du timer.
  • GET_NEW_JOKE permet de changer la blague en re-exécutant les hooks vu plus haut.
1
on: {
2
START_TIMER: "timer_running",
3
GET_NEW_JOKE: "choose_joke",
4
}

La phase du décompte de temps

Ici très simple, on utilise l'instruction after pour définir une période de temps après laquelle on exécutera une action. Dans notre cas c'est un changement d'état vers choose_loser.

Encore une fois on utilise une référence textuelle vers la configuration de la machine mais on aurait pu simplement passer la durée en clé (ce qui est moins pratique mais plus simple pour tester).

Ici, selon l'environnement React Native que l'on soit en mode développement ou production la durée est raccourcie à 2 secondes au lieu de 10 secondes pour développer plus facilement.

1
timer_running: {
2
after: {
3
LAUGH_TIMER: "choose_loser",
4
},
5
}
6
7
// …
8
delays: {
9
LAUGH_TIMER: __DEV__ ? 2000 : 10000,
10
},

La phase de choix du perdant

On atterri ensuite dans la phase de choix du perdant, où si quelqu'un à rigolé durant la phase précédente il fait marquer un point à l'équipe adverse.

Ici on utilise encore l'instruction on sauf qu'on y rajoute un paramètre sous forme de tableau.

XState va évaluer dans l'ordre les éléments en y appliquant la condition définie et cond et référencée en option de la machine. Lorsqu'une condition sera bonne XState s'arrêtera et exécutera les actions définies dans le bloc.

Dans notre cas on vérifie si l'évènement à reçu un argument avec la référence vers le perdant, si c'est le cas on adapte le score en cohérence, sinon on migre simplement vers l'étape choose_joke

1
choose_loser: {
2
on: {
3
SELECT_WINNER: [
4
{
5
cond: "winnerIsNotNull",
6
target: "choose_joke",
7
actions: "updateTeamsPoints",
8
},
9
{ target: "choose_joke" },
10
],
11
},
12
},
13
14
// Le guard correspond à la cond plus haut
15
guards: {
16
winnerIsNotNull: (ctx, event) =>
17
event.type === "SELECT_WINNER"
18
? Object.keys(ctx.teams_score).includes(event.winner)
19
: false,
20
},
21
22
// Et l'action met à jour le score
23
updateTeamsPoints: assign({
24
teams_score: (ctx, event) => {
25
if (event.type !== "SELECT_WINNER") return ctx.teams_score;
26
return Object.assign(ctx.teams_score, {
27
[event.winner]: ctx.teams_score[event.winner] + 1,
28
});
29
},
30
}),

XState côté frontend et affichage

Maintenant que l'on a réussi à définir notre machine à états, comment utiliser cette dernière avec React ? Et bien c'est très simple grâce à la librairie pour react fournie par XState.

1
// En second argument on peut injecter des surcharges de configuration
2
const [{ context, matches }, send] = useMachine(GameEngineMachineFactory, {
3
context: {
4
teams_score: params,
5
},
6
});
7
8
// Accéder au contexte (état)
9
context.current_joke // null ou une blague comme "Qu'est-ce qu'un indien qui rentre dans une pharmarcie ? Un douliprane (indou liprane)"
10
11
// Vérifier dans quel état nous sommes
12
{matches("timer_running") && (
13
<>
14
<Text style={tw("text-white text-xl font-semibold")}>
15
Il va falloir tenir sans rire maintenant…
16
</Text>
17
<CountDown />
18
</>
19
)}
20
21
// Et send qui permet d'envoyer un nouvel évènement pour déclencher une transition si possible
22
{Object.keys(context.teams_score).map((team, index, teams) => (
23
<AppButton
24
key={team}
25
title={team}
26
onPress={() =>
27
send("SELECT_WINNER", {
28
winner: index === 0 ? teams[1] : teams[0],
29
})
30
}
31
disableAutoAnimation
32
size="l"
33
/>
34
))}